Foodops
All docs

Guides · Updated 2026-08-21 · veb420a6

Online Ordering Storefront (Customer Web App)

Audio narration for this article is generated with the weekly docs refresh.

Plain-English summary

The storefront is what your customers see: a mobile-first web app (React PWA) at order.foodops.io/{outlet-code} where they browse your live menu, build a cart, choose pickup or delivery, verify their phone with an OTP code, pay (card via BML, cash, or bank transfer with a receipt photo), and track the order in real time. No app install and no account password — a phone number is the identity. The merchant-side pipeline (how orders land on the POS, how the site is enabled and fed) is covered in Online Ordering Platform and Online Ordering Setup; this entry is the customer's-eye deep-dive.

When you'd use this

  • Walking a customer through "how do I order from my phone?" (voice/chat agent script)
  • Debugging a stuck checkout — which step gates on what (auth, address, payment method)
  • Explaining why an item shows for dine-in but not for delivery
  • Answering payment questions: what "Bank Transfer" means, when cash is collected, why the card page is BML-branded
  • Helping a customer re-find an order from a tracking link

The storefront at a glance

Aspect Detail
URL order.foodops.io/{outletCode} (prod), dev-order.foodops.io (staging)
Table QR order.foodops.io/table/{tableId}/login — dine-in, pre-tied to the table
Tracking link order.foodops.io/orders/{orderId} — works even when logged out (OTP gate)
Frontend React 19 + Vite PWA (foodops.ordering.app@main)
Backend Blazor-hosted ASP.NET API at order-api.foodops.io (foodops.ordering.api@main, IIS)
Identity Phone + OTP for delivery/login checkout; no passwords ever. Dine-in and takeaway also support anonymous guest checkout (name only, no phone/OTP) via a "Checkout as guest" option
Real-time SignalR /orderHub (status) + /groupCartHub (group carts)

Walkthrough — customer's view

The umbrella entry's walkthrough covers outlet selection, menu browse, cart, anonymous checkout, order modes, group cart, and tracking step-by-step with screenshots 141–148. This section documents the layers that entry does not: availability display, the OTP plumbing, payments, and the tracking-link flow.

1. Entry and menu availability

  • Three entry points: direct outlet URL (resolved via GET /menu/outlet/reference/{code}), table QR, or the bare-root outlet picker. [code: src/router.tsx; src/components/outlet/OutletValidator.tsx]
  • Every menu request carries X-Order-Mode (dine-in when a table is bound, else pickup-delivery); the API filters categories/products/search by the item-variant's per-channel flags, so a delivery customer never sees dine-in-only items. The header is parsed case-insensitively and unknown values skip filtering. [code: src/lib/api/client.ts; foodops.ordering.api: Foodops.Ordering.Models/Menu/OrderMode.cs, MenuController.cs]
  • Staff menu edits propagate live: the FoodOps menu backend POSTs api/menu/notify-update to the ordering API, which invalidates its cache and broadcasts MenuUpdated to the organisation's SignalR group — the storefront resets its menu cache and shows a toast. (Receiver confirmed on the ordering API; the notifier moved off the now-retired monolith — see Online Ordering Setup for the open emitter-location note.) [code: foodops.ordering.api: MenuUpdateController.cs; src/features/menu/pages/MenuPage.tsx]
  • When the outlet's IsAcceptingOnlineOrders flag is off, an amber "{outlet} is not accepting online orders at this time" banner shows on the menu and above the cart CTA, and the server rejects submission. Dine-in (table QR) and group-cart orders are exempt. [code: src/features/menu/pages/MenuPage.tsx; foodops.ordering.api: OrderSubmissionService.cs]

2. Cart and modifier rules

  • Anonymous carts live in localStorage and still get server-computed totals (tax + service fees) via POST /cart/totals/anonymous; authenticated carts are server-side (GET /cart, GET /cart/totals). Multi-tax (e.g. CGST/SGST) renders one row per component. [code: src/components/cart/CartSidebar.tsx; src/components/cart/CartTotals.tsx]
  • Modifier groups enforce min/max per item and, since 2026-06-08, a maximum total quantity across the group (MaximumGroupQuantitySelection) — validated client-side in the item dialog and re-validated server-side at submission. [code: app 5bd675f; api 2a9e64b OrderSubmissionService]
  • Back-button behaviour: drawers/dialogs (cart, item details, auth sheet) register overlay history entries, so the phone's back gesture closes the overlay instead of leaving the site (shipped 2026-06-04). [code: src/hooks/useOverlayHistory.ts, b0b419a]

3. Checkout — anonymous until payment, then OTP

A guest can browse, build a cart, and pick an order type with no account. For delivery orders (or if they choose to sign in), the auth sheet appears at the CTA ("Login to place order"). For dine-in and takeaway, anonymous carts also show a "Checkout as guest" option: it opens a name-only sheet (min 2 chars, no phone/OTP) and submits the cart items inline via POST /order/guest-submit. On success the cart clears and an order reference is shown, but the customer is not sent to the trackable /orders list (that requires login) and the CSAT survey is skipped for guest orders.

  1. Phone step — enter phone, "Request OTP". The request carries the outletId so the backend picks the SMS provider for the outlet's country. [code: src/components/auth/PhoneStep.tsx]
  2. OTP routing (server)SmsServiceFactory matches the outlet's organisation country name: India → Fast2SMS; everything else (incl. Maldives) → MsgOwl (MsgOwl is the default/fallback, also used when country resolution fails). [code: foodops.ordering.api: Foodops.Ordering.Core/Services/SmsServiceFactory.cs:36-43]
  3. OTP step — 6-digit code, resend countdown.
  4. Name step — only if the customer record has no name; minimum 2 chars. That's the entire registration: phone + name.
  5. Cart hand-off — the local cart is replayed to the server (POST /cart/add per item) and the order submits without a second tap. [code: src/hooks/useCartSync.ts; src/components/payment/PaymentButton.tsx]

4. Pickup vs delivery

  • The cart drawer shows Takeout / Delivery buttons (plus Dine In when a table is bound). [code: src/components/cart/CartSidebar.tsx]
  • Delivery requires a saved address — the AddressSelector is auth-gated, and addresses are managed from the Profile page. Delivery sectors (zones with per-sector charges) are configured by the merchant; see Deliveries. [code: src/components/cart/AddressSelector.tsx; src/features/profile/components/AddressManagement.tsx]
  • Takeout (pickup): no address; the customer collects at the outlet when the tracker reaches "Ready"/"Picked Up".

5. Payment options

The payment-method sheet offers up to three options, each individually enabled per outlet (GET api/payment/methods/outlet/{outletId}/enabled): [code: src/components/payment/ChoosePaymentMethodModal.tsx; foodops.ordering.api: PaymentController.cs]

