Checkout & orders
Pending pickup checkout that snapshots a tenant cart into an order.
Checkout & orders
The fourth product slice is Checkout → Order. A guest turns a non-empty, valid tenant cart into a pending pickup order. Payment is cash / unpaid. There is no payment provider.
Cart prices stay live until submit. Checkout snapshots name, unit price, quantity, and line totals. Later menu edits do not change the order.
Operator accept/reject and the 10-minute timeout are Slice 5: Accept, reject & timeout. Kitchen status after accept is Slice 6: Kitchen status. Pickup and delivery checkout are gated by weekly opening hours, then by the order pause: Opening hours.
Tables
Seeds do not create orders. Luigi/Mario isolation is proven by real checkouts in tests.
orders
| Column | Notes |
|---|---|
id | UUID |
restaurant_id | FK → restaurants.id (ON DELETE restrict). Tenant key |
fulfillment_type | pickup only in this slice |
order_status | Written as pending at checkout. Accept/reject/timeout may set accepted or cancelled. Kitchen may set preparing / ready / completed |
payment_status | Written as unpaid |
payment_method | Written as cash |
customer_name / customer_phone | Required. Phone is stored as E.164 (ADR-008); checkout validates presence/length only |
note | Optional, max 500 trimmed characters |
subtotal_cents | Snapshot of visible line totals |
acceptance_deadline_at | created_at + 10 minutes (ADR-016). Enforced by the Slice 5 scheduler |
eta_minutes | Nullable. Set only on accept (15/30/45/60) |
cancellation_reason | Nullable. Set on reject/timeout |
created_at / updated_at | Timestamps |
order_items
| Column | Notes |
|---|---|
id | UUID |
order_id | FK → orders.id (ON DELETE cascade) |
restaurant_id | Defense-in-depth copy of the tenant key |
menu_item_id | FK → menu_items.id (reference only, not a live price) |
name | Snapshot at submit |
unit_price_cents | Snapshot at submit |
quantity | Integer 1..99 |
line_total_cents | Snapshot (unit_price_cents × quantity) |
created_at / updated_at | Timestamps |
Submit rules
Checkout uses the same hostname tenant gate as /storefront/cart. Query/body restaurantId is ignored.
- Every stored cart line must still be
is_activeand belong to the resolved tenant. Otherwise 409 and no order. - Empty cart, or a cart that would only contain omitted inactive lines → 409.
- Closed or empty opening windows at injectable
now→ 409 (Restaurant is closed). Cart is unchanged. No order row. Unknown/inactive host is 404, not 409. See Opening hours. - Success: persist the order, empty cart lines, keep cookie
bb_cart(same asDELETE /storefront/cart). - Luigi checkout never includes Mario items. A Luigi
orderIdon Mario's host returns 404. - A Luigi
bb_cartcookie sent to Mario does not create a Luigi order.
Public API
| Method | Path | Body |
|---|---|---|
POST | /storefront/checkout | { name, phone, note?, restaurantId? } |
GET | /storefront/orders/:orderId | — |
note is optional. After trim it is stored as null when empty, otherwise at most 500 characters.
Example (Luigi, two Margherita, still pending):
{
"id": "<order uuid>",
"restaurantId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
"fulfillmentType": "pickup",
"orderStatus": "pending",
"paymentStatus": "unpaid",
"paymentMethod": "cash",
"customerName": "Anna",
"customerPhone": "+491111111111",
"note": null,
"items": [
{
"id": "<order_item uuid>",
"menuItemId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5",
"name": "Margherita",
"quantity": 2,
"unitPriceCents": 850,
"lineTotalCents": 1700
}
],
"itemCount": 2,
"subtotalCents": 1700,
"createdAt": "<iso>",
"acceptanceDeadlineAt": "<iso>",
"etaMinutes": null,
"cancellationReason": null
}Unknown or inactive restaurant → 404. Database failure during hostname lookup → 503.
Checkout still does not send SMS or e-mail. Transactional SMS starts at accept / reject / timeout / ready. See Transactional notifications.
Dashboard API
Requires a staff session. Restaurant context is the path (ADR-021), not the storefront hostname.
| Method | Path |
|---|---|
GET | /dashboard/restaurants/:restaurantId/orders |
GET | /dashboard/restaurants/:restaurantId/orders/:orderId |
POST | /dashboard/restaurants/:restaurantId/orders/:orderId/accept |
POST | /dashboard/restaurants/:restaurantId/orders/:orderId/reject |
POST | /dashboard/restaurants/:restaurantId/orders/:orderId/preparing |
POST | /dashboard/restaurants/:restaurantId/orders/:orderId/ready |
POST | /dashboard/restaurants/:restaurantId/orders/:orderId/complete |
List is newest first and shows the real status. Accept/reject and the timeout job: Accept, reject & timeout. Kitchen after accept: Kitchen status. A Luigi order under Mario's restaurantId is 404.
Storefront
From the cart summary, Checkout opens /checkout (not on storefront.bytebite.test). The page shows live cart lines and EUR total, then name, phone, and optional note. While the restaurant is closed, the primary CTA is disabled and copy explains that pickup is unavailable. While it is open but paused, the CTA is disabled with “Not taking orders right now.” Guests can still browse and add to the cart.
Success goes to a confirmation with the order ID, snapshot lines, and pending / bar / Abholung. After accept/reject/timeout the same page shows accepted (with ETA) or cancelled (rejected vs timeout). After kitchen writes it shows preparing, ready, or completed. An empty cart is not a dead-end: guests go back to the menu.
SSR uses API_INTERNAL_URL + X-ByteBite-Tenant-Host + cookie forward, cache: "no-store". Browser submit uses same-origin /api with credentials: "include". Confirmation polls the existing GET.
Not in this slice
BitePay/Stripe, card_at_door, capture/refund/void, customer accounts, extras/allergens, cancel after accept, customer cancel, checkout SMS/e-mail, sound/PWA, production deploy. Sizes: Menu sizes. Delivery: Delivery. Closed checkout 409: Opening hours. Kitchen after accept: Kitchen status. Dashboard session: Staff authentication. Accept/reject/timeout/ready SMS: Transactional notifications.