Skip to content

Order Checkout (QRIS, Cash & COD) ​

What it does ​

Table ordering got a customer from a scanned QR code to a full cart. This closes the loop: the cart page — already showing the guest their items and total — is also the payment page. Its footer button reads "Bayar dengan QRIS · {total}"; there's no separate checkout page recapping the same cart first. Tapping it asks the guest's name (prefilled if they've ordered from this phone before) and, when more than one payment method is enabled, a choice between them: QRIS, Cash, or COD. Choosing QRIS takes them straight to /orders/{reference}, a QRIS QR code generated on the spot at a URL keyed to that payment — reloading the page, backing out and returning, or reopening a bookmark all show the same live QR until it's paid or expires, instead of losing it. Because the guest is paying on the same phone the QR is displayed on, they can't point its camera at its own screen — so the screen also offers "Download QR", saving the code as an image they open from their banking or e-wallet app's "pay from gallery" flow.

Choosing cash sends the guest to the same URL, but showing a "Bayar di kasir {lokasi}" instruction screen instead: their daily transaction number as the largest thing on it — the same short number the cashier already reads out and the KDS already prints, easier to say aloud across a counter than the 16-character payment reference underneath it — the amount, the item list, and a countdown to walk downstairs and pay before the order expires. There's nothing to scan and nothing to confirm on the guest's side; paying is the cashier's existing Pay flow on the Transactions list, exactly as it works for a walk-in sale.

Choosing COD (Cash on Delivery — a trial covered in full below) asks for one more thing before the order is placed: a photo, taken live through the phone's own camera, of the guest's surroundings at the table. There's no picking a photo from the gallery — only a shutter button — because the photo's whole job is to let a barista confirm the guest is actually in the café before anything is made.

Once the bank confirms payment, that same page flips in place to the order-status view — no second navigation, because it was the order-status page all along: the table label (large — it's how the order finds them, there's no pager and no order number), the guest's name, and what they paid for. A Transaction now exists for that order, marked paid, showing up on the POS exactly like a cashier-entered sale — badged "Order App" and filterable by source — with the right table attached so staff know where to carry it.

That order-status page doesn't stop at "paid" — it keeps watching the order through to pickup. See Order Fulfilment Status, After Payment below. And that page isn't the only way back to this order — every paid order from this session shows up in Order History, one tap away from a header button on every order-app screen.

Why it matters ​

Table ordering solved discovery and cart-building, but a guest still had to walk to the counter and pay a cashier — the actual bottleneck at peak hours was untouched. This is what makes self-ordering actually replace a trip to the till, not just make composing the order nicer.

It also had to be built without ever letting a payment credential near the customer's phone. Every DOKU call — minting the QR, checking its status, receiving DOKU's payment notification — happens inside the Go API. The order app talks only to Gatherloop's own endpoints and never holds, or could leak, a gateway secret.

Order Fulfilment Status, After Payment ​

Paying doesn't end the guest's page — it starts the part that closes the loop. The same URL that showed the QR now shows a preparing screen: the guest's pickup number as the largest thing on it, a pulsing ring and an animated ellipsis proving the page is live, the table, and the item list — with no elapsed timer or countdown anywhere, deliberately. The WhatsApp number collected at checkout means a guest who locks their phone and walks off still gets told the moment their order is ready — see Guest WhatsApp Notifications. A barista finishing the order marks it Ready from the POS (see Transactions), and the guest's open page flips itself to a green ready screen within about ten seconds, telling them to collect at the counter with their number, whether or not the WhatsApp message has arrived yet. Closing the tab raises no warning of any kind: the guest gets back to this same page from their order history, one tap away on every order-app screen.

Cancelling a Pending Payment ​

A guest who picked the wrong method — QRIS when their bank app won't open the saved QR, or cash when they'd rather not walk downstairs — isn't stuck waiting out the timer. Both the QRIS and cash countdown screens carry a secondary "Batalkan pembayaran" button below the payment instructions. Tapping it opens a confirmation ("Jika Anda sudah membayar, jangan batalkan…" for QRIS, a cashier-facing note for cash) before anything happens, and pressing the phone's own Back button while the countdown is showing opens the same confirmation instead of silently landing on a cart the guest can't edit. Confirming releases the payment — the cart it was priced from keeps every item, note and quantity — and the guest is back on the cart page, one tap from checking out again with a different method.

Closing the tab instead of pressing Back is covered too: the cart itself now knows when a payment is pending. Reopening the table's QR shows a "⏳ Menunggu pembayaran…" bar on the menu in place of the usual cart button, the cart page shows the same items read-only with a banner instead of the checkout button, and opening a menu item to add something explains the pending payment there too, offering "Batalkan & tambah item" so cancelling and adding the new item happen in one step. Every one of these surfaces offers "Lanjutkan pembayaran" back to the countdown as well, so a guest who just wants to finish paying is never stuck looking at an explanation instead of their QR.

Cancelling is safe against the guest actually having paid: the server re-checks with DOKU before it lets a QRIS cancellation go through, and a cashier can no longer collect cash for an order the guest has walked away from — that pending cash order also drops off the Kitchen Display System's "waiting at the till" list the moment it's cancelled. If the money turns out to be real after all — a guest cancels, then pays the saved QR PNG anyway — the payment that actually got the money wins, and any newer order made in the meantime is cancelled in its place rather than double-charging.

Cash on Delivery (COD) ​

COD is a trial third payment method, sitting next to QRIS and cash: the guest still pays cash at the counter, exactly like today's cash flow, but the order is only made once a barista has confirmed — from the live camera photo taken at checkout — that the guest is actually in the café. It exists for guests whose banking app won't cooperate with QRIS and who don't want to walk to the till before eating, without opening the door to an order nobody ever collects.

After the camera step, the guest lands on the same /orders/{reference} page as any other method, but showing "Menunggu konfirmasi barista…" instead of a QR or a countdown to the till — their transaction number, the item list, and a note that the order is made only once a barista confirms, with the same window (default 15 minutes) that a busy bar has to look at the photo before it's cancelled automatically and the guest's cart is preserved. The barista does that confirming from the POS — see Verifying a COD Order — and the moment they do, the guest's page flips itself to the ordinary preparing screen, the same one a paid QRIS or cash order shows, with one addition: a banner reading "Bayar Rp {jumlah} di kasir saat mengambil pesanan", since the money hasn't moved yet. That banner stays through preparing and ready and disappears the instant the cashier takes the payment through the same Pay flow as any other sale — there is no separate "confirm COD payment" screen. If the barista instead can't confirm the guest is there, the page shows "Pesanan ditolak" and sends the guest back to the cart with everything still in it; no WhatsApp message is sent for a rejection; the guest's own live status page already says so.

Cash is hidden by configuration during the trial (NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false), not deleted — from the guest's point of view COD is a superset of cash (still paid at the counter, just verified first), and ending the trial in either direction is an environment change and a redeploy, not a release.

Key capabilities ​

  • QRIS, generated per order — a fresh, amount-locked dynamic QR from DOKU for exactly what the cart totals at the moment of checkout; a later price edit by staff can never change what the guest is being asked to pay.
  • Save-and-pay-from-gallery — the guest is paying on the same device showing the QR, so "Download QR" downloads it as a PNG for their banking app's QRIS-from-gallery flow, with a long-press fallback on older iOS Safari.
  • One QR per cart — a double-tapped pay button or a reloaded screen returns the existing pending QR rather than minting a second one.
  • The QR — or the cash instructions — lives at a URL, not in memory — /orders/{reference} is a durable, reloadable page for that one payment; reloading, backing out and returning, or reopening a bookmark all show the same pending QR or cash instructions (or the paid outcome, once it's paid) instead of stranding the guest back at an empty cart.
  • Extra payment methods, picked in the same sheet — cash and COD both sit next to QRIS in the same method picker, each behind its own flag (NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED, NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED); with both off, the sheet is byte-for-byte the QRIS-only original.
  • COD's camera step never offers a gallery — a live getUserMedia viewfinder only, with a shutter button; a denied permission or an unsupported browser tells the guest to use QRIS instead, never a file picker.
  • The server gate is authoritative for COD — ORDER_COD_PAYMENT_ENABLED on the API stops an old tab, a cached bundle, or a direct call from creating a COD order (and the photo that comes with it) once the trial flag is off; the frontend flag only hides the option.
  • The cashier's existing Pay flow is the cash gateway — no separate "confirm this app order" screen exists; paying a cash order-app transaction from the Transactions list settles the payment and flips the guest's screen automatically.
  • A cash order buzzes the kitchen twice — once at checkout, so a barista can walk to an unstaffed till before the guest arrives, and again once it's paid — see Kitchen Display System (KDS).
  • Cash orders expire on their own too — a server-side sweeper, not the guest's phone, cancels an unpaid cash order after a configurable walk-and-queue window (CASH_PAYMENT_EXPIRY_SECONDS, default 10 minutes — longer than QRIS's QR-validity window, because the clock is measuring a trip to the counter, not a QR's lifetime); the guest's cart is preserved and reordering is one tap.
  • An abandoned order the guest closes the tab on is still findable — a pending payment, cash or QRIS, shows up in Order History with a "Belum dibayar" / "Menunggu pembayaran QRIS" badge, routing straight back to the instruction screen or the countdown.
  • Cart freeze while a payment is live, but never a dead end — while a payment is pending and unexpired, the cart it was priced from can't be mutated; the freeze releases itself the moment the QR or cash window expires, or the guest cancels it themselves (see Cancelling a Pending Payment) — and every screen the freeze could otherwise silently block explains it and offers a way through.
  • Two independent ways to learn a payment succeeded — DOKU's own webhook notification is the primary path, and the screen's own status poll independently re-checks with DOKU, so a missed or delayed webhook never leaves a guest staring at a paid QR that the app doesn't know is paid.
  • The server decides expiry, not the phone's clock — a countdown reaching zero triggers one last check with DOKU before anything is shown as expired, so a few seconds of clock skew can never tell a guest who just paid that their window closed.
  • Remembers the guest's name — asked once per session, offered again on the next order from the same phone, without ever becoming a login or an account.
  • Makan di sini or Bawa pulang, chosen in the same sheet — right below the WhatsApp number, "Makan di sini atau bawa pulang?" offers both as a button pair, preselected to Makan di sini, and it's asked whether or not cash is enabled. The choice is saved on the transaction whichever payment method is picked, and a takeaway order still keeps its table — staff still see where to carry the food, the same as a dine-in order.
  • Origin visible on every transaction — the POS transaction list and detail screen show a Source badge (POS / Order App) and the table it's headed to, with a matching filter.
  • A wrong payment is fixed the way a wrong cashier payment always was — no refunds, voids or partial payments exist anywhere in the POS yet, so an order-app mistake is unwound the same manual way (Unpay within 24 hours) as one at the till.
  • The status page keeps polling after "paid" — once the barista marks the order ready on the POS (see Transactions), the guest's already-open page flips to a pickup screen within about ten seconds, no reload needed.
  • No elapsed time is ever shown to the guest — the preparing screen proves it's live with motion (a pulsing ring, an animated ellipsis), never a duration or a countdown, so a longer-than-usual wait never reads as the app being stuck.
  • Leaving mid-order raises no confirmation — closing the tab or navigating away while the order is still preparing is free; the guest finds the order again under their order history header button, not a dialog telling them to stay.

For engineers ​

  • Customer-facing screens: libs/ui/src/presentation/views/screens/order/{CartScreen,OrderStatusScreen}.tsx — the cart screen owns the pre-payment recap and the "Bayar dengan QRIS" CTA (there is no separate checkout screen), components under libs/ui/src/presentation/views/components/checkout/
  • Frontend payment state machines, split across the hop: libs/ui/src/domain/usecases/checkout.ts owns the name prompt, method selection and payment creation, terminating at created with the payment in context; libs/ui/src/domain/usecases/orderStatus.ts owns everything about watching one, seeded from its SSR-fetched payment — QR polling for QRIS, clock-only polling for cash, the countdown-triggered final check, and the expiry branch. The handoff between them is the navigation to /orders/{reference}, not shared in-memory state.
  • Route entry: /t/{code}/cart (recap and pay, apps/order-web/src/pages/t/[code]/cart/index.tsx) and /orders/{reference} (apps/order-web/src/pages/orders/[reference].tsx) — /t/{code}/checkout and the old /t/{code}/status?ref= are permanent redirects, not pages
  • NEXT_PUBLIC_ORDER_CHECKOUT_ENABLED gates the cart's pay button (libs/ui/src/app/order/Cart.tsx); when unset the button renders disabled with "Checkout belum tersedia" instead of routing anywhere. NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED separately gates the cash option in the name sheet, and NEXT_PUBLIC_ORDER_CASHIER_LOCATION (default Lantai 1) is rendered in both the sheet and the cash instruction screen.
  • Gateway integration, entirely server-side: apps/api/data/doku/** implements PaymentGatewayRepository (apps/api/domain/payment_repository.go) against DOKU's SNAP QRIS Direct API — the only layer that ever holds a DOKU credential. Cash never touches this port: Payment.RequiresGateway() (apps/api/domain/payment_entity.go) is the one branch point, so every "should I call DOKU?" question fails closed for a future third method.
  • Checkout + confirmation usecases: apps/api/domain/payment_usecase.go (Checkout, ConfirmPayment, GetPaymentStatus, ExpireStalePayments — the server-side sweeper that expires an abandoned cash or QRIS payment even if nobody's phone is watching)
  • Cash settles through the cashier's own pay flow: TransactionUsecase.PayTransaction (apps/api/domain/transaction_usecase.go) calls settleOrderPayment, which flips the linked payments row to paid and converts the cart — the same PUT /transactions/{id}/pay route a walk-in sale already uses
  • Backend routes: POST /carts/current/checkout (method: qris \| cash), GET /payments/{partnerReferenceNo}, GET /payments (order history, including every pending payment, any method), POST /payments/{partnerReferenceNo}/cancel (owner session only, idempotent), and the unauthenticated-but-signature-verified POST /payments/doku/notification (apps/api/presentation/restapi/payment_route.go, VerifyDokuSignature middleware)
  • Cancel: PaymentUsecase.CancelPayment (apps/api/domain/payment_usecase.go) re-queries DOKU before giving up on a QRIS payment, then routes through the same finalizeUncollectedPayment that expiry uses, so there is one definition of "releasing a payment"; a pending payment paid late, or a cashier's late cash collection, supersedes any newer pending payment on the same cart rather than allowing a double order. Cart.pendingPayment (apps/api/domain/cart_entity.go, CartUsecase.GetCurrentCart) carries the lock onto every cart response, which is what lets the menu, item sheet, cart and cart-item-edit screens explain it without a second request. ORDER_PAYMENT_CANCEL_ENABLED is the kill switch (apps/api/utils/env.go).
  • Frontend: libs/ui/src/domain/usecases/paymentCancel.ts (PaymentCancelUsecase, idle → confirming → cancelling → settled) is shared by OrderStatusHandler, MenuListHandler and CartHandler through presentation/handlers/hooks/usePaymentCancel.ts; presentation/views/components/checkout/PaymentCancelAlert.tsx is the confirmation dialog, and presentation/views/components/cart/{PendingPaymentBar,PendingPaymentNotice}.tsx are the locked-cart surfaces on the menu and everywhere else. utils/backNavigationGuard.ts (installBackNavigationGuard, web-only, wrapping Next's Router.beforePopState) turns a Back press into the same confirmation.
  • Design doc: docs/prd-order-payment-cancellation.md — why a cancel is a new terminal payments.status rather than reusing expired (D1), why the cart is never touched by a cancel (D2), the race against a paid-late webhook and a cashier's own pay flow (D6-D8), and why the cart carries its own lock instead of a second endpoint (D18)
  • Fulfilment status is transactions.completed_at, a nullable timestamp read through the Payment.fulfillmentStatus field ToApiPayment derives (apps/api/presentation/restapi/payment_transformer.go) — the guest never reads transactions directly
  • OrderStatusUsecase (libs/ui/src/domain/usecases/orderStatus.ts) splits loaded into preparing (polls every 10s) and ready (terminal, stops polling); the two screens are OrderPreparingView / OrderReadyView (libs/ui/src/presentation/views/components/orderStatus/)
  • Design doc: docs/prd-order-fulfillment-status.md — the barista/guest state model (completed_at on transactions, D1/D2), why no duration is ever rendered (D17), and what a future kitchen display system inherits from this design
  • Transaction origin: source (pos | order) and cart_id on transactions (migration 000023), surfaced in libs/ui/src/presentation/views/components/transactions/{TransactionListItem,TransactionDetail}.tsx
  • Guest identity: customers table keyed by session id (migration 000024) — a display name and, since docs/prd-order-whatsapp-notifications.md, a WhatsApp number; still no email or cross-session identity
  • Design doc: docs/prd-order-checkout-qris-doku.md — every decision behind the above, including why the transaction is created unpaid at QR generation rather than on payment success (D4), how an abandoned checkout is disposed of (D5), and what's still explicitly out of scope (refunds, other payment methods, kitchen notifications)
  • Follow-up design doc: docs/prd-order-app-ux-round-2.md — moved checkout from its own page onto the cart page and gave the QR a durable, reloadable URL on the status page (D4, D5), retiring the old /checkout route
  • Follow-up design doc: docs/prd-order-whatsapp-notifications.md — the WhatsApp number collected at checkout and the order-ready message it triggers, replacing an earlier push-notification opt-in; see Guest WhatsApp Notifications
  • Follow-up design doc: docs/prd-order-cash-payment.md — why cash is a payments.method value rather than a new table (D1), why the cashier's existing pay flow is the cash gateway (D11), the separate cash expiry window and why it's twice QRIS's (D5), and why a cash-pending KDS notification bypasses the station rule (D9)
  • Dining option: the button pair lives in CustomerDetailsSheet.tsx, wired through CartHandler.tsx to a CHANGE_DINING_OPTION action in the checkout.ts state machine, carried as PaymentCheckoutRequest.diningOption to PaymentUsecase.Checkout. See Transactions for where it shows up on the POS side and docs/prd-dine-in-takeaway.md for the full design, including why a retried checkout still updates the reused transaction (D8)
  • COD is a payments.method value, not a separate entity, settled through the same PayTransaction flow as cash — RequiresGateway() (apps/api/domain/payment_entity.go) keeps DOKU out of it, same as cash
  • Camera capture: libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx (web getUserMedia viewfinder; index.native.tsx is an unsupported stub for pos-mobile's Metro bundle) — canvas-downscaled to ≤ 1280px, emitted as base64 JPEG, no file input in any state
  • Verification is a second axis, not a new payment status: payments.verification_status (awaiting | approved | rejected) alongside the existing status, so every existing "pending" reader stays correct; Payment.IsExpirable() (apps/api/domain/payment_entity.go) is the one place that has to ask "approved?" explicitly
  • The photo: its own table (payment_verification_photos, migration 000044), hard-deleted in the same DB transaction as the barista's decision — approve, reject, expiry and guest cancel all delete it; a sweeper backstop (DeleteOrphaned) logs any orphan at warn
  • COD checkout branch and flags: PaymentUsecase.Checkout (apps/api/domain/payment_usecase.go), ORDER_COD_PAYMENT_ENABLED and COD_VERIFICATION_EXPIRY_SECONDS (default 900s) in apps/api/utils/env.go; frontend gate NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED in libs/ui/src/app/order/Cart.tsx
  • Frontend state: CheckoutUsecase (libs/ui/src/domain/usecases/checkout.ts) owns the captured photo (CAPTURE_PHOTO/RETAKE_PHOTO); OrderStatusUsecase (libs/ui/src/domain/usecases/orderStatus.ts) adds the awaitingVerification state (3s poll), rendered by CodVerificationView.tsx; the pay-at-pickup banner is PayAtPickupBanner.tsx, shown on OrderPreparingView/OrderReadyView for an approved-but-unpaid COD order
  • Design doc: docs/prd-order-cod-payment.md — every COD decision, including why verification is a second axis rather than a new payment status (D2), why the photo lives in MySQL and is hard-deleted (D4), the 15-minute default verification window (D19), and why an uncollected approved order is delivered to the guest's table and paid there rather than written off (D17)

Built with VitePress. Content lives in docs-site/ — see the README for how to run it locally.