Method Label/copy in app How it works
BML card "Bank of Maldives (BML)" — "Pay securely with BML gateway" POST api/payment/bml/initiate redirects to the BML hosted gateway; order confirms on gateway success. [code: BmlPaymentService.cs]
Cash "Cash" — "Pay cash on pickup/delivery" POST api/payment/cash/process records a cash-due order; cash is collected at pickup or by the rider (then flows through the rider cash loop — see Rider App).
Bank transfer "Bank Transfer" — "Transfer and upload receipt" Customer transfers, then photographs/uploads the slip (POST api/payment/transfer/upload-receipt → blob storage → transfer/process); staff verify the slip on the counter. AI slip check (RE-CONFIRMED present on the new stack, 2026-06-29): at upload the slip is validated by an AI vision check (Google Gemini, model gemini-2.5-flash) comparing amount/currency/date to the order (±0.01 amount tolerance, ±1 day date tolerance; no reference-number comparison). If the AI check flags a soft mismatch (amount/currency/date off from the order) the customer sees a warn-but-allow dialog — title "Slip may not be valid", body "⚠ Our system detected that the uploaded file might not be a valid transfer slip. You can still submit it — the restaurant will verify it manually" (Upload Anyway / Choose Another) — and staff get a "{Order}: transfer slip needs review" toast on the counter. As of 2026-07-30, a distinct hard-rejection path exists (uploadResult.rejected) for files the server refuses outright as not being a transfer slip at all: there is no "Upload Anyway" option — the app clears the picked file and shows an error toast asking the customer to choose a different file, and (on the pay-after-order-submit path) if this happens after the order was already placed, the order is kept and the customer is told to retry the slip from the order page rather than losing the order. It's non-blocking + inert when Gemini:ApiKey is unset (returns WasValidated=false); manual staff verification remains the backstop. [code: foodops.ordering.api Foodops.Ordering.Api/Services/TransferSlipValidationService.cs:18-31,58-63 (Gemini) + Foodops.Ordering.Core/Services/TransferSlipVerdictBuilder.cs:23-59 (amount/currency/date compare); foodops.ordering.app src/features/orders/pages/OrderDetailsPage.tsx:757-764 (warn-but-allow ConfirmationDialog); foodops.counter.app src/hooks/useSignalR.ts:188-201 (TransferSlipUploaded → "transfer slip needs review" toast) @ main]

If no method is enabled the sheet says so and checkout cannot complete. Whether the customer can pay before staff accept the order is the per-outlet allow-payment-without-acceptance flag — it decides whether the CTA reads "Make Payment", "Place Order", or shows both. [code: PaymentController.cs:141; src/components/payment/PaymentButton.tsx]

