From a marketing site to a working gate
A digital amusement-park brand was opening in a new market with a marketing site and nothing behind it. To actually run the venue it had to sell admission online, check guests in and out at the gate, and let staff reconcile the day’s takings, all from one system instead of a card reader on the counter and a spreadsheet in the back. A fixed launch date sat at the end of the calendar, so the build had to stay playful and on-brand at the front while being strict about money and state underneath. The rule that shaped most of the design sounds obvious and isn’t: the booking lifecycle had to match how floor staff already move on a busy day, because anything that fights the floor gets bypassed by lunchtime.
One booking, many wristbands
On paper a booking is one row. Who is coming, for which slot, paid or not. That row stays the source of truth from the moment a ticket sells to the moment the guest walks out. What the floor needs is different. A family buys one booking and arrives as four people, each handed an RFID wristband, and each wristband is its own thing to follow through the day. So beneath the booking sits a set of visit sessions, one per card assignment, and the session is where check-in, priority, and status actually live.
That split earns its keep the moment reality gets messy. A group can enter together and leave separately: two sessions against the same booking, both stamped with one originalCheckInAt, so a later split never rewrites when the group actually arrived. A guest loses a wristband, staff void the old card and assign a new one, and the replacement session inherits the original check-in time rather than resetting the clock in the guest’s favour. Sessions also carry a priority that decides who the floor serves first when a slot fills up: a booking inside its slot outranks a member inside the slot, which outranks an off-slot booking, which outranks a walk-in. And a session that never checks out, the guest who drifts to the car park without scanning, ends the day marked ABANDONED instead of sitting open forever.
Why the mobile app can’t take your money
Check-out is the one place money can still change hands, because a guest who booked two hours and stayed three owes for the extra time. Two devices can run a check-out, and they are not equal on purpose. One is the desktop kiosk near the exit. The other is a Flutter app the floor staff carry.
When a wristband is scanned out, the backend reads the session’s originalCheckInAt against the booked slot and works out the overstay in minutes. If it is zero, the checkout is clean: the mobile app closes the session inside a single transaction and the guest leaves. If the overstay is above zero, the backend does not let the mobile app finish. It returns a status of redirect_to_kiosk, and the app refuses the checkout and points the guest at a kiosk.
The reason is fraud prevention. Extension fees are collected at the kiosk only, because the kiosk is wired to the venue’s payment rails and the phone in a staff member’s hand is not. Letting the app take an extension payment would put cash or a personal account in the path of a member of staff on the floor, which is the leak the boundary closes. The rule lives in the code rather than a policy manual: the path that owes money has nowhere to settle it except the kiosk.
An idempotency key rides along so the handoff is safe. The mobile attempt persists the key before it redirects, so when the same guest is scanned again at the kiosk the pending checkout resolves to that one record rather than billing twice.
// Overstay is billable, and extension fees settle at the kiosk only.
// Floor staff carry the Flutter app but never a payment terminal, so the
// app must not close a session that owes money: it hands off to a kiosk.
async function completeCheckout(sessionId: string, idempotencyKey: string) {
const session = await sessions.forCheckout(sessionId);
const overstayMinutes = minutesOver(session.originalCheckInAt, session.slot.endsAt);
if (overstayMinutes > 0) {
// Persist the key first so a rescan at the kiosk resolves to this same
// pending checkout instead of charging the guest a second time.
await checkouts.reserve({ sessionId, idempotencyKey, overstayMinutes });
return { status: 'redirect_to_kiosk', overstayMinutes };
}
// Zero overstay: nothing to charge, so the mobile path may close it out.
return prisma.$transaction(async (tx) => {
await tx.visitSession.update({
where: { id: sessionId },
data: { status: 'CHECKED_OUT', checkedOutAt: new Date() },
});
return { status: 'completed' };
});
} Five QR rails and a printer the server can’t reach
Payment here is a QR economy, and no single rail carries everyone. The kiosk speaks to five gateways, eSewa, Khalti, Fonepay, NepalPay, and NPS, and usually two are live at once. Each transaction picks one, and if the configured default has been switched off, a guarded fallback steps to another enabled gateway rather than failing the sale at the counter. Folding those options into a single point of payment is most of why the counter stays quick.
The hardware is where the architecture has to stay honest about its own reach. The receipt printer, the QR scanner, and the card terminal hang off the kiosk’s own LAN and USB, out on the floor, unreachable from the backend. So the backend does not drive them. It is the source of record, not the executor: the kiosk client fires the print or the charge, watches what the hardware actually did, and reports the outcome back, at which point the backend persists it and writes an audit-log entry. If the printer jams after a charge goes through, the record still knows the money moved and the receipt did not, which is the exact distinction a refund desk needs an hour later.
What couldn’t slip on opening day
I ran product, design, web, and engineering against a milestone plan that put the marketing surface first, then the booking and payment core, then the check-in and check-out lifecycle, and the admin back office last. The ordering was a risk call more than a preference. Revenue-critical flows went in early, so that if anything slipped, the thing that slipped was never the ability to sell a ticket at launch.
Two areas got no slack, payment and the booking lifecycle, and both were staged and reviewed before go-live. The admin tooling was checked against the days that actually break a venue, a rush at the gate, a no-show holding a slot, a refund after an overstay charge, not just the happy path where every guest behaves. The back office runs on the same backend as the storefront, so sales, sessions, and clock-in and clock-out stay in one place in real time instead of being reconciled after closing.
Outcomes
- 1 record
- 5 rails
- Real-time
- Launch-ready
- End-to-end
- Staff-fit