6. Tracking — including the logged-out tracking link

  • After submit the customer lands on /orders: Ongoing/Completed lists with a per-order-type progress bar (Delivery: Placed → Accepted → Preparing → Ready → Enroute → Delivered; Takeout ends at Picked Up; Dine-in at Served), updated live over SignalR. [code: src/features/orders/utils/orderUtils.ts; src/services/signalRService.ts]
  • Tracking link (/orders/{orderId}): the OrderAccessPage handles visitors who aren't logged in — it shows the phone+OTP form, and after verification loads the order, restores the outlet/table context from the order itself, and renders the order details. This is how a customer on a new device or cleared browser re-finds their order: same phone number, same order. [code: src/features/orders/pages/OrderAccessPage.tsx]
  • Order ratings: a CSAT drawer can fire after submission (with a cool-off so repeat customers aren't re-prompted every order). [code: src/components/survey/CSATSurveyDrawer.tsx]

Key concepts

  • Phone is the account — no email/password ever; OTP-verified phone + a display name is the full customer identity. Orders bind to the customer record, not the device.
  • Per-channel availability — each item variant carries DineIn/Takeout/Delivery flags; the X-Order-Mode header makes the server enforce them on every menu read, so out-of-channel items can't be added even by URL tricks.
  • Country-routed OTP — SMS provider is chosen by the outlet's country (India → Fast2SMS, otherwise MsgOwl), which is what makes non-Maldives phone numbers usable.
  • Server-authoritative cart — anonymous carts are local but totals are server-computed; on login the local cart is replayed to the server, which then owns it.
  • Payment-before-acceptance flag — per outlet: either customers pay immediately, or they "Place Order" and pay after staff accept.
  • Tracking link ≠ session/orders/{orderId} works from any device; OTP with the ordering phone number unlocks it.

Common questions

Q: Does the customer need to install an app? A: No — it's a web app (PWA). It can be "installed" to the home screen from the browser prompt, but ordering works straight from the link or QR scan.

Q: Does the customer need an account or password? A: No password, ever. They can browse and build a cart anonymously; at checkout they verify their phone with an SMS code and give a name. That's the whole sign-up.

Q: My customer in India isn't receiving the OTP — why does my Maldives test number work? A: OTP SMS routes by the outlet's country (India → Fast2SMS, all other countries → MsgOwl). If a provider-side issue affects one route, the other is unaffected — check the corresponding provider account/balance.

Q: What payment methods can customers use? A: Up to three, each toggleable per outlet: BML card gateway, cash (on pickup/delivery), and bank transfer with a receipt-photo upload. If you see fewer on the site, the missing ones are disabled for that outlet.

Q: What does "Bank Transfer" actually do? A: The customer transfers to your bank account and uploads a photo of the transfer slip in the same flow. The slip lands on the order for staff to verify before treating it as paid.

Q: Why does one customer see "Make Payment" and another "Place Order"? A: The per-outlet "allow payment without acceptance" setting. Off = customers place the order and pay after staff accept; on = they can pay immediately.

Q: Customer says an item is "missing" from the delivery menu but it's on the dine-in menu. A: That's per-channel availability working as configured — the variant's Delivery flag is off. Edit the item's ordering availability to include delivery.

Q: Can a customer track an order from a different phone or after clearing their browser? A: Yes — open the tracking link /orders/{orderId} (or the site) and verify with the same phone number used to order; the order list/details come back.

Q: How fast do menu edits show up on the storefront? A: Near-instant — the POS edit triggers a notification to the ordering API, which broadcasts MenuUpdated; open storefronts drop their cache and refetch (customers see a refresh toast).

Q: What happens if the restaurant pauses online ordering mid-browse? A: An amber "not accepting online orders" banner appears on the menu and cart, and the server rejects submission. Dine-in table-QR sessions and group carts keep working (they're treated as in-person).

Q: Can customers order from a table QR and also pick delivery? A: No — a table-QR session is locked to dine-in for that table. For delivery they'd use the outlet's normal URL (picking an outlet clears table context).

Q: Is there a minimum order or delivery fee shown? A: Delivery charges come from the merchant's delivery sectors and land in the server-computed totals. (Per-sector minimum-order enforcement on the storefront is not code-verified in this pass — see Edge cases.)

Edge cases and known issues

  • OTP delivery is the hardest dependency — checkout cannot complete without the SMS arriving. Provider/balance issues on Fast2SMS or MsgOwl block all new-customer checkouts for outlets routed to that provider.
  • Anonymous totals are a fallback race — while POST /cart/totals/anonymous is in flight (or on error) the cart briefly shows a flat local subtotal without tax; the server figure replaces it. [code: CartSidebar.tsx]
  • Favorites and re-order need sign-in — anonymous visitors see inert heart icons and a login-required empty state. [code: src/components/auth/LoginRequiredEmptyState.tsx]
  • Minimum-order-per-zone: user story US-ORD-EDGE-007 describes a "Minimum order MVR 50 for this area" gate; no implementing storefront code was identified at 09e5e61 in this pass — treat as not-verified rather than confirmed behaviour.
  • Order cancellation from the storefront (US-ORD-004/EDGE-005): customers can self-cancel an online order from the storefront while it is still "New" (not yet accepted by the restaurant) — a Cancel button on the order card / order details opens a confirmation dialog, calls cancel, and shows an "Order cancelled" toast. Once the restaurant accepts the order (its status leaves "New"), the self-cancel control disappears and cancelling reverts to a call-the-restaurant flow. [code: foodops.ordering.app/src/features/orders/components/{OrderCard,OrderDetailsModal}.tsx — canCancelOrder / handleCancelOrder @ b63bce5]
  • Visual layout of the payment sheet, transfer-slip upload, and OrderAccessPage OTP form are code-documented only.

Related

  • Online Ordering Platform — the umbrella entry: customer walkthrough with screenshots, merchant pipeline, recent-change log

  • Online Ordering Setup — merchant go-live: URL/branding, the accept-orders toggle, menu publishing, payment-method enablement

  • Deliveries — what happens after a delivery order is accepted: queue, sectors, rider dispatch

  • Rider App — the rider who brings the order and collects the cash

  • Takeouts — pickup orders on the counter side

  • User Stories: Online Ordering — 20 scenarios incl. edge cases

  • 2026-08-09 — codebase: foodops.ordering.app 4e8d3b2..2c35685 adds per-service order-type enablement to the storefront's order-type picker, not currently documented in this entry's section 4 ('Pickup vs delivery') or section 1 ('Entry and menu availability'). CartSidebar.tsx now reads three new outlet-data fields — isDineInEnabled/isDeliveryEnabled/isTakeoutEnabled (per the added comment, 'mirroring the admin app's Settings → Outlets → Services toggles') — and only an explicit false disables an order type (undefined, e.g. mid-flight query or an API build predating the fields, is treated as available, so the picker can't flicker or strand a customer). The Takeout/Delivery/Dine-in buttons this entry's section 4 describes are now filtered to availableOrderTypes built from these flags (dine-in additionally requires a bound table, matching the entry's existing 'Dine In when a table is bound' claim); a useEffect drops an already-selected order type if its service is toggled off mid-session, and a new hasNoAvailableOrderTypes (all relevant services off — for group carts, dine-in alone, since 'Group carts always submit as dine-in') is folded into isOrderingBlocked alongside the already-documented isOutletClosed/isOnlineOrderingDisabled. To catch a mid-session admin toggle, the outlet query now polls every 15s while the cart drawer is open (pollingInterval: isOpen ? 15000 : 0), where the outlet fetch previously only ran on mount/focus. This doesn't contradict the entry's documented button set or blocking logic (both already described as conditional/per-outlet) — it's a new gating dimension worth folding into section 4 and a Common-questions entry next time that section is touched (e.g. 'why don't I see a Delivery button for this outlet' → the outlet has that service disabled in admin, separate from the online-ordering-paused banner). No foodops.ordering.api diff was supplied this pass to confirm the OutletDto actually serves these three new fields or how the admin app's Settings → Outlets → Services toggles map to them — flag for a follow-up pass before citing the admin-side control by name. Also in this diff, not touching documented behavior: AuthSheet.tsx drops its custom useKeyboardInset/repositionInputs={false} iOS-keyboard workaround in favor of a new shared DrawerBody primitive (commit: 'update Drawer components to improve layout and add DrawerBody for better content management'), and several other drawers (EditCartItemDrawer, EditGroupCartItemDrawer, GuestNameSheet, GroupCartQRCodeModal, StartGroupCartModal, ItemDetailsDialog) lose their fixed max-h-[..vh] caps in favor of flex/overflow-y-auto bodies — layout/scrolling internals only, no change to any documented step, label, or endpoint. The commit subjects also mention a useOtpInput hook and payment-modal changes to skip the cart query for unauthenticated users, but no diff hunks intersecting this entry's cited surface were supplied for either this pass — flag for a follow-up pass before touching OTP-step or payment-modal claims. Re-pin foodops.ordering.app to 2c35685 on next full verification pass; no foodops.ordering.api diff supplied this pass, pin e3fca93 unaffected. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-09 — codebase: foodops.ordering.api 96a238e..bf076ac. The 'fix(status-update): quote the persisted order total in the accepted SMS' commit in this range is the same change already logged in the 2026-08-06 verification-history entry (pinned e3fca93) — the Include(o => o.TransactionCurrency) and amountDue/currencyCode derivation match verbatim; no new note needed there, the pin can safely advance past it. Genuinely new in this range: 'feat: Implement outlet service availability checks and update caching strategy' introduces a per-outlet, per-order-type enable/disable gate (admin → Settings → Outlets → Services) not currently documented in section 1 or section 4 ('Pickup vs delivery'). A new OutletOrderTypeAvailability.ForOutletAsync (Foodops.Ordering.Core/Services/OutletOrderTypeAvailability.cs) derives Dine-In/Delivery/Takeout enabled state from the outlet's RestaurantTable rows (Delivery/Takeout: enabled if any non-deleted table of that order type exists; Dine-In: default-on, disabled only by a deleted IsSystemTable 'tombstone') — the same derivation sales-api's admin toggle reader uses, so admin and the storefront can't disagree. OrderSubmissionService.SubmitOrderAsync now enforces this server-side, authoritatively, ALONGSIDE (not replacing) the already-documented IsAcceptingOnlineOrders 'not accepting online orders' gate: submitting a disabled order type is rejected with 'This outlet is not accepting {delivery|dine-in|takeaway} orders at this time.' — and unlike the online-orders flag, there is no dine-in exemption on this new check, since disabling a service means the outlet doesn't run it at all (vs. the online-orders flag, which only governs online intake). OutletDto also gains IsDineInEnabled/IsDeliveryEnabled/IsTakeoutEnabled fields (MenuService.cs), and the previously 5-minute-cached order-types-availability endpoint (RestaurantController.GetOrderTypesAvailability) is now no-store so a merchant's Services toggle takes effect immediately instead of up to 5-10 minutes later. Separately, RestaurantService's three IsXAvailableAsync methods were refactored onto the same OutletOrderTypeAvailability reader, fixing a previously org-scoped-instead-of-outlet-scoped bug (enabling takeout at one outlet previously reported it available at every outlet in the org) — not previously documented, so nothing contradicted. No foodops.ordering.app diff was supplied this pass to confirm whether/how the storefront's order-type picker (section 4) already reflects these three new flags or what copy it shows on a rejected submission — a code comment in OrderSubmissionService claims 'The customer app hides disabled services from its order-type picker' but that's unconfirmed without app-side evidence. Worth folding into sections 1/4 and a new Common-questions entry next time those sections are touched ('why can't I order delivery from this outlet even though the site is otherwise accepting orders'). Re-pin foodops.ordering.api to bf076ac on next full verification pass; no foodops.ordering.app diff supplied this pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-06 — codebase: foodops.ordering.api 96a238e..e3fca93 ("fix(status-update): quote the persisted order total in the accepted SMS") is a bug fix to a customer-facing SMS this entry does not currently document: OrderStatusController.SendOrderAcceptedSmsWithBankDetails texts the customer the outlet's bank-transfer account details on order acceptance, quoting an amount due. Previously that SMS quoted request.OrderTotal/request.Currency verbatim from the status-update caller's payload; per the added comment, a counter client with a stale or absent local total could make the SMS read 'MVR 0.00'. It now derives the amount from the persisted order itself — order.Outstanding if >0, else falling back to order.GrossTotal (Outstanding is 0 on a fresh unpaid ordering-app order) — and the currency from the order's newly-Included TransactionCurrency.Code, falling back to the request's currency, then 'MVR'. This SMS pathway sits alongside — and is distinct from — this entry's documented bank-transfer flow (section 5: customer transfers, uploads a slip, staff verify); it appears to be the mechanism that tells the customer where to transfer, which isn't described anywhere in this entry today. No diff evidence was supplied this pass to confirm exactly when this SMS fires (e.g. gated to bank-transfer-method + unpaid orders on acceptance) or how/whether it reaches guest-checkout orders; worth a follow-up pass to pull the surrounding method and fold a 'bank-details SMS' line into section 5 and Common questions. Re-pin foodops.ordering.api to e3fca93 on next full verification pass; no foodops.ordering.app diff supplied this pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — codebase: foodops.ordering.api 7b91621..96a238e. Five changes intersect this entry's tracked surface, none contradicting documented claims. (1) Duplicate order-submission guard (new): a new OrderSubmissionLock (Foodops.Ordering.Core/Services/OrderSubmissionLock.cs) takes a SQL Server sp_getapplock session-scoped exclusive lock, keyed by fo:ordersubmit:{organizationId}:{sha256(identity)}, before the cart is read for order submission — per its own doc comment, this exists specifically so two overlapping submissions from the same customer/cart cannot both turn into separate orders (docs/adr/0003). On failure to acquire (a genuine concurrent duplicate) the caller must treat the second submission as a probable duplicate and not create an order; on any unexpected error acquiring the lock it fails open (proceeds unlocked) rather than blocking a legitimate order; on the in-memory test provider it's a no-op. This is new server-side protection for the 'Cart hand-off... order submits without a second tap' flow documented in section 3 step 5 — worth a line in 'Edge cases' next time that section is touched (rapid double-tap/network-retry on submit is now guarded server-side). No foodops.ordering.app diff supplied this pass to confirm what the storefront shows the customer if a submission is rejected as a duplicate — flag for follow-up. (2) Transfer-slip hard-rejection DTO fields land server-side: PaymentController.UploadTransferReceipt now returns Rejected = verdict?.IsNotATransferSlip == true plus a canned RejectionReason on UploadReceiptResultDto — this is the first backend evidence of the exact Rejected/RejectionReason shape this entry's payment table already describes (dated 2026-07-30) as the app-side 'hard-rejection path' keyed on uploadResult.rejected. Confirms rather than changes the documented behavior; no app diff supplied this pass to confirm the app renders this specific RejectionReason string verbatim (vs. its own hardcoded copy) — worth confirming next full pass. Same controller: ProcessTransferPayment's failure message now surfaces result.ErrorMessage when present instead of always the generic 'Failed to process transfer payment' — error-copy only, not previously quoted verbatim in this entry. (3) Service fee base now excludes fee-exempt categories (CartService.CalculateTotalsForItemsAsync, GroupCartService.CalculateTotalsAsync): percentage service fees are now computed against a serviceableItemsTotal/serviceableTaxTotal that excludes line items whose ProductCategory.IsServiceFeeApplication is false, instead of the full cart total — per the added comment, this matches how the in-person bill already excludes those categories. This is a quiet correctness fix to the 'server-computed totals (tax + service fees)' claim in section 2 — the computed fee may now be lower for carts containing fee-exempt items — but the entry doesn't document category-level fee-exemption mechanics today, so nothing is contradicted. (4) Menu image priority reordering (MenuService.GetCategoriesByOutletAsync): product-card ImageUrl now prefers the product-level default image over any variant image (previously variant images were tried first); variant images remain a fallback. Not previously documented in this entry — cosmetic/display-only, low priority. (5) Out of this entry's customer-facing scope: OrderStatusController.UpdateOrderStatus's SMS phone-number lookup was rewritten to stop an implicit INNER JOIN on the required Customer navigation from silently dropping status SMS for counter/POS/walk-in orders (CustomerId == Guid.Empty) — those orders aren't reached via the storefront's OTP-verified Customer flow this entry documents, so no edit applies, but flag for a future POS/counter-facing entry. Re-pin foodops.ordering.api to 96a238e on next full verification pass; no foodops.ordering.app diff supplied this pass, app pin unaffected. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — codebase: foodops.ordering.app 8c01414..4e8d3b2 ("feat: implement useSingleFlight hook to prevent duplicate submissions in payment processes") adds a client-side double-submission guard, not currently described in this entry. A new useSingleFlight hook (src/hooks/useSingleFlight.ts) wraps an async handler in a useRef flag that flips synchronously on the same tick as the click/tap, dropping (resolving to undefined, not throwing) any second invocation while the first is still in flight — closing a gap the existing React-state disabled-button guards (isProcessing/isSubmittingOrder/isSubmittingGuest) leave open for one render cycle, per the hook's own comment: 'two overlapping submissions used to mean two orders, two invoices, two kitchen tickets and two texts.' It's now applied to every order-submission path this entry documents: the BML/transfer/card submit in PaymentModal.handleSubmit, cash submit in CashPaymentModal.handleSubmit, the guest-checkout name-sheet submit in CartSidebar.handleGuestNameSubmit (section 3's 'Checkout as guest' flow), and both PaymentButton.handleSubmitOrderWithoutPayment (the pay-after-acceptance 'Place Order' path from section 5) and handleSaveAndAccept (dine-in save-and-accept). This is purely a client-side race-condition hardening — no change to any documented button label, dialog copy, endpoint, or flow order — but is worth a short line in 'Edge cases and known issues' next time that section is touched, noting that double-tapping any submit/pay button is now a no-op rather than a risk of duplicate orders. The related commit in this diff range, 'feat: enhance transfer payment process with upload validation and rejection handling,' supplied no hunks intersecting this entry's cited surface this pass — flag for a follow-up pass with that diff before touching the transfer-slip hard-rejection claims already documented in section 5. Re-pin foodops.ordering.app to 4e8d3b2 on next full verification pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — codebase: foodops.ordering.api 7b91621..1f7c4a2 hardens bank-transfer settlement, extending (not contradicting) this entry's documented 'hard-rejection path' and 'no reference-number comparison' claims. Two new server-side refusals in OrderSubmissionService.ProcessExistingOrderTransferPaymentAsync: (1) a transfer payment can no longer be applied against a made-up/empty document id — TransferReceiptExistsAsync must confirm the uploaded receipt document really exists first, else TransferPaymentProcessResult.Rejected("A transfer slip is required to record a bank transfer payment."); (2) the same slip can no longer settle two orders — the bank's own reference number (now persisted queryably via ChequeNumber = slipVerdict?.ReferenceNumber, previously only embedded in the Remarks note text) is checked via a new IsSlipReferenceAlreadyUsedAsync, rejecting with "This transfer slip has already been used to pay another order." if reused. Storing the reference for replay-detection is a different comparison from the documented verdict-builder amount/currency/date match against the order's own figures — the 'no reference-number comparison' claim about TransferSlipVerdictBuilder's soft-mismatch logic is untouched and still accurate. Also, PaymentController.UploadTransferReceipt's response DTO gains explicit Rejected/RejectionReason fields (RejectionReason = "That file doesn't look like a bank transfer slip. Please upload the receipt from your banking app." when IsNotATransferSlip), and the transfer-process failure message now surfaces result.ErrorMessage instead of a generic string — plumbing that lets these new customer-facing rejection reasons reach the app. No foodops.ordering.app diff was supplied this pass, so it's unconfirmed whether the storefront's existing hard-rejection UI (clear file + error toast; keep-order-and-retry-from-order-page for the pay-after-submit case) already generically surfaces these two new reasons or needs new copy/branches — flag for a follow-up pass with app-side evidence before editing section 5's payment table. Separately and lower-priority, unrelated to this entry's documented storefront surface: OrderStatusController.UpdateOrderStatus fixed a bug where its authoritative-order lookup used an implicit INNER JOIN on the required Customer navigation, returning null (and silently suppressing the customer status-update SMS) for any counter/POS/walk-in order with no Customer row; now resolves the order and customer via separate queries plus a blank-string-safe Coalesce helper. This concerns order-status SMS delivery, which this entry doesn't currently document (SignalR-based in-app tracking only) — likely belongs in whichever entry documents that notification path, not here. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-23 — codebase: foodops.ordering.api 690d322..7b91621. Three changes, none contradicting documented claims. (1) Customer-update org scoping bug fix: CustomersController.UpdateCustomer now resolves the active outlet's organisation and threads it into CustomerService.UpdateCustomerAsync, which (when supplied) filters the customer lookup to ApplicationOrganisationId == applicationOrganisationId before matching by phone. Per the added comments, customers are partitioned one row per phone+org, and the prior unscoped FirstOrDefault could update a different org's record for the same phone — silently failing to persist the name for the active outlet's org, so the storefront's 'Name step' (documented in section 3 step 4 as 'only if the customer record has no name') would keep re-prompting on every login for phone numbers shared across organisations. This is a fix that makes that already-documented one-time-name-capture behavior reliable in multi-org phone-reuse scenarios — not a change to the flow itself, so nothing to edit. (2) OutletDto.TimeZone added, populated via a new TimeZoneHelper.ToIanaTimeZone(organisation?.TimeZone) in both GetRestaurantOutletsByOrganizationAsync and the sibling outlet-lookup projection — converts the organisation's Windows time zone ID to IANA format. Backend-only plumbing; no foodops.ordering.app diff supplied this pass to confirm the storefront consumes it (e.g. for displaying/formatting order times in the outlet's local zone) — flag for a follow-up pass before documenting any timezone-display behavior. (3) OrderSubmissionService.SubmitOrderAsync now stamps OrderDate/DeliveryDate/note timestamps with DateTime.UtcNow instead of DateTime.Now — an internal correctness fix (server-local-time bug) with no customer-visible change to the documented order-tracking timeline (Placed → Accepted → … per section 6). Re-pin foodops.ordering.api to 7b91621 on next full verification pass; no foodops.ordering.app diff supplied this pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-23 — codebase: foodops.ordering.app 59a13c5..079a642 ("feat: add timeZone property to outlet-related components for improved time handling") changes how order timestamps are displayed to the customer, not currently described in this entry. The cached outlet object (localStorage/sessionStorage currentOutlet, populated at login/outlet-resolution in AuthSheet.tsx, LoginForm.tsx, AppLayout.tsx, OutletValidator.tsx, and typed in types/menu.ts as a new OutletDto.timeZone IANA id e.g. Asia/Karachi) now carries the organisation's time zone. orderUtils.ts adds a getOrgTimeZone() reader and threads it into formatDate/formatTime's toLocaleDateString/toLocaleTimeString calls via a new timeZone option — order timestamps (order list, order details, the AttachmentViewerModal upload date, which also switched from a raw toLocaleDateString() call to the shared formatDate helper) now render in the restaurant outlet's local time zone instead of the customer's browser/device time zone, falling back to the browser's local zone only when no outlet time zone is cached. This is a genuine display-behavior change worth a line in section 6 ('Tracking') or a Common-questions entry next time that section is touched (e.g. 'why does my order time look different than my phone's clock' — answer: times are shown in the restaurant's local time zone, not the customer's device time zone). No existing documented claim in this entry addresses timestamp time-zone handling, so nothing is contradicted. Re-pin foodops.ordering.app to 079a642 on next full verification pass; no foodops.ordering.api diff supplied this pass, pin 690d322 unaffected. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-21 — codebase: foodops.ordering.api 10edb52..690d322 ("feat: Update out-of-stock handling to flag items as unavailable instead of hiding them") changes out-of-stock handling in MenuService.cs across GetProductsAsync, GetGroupedProductsAsync, GetProductVariantsAsync, and SearchProductsAsync: the hard .Where(StockAvailability.IsOrderable) query filters and the list-pruning RemoveOutOfStock helper — which previously excluded a stock-tracked, depleted variant/product entirely from every menu/search response — are removed and replaced by a renamed MarkAvailability helper, invoked after materialisation, that flags out-of-stock variants/products with IsAvailable = false instead of dropping them. Per the updated doc comments, 'Out of Stock items stay on the menu but are flagged un-orderable' and are 'shown greyed-out' rather than hidden; a product is now available if any of its variants is. This is a genuine behavior change from the menu-listing exclusion previously described only in this entry's verification-history notes (never promoted into the 'Entry and menu availability' main-body section, so nothing there is contradicted): out-of-stock items now stay browsable in menu browse, category listings, and search — just non-addable — instead of disappearing outright. This dovetails with the app-side note logged earlier today (ProductCard/ProductCardGridMulti/ProductCardGridSingle computing isOutOfStock = !product.isAvailable and rendering a greyed-out OutOfStockBadge with disabled card click): that frontend logic now has a live backend-supplied IsAvailable=false signal to act on for stock depletion, not just per-channel availability. Worth folding into 'Entry and menu availability' and a new Common-questions entry next time that section is touched, describing the new stay-visible-but-greyed-out treatment alongside the existing per-channel (DineIn/Takeout/Delivery) filtering. Re-pin foodops.ordering.api to 690d322 on next full verification pass; foodops.ordering.app pin (9a1b9ea) unaffected — no additional app diff supplied this pass beyond the already-logged 2026-07-21 note. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-21 — codebase: foodops.ordering.app e7625a2..9a1b9ea. Nearly all hunks in this range are already fully logged in prior notes: the cart-line Out-of-Stock flow (unavailableVariantIds, CartItem.tsx badge, CartSidebar.tsx banner/toast/guest-submit branching, register-closed isOutletClosed/isOrderingBlocked) was logged 2026-07-17; the anonymous-cart full-edit (modifiers/allergies/instructions) parity fix and the EditCartItemDrawer.tsx productId-resolution + productSnapshot.productId backfill across ProductCard/ProductCardGridMulti/ProductCardGridSingle/FavoriteProductCard were logged 2026-07-17/07-20 respectively; and the conditional state.outletId assignment plus deliveryTime reset in cartSlice.ts were logged 2026-07-20 as non-contradicting bug fixes. Genuinely new in this range, not previously captured anywhere in this entry: ProductCard.tsx, ProductCardGridMulti.tsx, and ProductCardGridSingle.tsx now surface Out-of-Stock (ADR 0002) directly on the menu grid/list, not just in the cart. Each imports a new OutOfStockBadge component and computes isOutOfStock = !product.isAvailable; when true the whole card's onClick (product-details view) is disabled entirely (previously only the Add button was disabled via the existing !product.isAvailable check on the Button's disabled prop — the card itself was always clickable), the product image is grayscaled/dimmed, the name/price text is greyed, and the Add-to-cart button is no longer just disabled but removed from the DOM entirely. This is additive, customer-visible menu-browsing behavior this entry's 'Entry and menu availability' section doesn't currently describe (that section only covers per-channel DineIn/Takeout/Delivery filtering, not stock-based display treatment) — worth a short addition there and a Common-questions entry ('why can't I tap this out-of-stock item / add it') next time that section is touched. No contradiction to any documented claim. Re-pin foodops.ordering.app to 9a1b9ea on next full verification pass; no foodops.ordering.api diff supplied this pass, pin 10edb52 unaffected. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-20 — codebase: foodops.ordering.app e7625a2..26cd36c supersedes the already-pinned 45a6ca7. Nearly all hunks in this range are already fully logged: the Out-of-Stock unavailableVariantIds/badges/banner and register-closed isOrderingBlocked gating (2026-07-17 note), the anonymous-cart full-edit (modifiers/allergies/instructions) parity fix in CartItem.tsx/cartSlice.ts (2026-07-17 note), and the EditCartItemDrawer.tsx productId-resolution + productSnapshot.productId backfill across ProductCard/ProductCardGridMulti/ProductCardGridSingle/FavoriteProductCard (2026-07-20 note) — no new claim there. Genuinely new in this range, not previously logged: updateCartState in cartSlice.ts now only overwrites state.outletId when the server cart response actually supplies one, instead of unconditionally assigning cart.outletId (which is always undefined on CustomerCartDto responses) — per the added comment this previously risked silently nulling out the client-resolved outlet on every cart load, which could short-circuit the outlet-change-detection logic (clearCartIfOutletChanged) that the 2026-07-08/07-10 outlet-switcher notes already document as clearing cart/table state on switch. This is a bug fix that makes that already-documented switching behavior reliable — not a change to it — so nothing to edit. Also minor: clearCartState now resets a deliveryTime field alongside pickupTime/orderNotes, not previously mentioned. Commit subjects for this range also include an order-service Authorization-header refactor and an outlet-switch confirmation-toast/session-storage change, but no diff hunks intersecting this entry's documented surface were supplied for either, so neither is confirmed to touch customer-visible behavior this pass — flag for a follow-up pass with app-side evidence before editing outlet-switch claims. Re-pin foodops.ordering.app to 26cd36c on next full verification pass; no foodops.ordering.api diff supplied this pass, pin 10edb52 unaffected. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-20 — codebase: foodops.ordering.app e7625a2..45a6ca7 supersedes the already-pinned 2f21ef8. The cartSlice/CartItem/CartSidebar hunks in this diff (Out-of-Stock unavailableVariantIds state, red 'Out of stock' badges, the 'Some items are no longer available' banner, and the register-closed isOrderingBlocked gating) are the same changes already fully logged in the 2026-07-17 verification note — no new claim there, nothing to re-document. Genuinely new in this range: EditCartItemDrawer.tsx now resolves the correct catalog productId before fetching modifier groups for a cart line. Per its own added comment, older/anonymous carts stored the variant id in the productId field, so GET /menu/products/{productId}/modifiers could silently query with the wrong id and return no groups when a customer tried to edit modifiers on such a line; the drawer now detects that case (productId missing or === productVariantId) and calls GET /menu/product-variant/{id} to resolve the true product id first, and also now threads cartItem.currency into the modifier objects it saves. In support, ProductCard, ProductCardGridMulti, ProductCardGridSingle, and FavoriteProductCard all now populate productId: product.productId in the local productSnapshot used for optimistic anonymous-cart adds, so newly-added lines carry the right id going forward. This is a bug-fix precursor to — not a contradiction of — the entry's 2026-07-17-logged claim that anonymous-cart edits now support full modifier/allergy/instruction edits: before this fix, that capability could silently fail to surface a line's modifier choices for items added via these product cards. Nothing here touches OTP, payment, or tracking-link claims. Re-pin foodops.ordering.app to 45a6ca7 on next full verification pass; the foodops.ordering.api pin (10edb52) is unaffected — no API diff was supplied this pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-17 — codebase: foodops.ordering.app e7625a2..2f21ef8 supplies the app-side evidence for two items the 2026-07-17 API-side note (e860cc1..10edb52) had flagged backend-only, plus a minor cart-editing symmetry fix — none contradict documented claims. (1) Out-of-Stock (ADR 0002) now surfaces in the cart/checkout UI: cartSlice.ts adds a transient unavailableVariantIds array (cleared on every cart mutation and on server cart load) set via a new setUnavailableVariantIds action; CartSidebar.tsx's guest-submit handler branches on result.unavailableItems from the submit response, flags the rejected lines, closes the guest-name sheet, and toasts 'Some items are no longer available: {names}. Please remove them and try again.'; matching lines in CartItem.tsx get a red-tinted row + 'Out of stock' badge, and the sidebar shows a red 'Some items are no longer available... sold out while in your cart' banner. This is the frontend counterpart to the stock-availability filtering already documented as backend-only (menu-listing exclusion) in section 1 — it's a new gate at submission time, additive to the existing per-channel (DineIn/Takeout/Delivery) availability flags, not a replacement. (2) Register/POS-session gating reaches the storefront: CartSidebar.tsx now reads outletData?.hasActiveRegisterSession and ORs isOutletClosed into a new isOrderingBlocked that gates the 'Checkout as guest' CTA (previously gated only by the IsAcceptingOnlineOrders-derived isOnlineOrderingDisabled, which this entry documents in section 1 as exempting dine-in/group-cart). Per the dev's own comment this register-closed state rejects submission for every order type including dine-in and group carts — a separate condition from, and not a contradiction of, the documented online-ordering-flag exemption. This confirms the backend HasActiveRegisterSession field (flagged 2026-07-17 as backend-only, unconfirmed on the storefront) is now consumed app-side, at least for the guest-checkout CTA. (3) Lower-priority: a removed code comment shows anonymous-cart line edits were previously quantity-only ('Modifier edits are only persisted through the quantity for now'); updateItemOptimistic now accepts optional modifiers/allergies/specialInstructions and the edit-save handler passes them through for anonymous carts too (recomputing totalPrice via perUnitModifierTotal), making full-edit capability symmetric between anonymous and authenticated carts — not previously documented as a limitation, so nothing to correct. Re-pin foodops.ordering.app to 2f21ef8 on next full verification pass; no foodops.ordering.api diff was supplied this pass to confirm the unavailableItems/guest-submit response shape server-side. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-17 — codebase: foodops.ordering.api e860cc1..10edb52 adds several backend behaviors not yet reflected in this entry, none contradicting documented claims. (1) Server-authoritative modifier pricing: CartService.AddItemToCartAsync/UpdateCartItemAsync now call a new ResolveModifierPricesFromCatalogAsync, which re-resolves every submitted modifier's price (and backfills name/group) from RestaurantModifierGroupItem by id before it's stored or used for the duplicate-line match — a client-submitted price is never trusted, and an unresolvable modifier id is neutralised to price 0. This extends, and doesn't contradict, this entry's 'Server-authoritative cart' key concept and '2. Cart and modifier rules' section (previously documented as totals-only server-authority). (2) Out-of-Stock menu filtering (ADR 0002): GetProductsAsync, GetGroupedProductsAsync, GetProductVariantsAsync, SearchProductsAsync (MenuService) and GetTopSellingItemsAsync (OptimizedMenuService) now all exclude variants that are stock-tracked, disallow negative stock, and have zero available stock (CurrentStockLevel - CommittedStockLevel <= 0) — a new filter dimension alongside the already-documented per-channel (DineIn/Takeout/Delivery) availability flags in '1. Entry and menu availability'. (3) Register-session visibility: GetRestaurantOutletsByOrganizationAsync's OutletDto gains a new HasActiveRegisterSession field (mirrors the open-register-session check OrderSubmissionService already enforces at submission) — comment states intent is to let the app warn up-front, but no foodops.ordering.app diff was supplied this pass to confirm the storefront actually surfaces or gates on it yet; treat as backend-only until confirmed. (4) WhatsApp OTP channel: AuthController.RequestOtp now accepts a Channel field (OtpChannelParser.Parse(request.Channel)) and calls GenerateOtpAsync(phone, outletId, channel), returning a distinct 'OTP sent successfully via WhatsApp' message when channel == OtpChannel.WhatsApp — a second delivery channel alongside the SMS/country-routed provider path this entry documents in section 3 step 2 and the 'Country-routed OTP' key concept. No app-side diff supplied to confirm the storefront UI exposes a channel choice; flag for a follow-up pass before editing the OTP-flow claims. (5) Auth hardening (internal, non-contradicting): the legacy [SkipApiKeyValidation]/Foodops.Ordering.Api.Attributes scheme was replaced by a policy-based Foodops.Ordering.Api.Authentication framework — CartController and CustomersController now carry class-level [CustomerAuthorize] (with totals/anonymous explicitly [AllowAnonymous], matching this entry's documented anonymous-totals-preview behavior), AuthController is [AllowAnonymous], and CustomersController added phone/customer ownership checks (IsOwnPhone/IsOwnCustomerAsync, returning Forbid() on mismatch) on profile and address endpoints. This confirms rather than changes the entry's documented session/ownership model. Re-pin foodops.ordering.api to 10edb52 on next full verification pass; prioritize an app-side diff to resolve (3) and (4) before editing user-facing claims. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-14 — codebase: foodops.ordering.app 04776e0..e7625a2 (feat: enhance cart item management with customization signatures and modifier surcharges) changes local cart-line merge semantics in src/store/slices/cartSlice.ts, not previously documented in this entry's '2. Cart and modifier rules' section. Previously addItemOptimistic merged any two adds of the same productVariantId into one line regardless of modifiers/allergies/notes, and both that merge path and updateQuantity recomputed totalPrice as quantity * unitPrice only — dropping any modifier surcharge from the line total whenever a line was merged or its quantity changed via the stepper. A new buildItemSignature(item) builds a canonical, order-independent key (productVariantId::sorted 'id:quantity' modifier pairs::trimmed allergies::trimmed notes), and merging now requires an exact signature match — so the same product added twice with different modifier selections, allergy notes, or special instructions now stays on two separate cart lines instead of collapsing into one. A new perUnitModifierTotal(item) helper is folded into totalPrice on both the merge path and updateQuantity, so a merged/quantity-changed line's total now correctly reflects its modifier surcharges instead of silently dropping them. This is a client-side (Redux cartSlice) mechanic, distinct from the backend CartService.AreModifiersEqual cart-line-matching fix already logged 2026-07-13 for foodops.ordering.api — different repos/layers but the same functional area (accurate per-line modifier identity in the cart); worth cross-referencing next time this section is touched. No contradiction to any documented min/max/MaximumGroupQuantitySelection claim — nothing to edit. Re-pin foodops.ordering.app to e7625a2 on next full verification pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-13 — codebase: foodops.ordering.api 93d14db..e860cc1 (extends the already-pinned 465b53e). Most of this diff is the outlet-cache-disable fix (718a6ac, logged 2026-07-10) and the GetOutlets organizationId/OutletTypeId fix (465b53e, logged 2026-07-11) reaching a newer commit unchanged — already covered, no new note needed there. The one genuinely new commit is 'fix: improve modifier comparison logic to handle quantity and group name for accurate cart item matching' (CartService.cs AreModifiersEqual), not previously documented. Cart-line modifier-equality previously compared only Id/Name/Price/Currency via a one-directional 'every modifier in list1 has some match in list2' check — ignoring each modifier's Quantity and GroupName, and susceptible to false-positive matches with duplicate entries. It now builds a canonical per-modifier signature (Id|Name|Price|Currency|Quantity|GroupName), sorts both modifier lists' signatures, and requires an exact ordered SequenceEqual. Practical effect on this entry's '2. Cart and modifier rules' section: two cart lines that previously could incorrectly merge into one aggregated line despite differing modifier quantity or originating group (e.g. '1x extra shot' vs '2x extra shots', or the same modifier chosen from two distinct modifier groups) now correctly stay as separate cart lines. This is a bug fix to existing, previously-undocumented cart-merge mechanics — it doesn't contradict the entry's documented min/max/MaximumGroupQuantitySelection group-validation claims, so no edit is required, but it's worth folding into the cart section next time that area is touched. Re-pin foodops.ordering.api to e860cc1 on next full verification pass. (auto-applied by the truth pipeline; adversarially gated) (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-11 — codebase: foodops.ordering.api 93d14db..465b53e (extends the already-pinned 718a6ac) adds the server-side counterpart to the outlet-switcher Restaurant-only filtering logged 2026-07-08/2026-07-10. Previously GET api/menu/outlets (MenuController.GetOutlets) accepted only an outletReference query param and delegated to GetOutletsByReferenceAsync, whose OutletDto projection did not populate OutletTypeId at all (several other projections — GetOutletByIdAsync, GetOutletByReferenceAsync, and OptimizedMenuService.GetOutletsByReferenceAsync — had the same gap). Since the 07-10 note already describes the storefront's client-side filter (outletTypeId == null || outletTypeId === 2, treating unset as pass-through) and the app sending an organizationId param the API did not yet accept, the org-scoped switcher call would previously have received outlets with OutletTypeId unset and fallen through the client-side filter unfiltered. This commit closes that gap: GetOutlets now accepts organizationId, routes it to a new GetRestaurantOutletsByOrganizationAsync that filters server-side to OutletTypeId == Restaurant (active outlets only), and backfills OutletTypeId into every other outlet DTO projection so the client-side filter has real data to act on everywhere. No contradiction to any main-body claim or to the 07-08/07-10 verification-history notes — this is the backend catching up to make that already-documented client behavior actually effective end-to-end. Also re-confirms (no new behavior) the outlet-metadata cache removal already logged 2026-07-10 on the same three endpoints. Re-pin foodops.ordering.api to 465b53e on next full verification pass; no app-side diff was supplied this pass to confirm the frontend now consumes the new organizationId-scoped response shape end-to-end. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-10 — codebase: foodops.ordering.app 2865c43..04776e0 refines the in-header outlet switcher already logged 2026-07-08 with two user-facing details not previously captured (no contradiction to that note or any main-body claim). (1) Restaurant-only filtering: Header.tsx now discriminates sibling outlets by outletTypeId (RESTAURANT_OUTLET_TYPE = 2; per types/menu.ts, 1 = Retail, 2 = Restaurant) — the switcher list is filtered to organizationId === currentOrgId && (outletTypeId == null || outletTypeId === 2). So an organisation that also runs Shops/Retail outlets will NOT see those in the FoodOps ordering switcher, only sibling restaurant outlets (outlets with no type set still pass through, defensively). (2) Auto-open on login redirect: landing on /menu with router state fromOutletLogin: true (straight off the outlet-login/slug redirect) now auto-opens the 'Switch Outlet' drawer once per mount when the org has 2+ eligible outlets (canSwitchOutlet), then clears the navigation state so a refresh or back/forward doesn't reopen it — previously (per the 07-08 note) the switcher only opened via tapping the header 'Welcome to {outlet}' control. Supporting plumbing: getOutlets (menuApi.ts) now takes {outletReference?, organizationId?} to fetch outlets scoped to the current org for the switcher's list, rather than only a string-reference lookup. Remaining hunks in this diff (Drawer footer removal on VariantSelectionDrawer.tsx, header logo icon-only variant, dark-mode text/sticky-bar spacing tweaks) are cosmetic/already covered by the 07-08 event — no new documentation needed there. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-10 — codebase: foodops.ordering.api 93d14db..718a6ac (fix: disable caching for outlet metadata to ensure immediate updates on branding and configuration changes) removes the previous 1-hour HTTP ResponseCache (Cache-Control: public, max-age=3600, s-maxage=7200) from the three outlet-lookup endpoints — GET api/menu/outlets, GET api/menu/outlet/{outletId}, and GET api/menu/outlet/reference/{reference} (the last is the one this entry cites in 'Entry and menu availability' as the outlet-URL resolution call). All three now respond Cache-Control: no-store, no-cache, must-revalidate (ResponseCache(Duration = 0, Location = None, NoStore = true)). Practical effect: outlet-level metadata — name, logo, and the IsAcceptingOnlineOrders flag that gates this entry's amber 'not accepting online orders' banner — previously could be served stale from a browser/shared cache for up to 1–2 hours after a merchant-side change; it now reflects immediately on next request. This is a purely additive fix (removes staleness, doesn't change any documented UI behavior, banner copy, or endpoint semantics) — no contradiction to existing claims, nothing to edit. Re-pin foodops.ordering.api to 718a6ac on next full verification pass. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-08 — codebase: foodops.ordering.app (Header.tsx, new OutletSwitcherModal.tsx) added an in-header outlet switcher, not covered by this entry's 'Entry and menu availability' section. When the outlet name/logo query resolves 2+ outlets sharing the current outlet's organizationId, the 'Welcome to {outlet}' text becomes a tappable control (now shown on mobile too, not desktop-only as before) that opens a drawer ('Switch Outlet') listing sibling outlets with logo/address, a green 'Current' badge on the active one, and tap-to-switch on the rest. Switching is a hard reset for the previous outlet's session: it clears cart state and any bound table id (outletStorage.clearTableId(), setCartTableId(null)) before persisting the new outlet and doing a full navigation to /menu — consistent with, and reinforcing, this entry's existing 'table-QR session is locked to dine-in' / 'picking an outlet clears table context' claims, so it does not contradict them. This is an additional entry point alongside the three already documented (outlet URL, table QR, bare-root picker) for customers who are already browsing one outlet in a multi-outlet organisation. Cosmetic-only in the same diff: header logo shrunk (35px→28px height, 'full' wordmark→icon-only 'menu' variant on fallback), and category headings/VariantSelectionDrawer got minor dark-mode/interaction tweaks (variant picker now adds an item immediately on tap instead of select-then-'Add Item') — none rise to documented-behavior changes for this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-04 — codebase: foodops.ordering.api@93d14db adds a guest/walk-in order-submission path not covered by this entry. POST order/guest-submit (PaymentController.cs) requires NO session token, phone, or OTP — only a customer name (min 2 chars, guarded via ModelState/length check) — and is dine-in/takeaway only (explicit BadRequest when orderType == "delivery"). Payment is hardcoded to cash ('settled in person; no payment processed' per comment) and cart items arrive inline and are server re-priced (RepriceGuestItemsAsync). The order links to a shared per-organisation 'Walk-In Customer' record (CustomerService.GetOrCreateWalkInCustomerAsync, mirroring the counter/POS walk-in account, created via AccountHelper.CreatePosCustomerAccount with a race-safe re-query) rather than a phone-verified customer; the guest's name is recorded in order notes as Guest: {name} (pipe-joined with any typed instructions), and OrderSubmissionService.SubmitOrderAsync now skips session validation entirely when request.IsGuest. This is an alternate path alongside — not a replacement for — the OTP-based checkout this entry documents in section 3 and the 'Phone is the account' key concept; no foodops.ordering.app (order.foodops.io storefront) diff was supplied in this pass, so it is NOT yet confirmed whether/how guests trigger this from the customer-facing UI (vs. e.g. a counter/staff-side walk-in entry point). Flag for a follow-up pass with app-side evidence before editing the OTP-mandatory claims. Separately and lower-priority: OrderController.cs (GetCustomerOrderDetails and GetCustomerOrders) now populates DeliveryAddress from the order's ShippingAddress.AddressLine1 for delivery orders — previously hardcoded to "" with a 'TODO' comment — a plumbing fix that doesn't contradict any documented tracking behavior in this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-XX — codebase: foodops.ordering.app added a name-only guest checkout path for anonymous dine-in/takeaway carts (GuestNameSheet + POST /order/guest-submit, gated by showGuestCheckout = anonymous && !groupMode && orderType in {dinein, takeout} && cart non-empty && ordering enabled). No phone or OTP is collected; cart items and the guest's name are submitted inline (GuestOrderSubmissionRequest), and on success the cart clears and an order reference is shown, but the guest is not routed to the trackable /orders/{id} page (login-gated) and the CSAT drawer is skipped. Delivery and group-cart checkout are unaffected and still require phone+OTP. Separately, OrderDetailsPage.tsx hardened the transfer-slip warn-but-allow flow with deferred toast/dialog timing and a ~600ms phantom-click-through guard on 'Upload Anyway' — implementation-only, no change to the documented dialog copy or button labels. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-06-11 — codebase: entry seeded against foodops.ordering.app@09e5e61 + foodops.ordering.api@d44572f (both main). Scope split from the umbrella online-ordering.md: this entry owns the customer-facing depth the umbrella lacked — payment options (BML initiate / cash process / transfer receipt-upload endpoints + per-outlet enablement + allow-payment-without-acceptance), the logged-out tracking-link flow (OrderAccessPage), exact OTP provider routing (SmsServiceFactory: India→Fast2SMS by country name, MsgOwl default), and the two changes newer than the umbrella's 2026-06-04 pass (modifier MaximumGroupQuantitySelection validation 5bd675f/2a9e64b; overlay back-button history b0b419a). Minimum-order-per-zone and customer self-cancel verified NOT FOUND in storefront code. Confidence medium; payment sheet / transfer upload / order-access visuals pending Playwright.

  • 2026-06-17 — codebase (drift triage): corrected the "no self-cancel" edge case. foodops.ordering.app@b63bce5 now ships customer self-cancel (commit 3ad4207: Cancel button + ConfirmationDialog, gated by canCancelOrder = order still "New"/pre-acceptance, orderService.cancelOrder + "Order cancelled" toast) plus cancelled-order status display. Re-pinned ordering.app ee910dd→b63bce5. (foodops.ordering.api also gained SignalR order-cancellation notifications + a net9→net10 bump in the same window — not separately re-verified this pass.)

  • 2026-06-29 — codebase: NEW-STACK re-verification for July-10 launch. Re-pinned foodops.ordering.app@25f1e9a82e + foodops.ordering.api@80e07fdf7a. AI transfer-slip validation RE-CONFIRMED present and live across all three repos: TransferSlipValidationService.cs:18-31,58-63 uses Google Gemini (gemini-2.5-flash), inert when Gemini:ApiKey unset; TransferSlipVerdictBuilder.cs:23-59 compares amount/currency/date only (CORRECTED the prior "amount/currency/date/reference" claim — no reference comparison exists); storefront warn-but-allow ConfirmationDialog at OrderDetailsPage.tsx:757-764 with verbatim copy ("Slip may not be valid" / "Upload Anyway" / "Choose Another"); counter staff "transfer slip needs review" toast at useSignalR.ts:188-201. Re-pointed the "POS/monolith notifies the ordering API" menu-publish line off the retired monolith. OTP country routing, payment options, tracking-link flow, self-cancel all still match main.

Additional verification events are appended here by the weekly KB sync and by manual re-verification passes. See _meta/failure-log.md for the global failure index.

Go beyond POS.
Choose the total solution.

Focus on creating the best experience for your guests while we handle the rest.