# Bober BBQ — `/api/*` Reference This documents every route registered under the Flask blueprint `api_bp` (`bober_bbq/api/__init__.py`, `url_prefix="/api"`), consumed by the React/Vite Mini App in `webapp/`. It was produced by reading every route handler in `bober_bbq/api/*.py` in full, plus the models, services and utility modules each handler calls into. Every field, status code, and error body below is traceable to a specific line of source; anywhere the code's behavior was not 100% certain, that's called out explicitly as a **Note** rather than guessed. Blueprint composition (`bober_bbq/api/__init__.py`): | Sub-blueprint | `url_prefix` | Final path prefix | |---|---|---| | `menu_bp` | *(none)* | `/api` | | `cart_bp` | `/cart` | `/api/cart` | | `settings_bp` | `/settings` | `/api/settings` | | `payments_bp` | `/payments` | `/api/payments` | | `orders_bp` | `/orders` | `/api/orders` | | `checkout_bp` | `/checkout` | `/api/checkout` | | `me_bp` | `/me` | `/api/me` | All request/response bodies are JSON. There is no `flask_cors`/`CORS(...)` usage anywhere in `bober_bbq/` (confirmed by search) and no custom `@app.errorhandler` for 404/405/500 was found in `bober_bbq/app.py` — those fall through to Flask's default (non-JSON) error pages. `MAX_CONTENT_LENGTH` is 8 MB (`bober_bbq/config.py`). --- ## Authentication Source: `bober_bbq/api/auth.py`, `bober_bbq/utils/telegram_auth.py`. There are two decorators. `menu`, `settings`, and the Monobank webhook use neither (no auth at all). **Every other route in this document — `cart_bp`, `orders_bp`, `checkout_bp`, `me_bp` — uses `@require_customer_auth`**, which wraps the Telegram-validation logic below with a cookie-based guest fallback and **never returns 401**. ### Telegram validation (shared core logic) 1. Reads the raw Telegram Mini App `initData` string from the **`X-Telegram-Init-Data`** request header (not a query param, not the body). 2. Validates it via `validate_init_data()`, which implements Telegram's official Mini App validation algorithm ([docs](https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app)): - Parses the string as a query string (`parse_qsl(..., strict_parsing=True)`); a malformed string fails closed (returns `None`). - Pops out the `hash` field; if absent, fails. - Rebuilds the `data_check_string` by joining all remaining `key=value` pairs sorted by key with `\n`. - Computes `secret_key = HMAC_SHA256(key=b"WebAppData", msg=bot_token)`, then `computed_hash = HMAC_SHA256(key=secret_key, msg=data_check_string)`, and compares it to the received `hash` with `hmac.compare_digest` (constant-time). - Rejects (`None`) if `auth_date` is more than **24 hours** old (`MAX_AGE_SECONDS = 24 * 60 * 60`). - Parses the `user` field's JSON string into a dict. - `bot_token` comes from `config.BOT_TOKEN`; if it's unset, validation always fails. 3. **Dev-mode bypass**: if validation failed (`data is None`) *and* `current_app.debug` is `True` (i.e. Flask debug mode is on), it reads a `?dev_user_id=` query parameter. If present, it fabricates `{"user": {"id": , "first_name": "Dev"}}` and treats it as authenticated — completely skipping signature verification. This bypass is unavailable when `debug` is off (e.g. production), and does nothing if `dev_user_id` isn't supplied. 4. On success, the Telegram user is **upserted** into `TelegramUser` (`_upsert_user`): looked up by `telegram_id`, created if missing (seeding `locale` from the token payload's `language_code`, normalized to `uk`/`en` — never touched again automatically after that, see `docs/I18N.md`), and `username`/`first_name` are overwritten from the token payload on every request. This runs (and commits) on every authenticated call, even read-only ones like `GET /api/cart`. 5. The resulting `TelegramUser` row is stashed on `g.telegram_user` for the view function to use. A failed validation attempt (but not a dev-mode bypass, and not one that then falls through to the guest path below) is logged at `WARNING` with the request path, `initData` length, a 120-char preview of it, and the `User-Agent` header — useful for diagnosing integration issues. ### `require_telegram_auth` (Telegram-only, currently unused by any route) The original decorator: runs the Telegram validation above and, if it fails (no valid `data`/`data["user"]`), rejects the request outright: ``` 401 Unauthorized {"error": "invalid or missing Telegram init data"} ``` Still defined in `auth.py` and still exercised indirectly (its logic is reused by `require_customer_auth` below), but as of the guest-ordering feature no route is wrapped in it directly anymore — every former caller switched to `require_customer_auth`. ### `require_customer_auth` (Telegram, falling back to a guest) Added so someone with **no Telegram account at all** can still use the Mini App's cart/checkout/order-history/locale endpoints if they open the plain webapp URL directly (outside Telegram) — the same `webapp/dist` bundle, no separate app. It: 1. Runs the same Telegram validation as above (including the dev-mode bypass). 2. If that succeeds, behaves identically to `require_telegram_auth` (`g.telegram_user` = the real `TelegramUser`). 3. If it fails, looks up a **guest** identity from the Flask session cookie (`session["guest_telegram_id"]`). If the cookie has none yet, it mints a fresh one via `allocate_guest_telegram_id()` (`bober_bbq/models.py`): a small `GuestIdSequence` table exists purely to hand out a collision-proof integer per guest, turned into `telegram_id = -(1_000_000 + sequence_id)` — i.e. always negative, and always past `GUEST_ID_BASE = 1_000_000` so it's visually distinct in logs/DB browsing from the pre-existing walk-in sentinel user, `WALKIN_TELEGRAM_ID = -1` (used for orders created manually from the admin panel). A `TelegramUser(telegram_id=, first_name="Гість")` row is created and the id is saved back into the session cookie (`session.permanent = True`, ~31 days) so the same guest is recognized on their next request/page reload. 4. **Never returns 401** — by the time the view function runs, `g.telegram_user` is always set, to either a real Telegram user or a guest. Consequences for every route below that lists `@require_customer_auth`: there is **no 401 error case** to document per-route (removed from the tables below accordingly), and a guest's cart/orders are scoped to their own `TelegramUser.telegram_id` exactly the same way a real Telegram user's are — nothing else in `cart.py`/`checkout.py`/`orders.py` needed to change to support guests, since none of it assumed `telegram_id > 0`. **Note:** there is no CSRF token, API key, or rate limiting on top of any of this — the Telegram init-data header (or, for a guest, the session cookie Flask itself signs) is the sole credential for every protected route. --- ## Menu (`menu_bp`, no extra prefix → `/api/...`) Source: `bober_bbq/api/menu.py`. No auth required on either route. ### `GET /api/categories` Lists active menu categories, ordered by `sort_order`. - Auth: none. - Query params: `locale` (optional, `"uk"` | `"en"`, defaults to `"uk"` for anything missing/unrecognized — see `normalize_locale()` in `bober_bbq/utils/i18n.py`). No session exists on this unauthenticated route to read a stored preference from, so the caller must pass it explicitly to get English names. - Request body: none. - Success — `200 OK`, a JSON array (not wrapped in an object): ```json [ { "id": 1, "name": "Бургери", "slug": "burgers", "sort_order": 0 } ] ``` Fields per `Category.to_dict(locale)`: `id` (int), `name` (str — `name_en` when `locale=en` **and** it's been filled in for that category, otherwise the Ukrainian `name`), `slug` (str, always the original slug regardless of locale), `sort_order` (int). Only rows with `is_active = true` are returned. - Errors: none defined — DB errors would surface as an unhandled Flask 500 (no custom error body). ### `GET /api/products` Lists active products, ordered by `sort_order`. - Auth: none. - Query params: `locale` (optional, same as `/api/categories` above). - Request body: none. - Success — `200 OK`, JSON array: ```json [ { "id": 10, "category_id": 1, "name": "Чізбургер", "description": "Соковита котлета, сир чеддер, соус.", "weight": "250 г", "price": 149, "image_url": "/static/products/10.jpg", "allergens": [ { "code": "gluten", "label": "Глютен (зернові)", "emoji": "🌾" }, { "code": "milk", "label": "Молоко", "emoji": "🥛" } ] } ] ``` Fields per `Product.to_dict(locale)`: `id`, `category_id` (int), `name` (str — `name_en` when `locale=en` and set, else Ukrainian `name`), `description` (str — same fallback via `description_en`), `weight` (str, e.g. `"300 г"` — `weight_en` when `locale=en` and set, else Ukrainian `weight`), `price` (int, whole UAH — **not** cents), `image_url` (str, may be a relative `/static/...` path), `allergens` (array of `{code, label, emoji}` — `label` is one of the 14 EU-mandated allergen names, translated per `locale` from a fixed dict in `bober_bbq/models.py`, not from a database column; unknown codes silently dropped). Only `is_active = true` products are returned; there's no per-category filter query param — the client filters client-side by `category_id`. - Errors: none defined; unhandled DB errors → default Flask 500. --- ## Settings (`settings_bp`, prefix `/api/settings`) Source: `bober_bbq/api/settings.py`. No auth required. ### `GET /api/settings` Public cafe configuration used to render the Mini App shell (name, hours, theme, delivery fee, etc.) before the user is even authenticated. - Auth: none. - Request body: none. - Success — `200 OK`: ```json { "cafe_name": "Bober BBQ", "logo_url": "", "phone": "+380 XX XXX XX XX", "address": "м. Київ, вул. Прикладна, 1", "work_hours_from": "11:00", "work_hours_to": "21:00", "is_open": true, "instagram_url": null, "maps_url": null, "delivery_fee": 60, "free_delivery_threshold": 500, "min_delivery_order_amount": 0, "pickup_discount_percent": 15, "force_closed": false, "force_closed_message": "", "announcement_enabled": false, "announcement_message": "", "theme": { "accent": "#ff7a3d", "accent_2": "#ffa374", "accent_grad": "linear-gradient(135deg, #ffa374 0%, #ff7a3d 55%, #e06c36 100%)", "accent_ink": "#2a0f00" }, "has_active_promos": false } ``` All values come straight from the `Setting` key/value table via `Setting.get` / `get_int` / `get_bool`, except: - `is_open`: computed by `workhours.is_open()` from `work_hours_from`/`work_hours_to` in the configured timezone (`config.TIMEZONE`), including overnight-schedule handling (e.g. `20:00`–`02:00`). - `force_closed`: `Setting.get_bool("force_closed", False)` — an admin kill switch independent of working hours. - `theme`: derived from the single hex color in `webapp_theme_accent` (default `#ff7a3d`) via `derive_palette()`, which lightens/darkens it and picks a readable ink color based on perceived luminance. - `has_active_promos`: `True` if at least one `PromoCode` is active, not expired, and not past `max_uses` — **ignores** per-user restrictions like `first_order_only`/`min_order_amount` (those need cart/user context), so it can be `true` even for a code a specific user couldn't actually use. - Errors: none defined; unhandled DB errors → default Flask 500. --- ## Cart (`cart_bp`, prefix `/api/cart`) Source: `bober_bbq/api/cart.py`. **All routes require `@require_customer_auth`** (see Authentication above) — a caller with no valid Telegram init data still succeeds, as a cookie-bound guest, so **none of these routes ever return 401**. The cart is keyed by `CartItem.user_id = g.telegram_user.telegram_id` — one shared cart per Telegram user or guest (also used by the Telegram bot itself, per the module's own comment on `CartItem`). Every route below returns the same cart envelope, built by `_serialize_cart()`: ```json { "items": [ { "product_id": 10, "name": "Чізбургер", "weight": "250 г", "image_url": "/static/products/10.jpg", "price": 149, "quantity": 2, "line_total": 298 } ], "items_total": 298, "count": 2 } ``` `items_total` = sum of `line_total` across items (int, UAH). `count` = sum of `quantity` across items (int; NOT number of distinct line items). ### `GET /api/cart` - Auth: required. - Request body: none. - Success: `200 OK`, cart envelope above (empty cart → `{"items": [], "items_total": 0, "count": 0}`). - Errors: none (see `require_customer_auth` above). ### `POST /api/cart/items` Adds a product to the cart, or increments its quantity if already present. - Auth: required. - Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `product_id` | int | yes (effectively) | No product matches `None`/missing → `404`. | | `quantity` | int | no, default `1` | Coerced with `int(...)`; a non-numeric value raises an unhandled `ValueError` → default Flask 500 (see Note below). | Example: ```json { "product_id": 10, "quantity": 2 } ``` - Success — `201 Created`, the updated cart envelope (see above). If the product is already in the cart, `quantity` is **added** to the existing line's quantity (not replaced). - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "product not found"}` | no active `Product` with that `id` (`Product.query.filter_by(id=product_id, is_active=True)`) | | 400 | `{"error": "quantity must be >= 1"}` | `quantity < 1` | **Note:** the product-existence check runs *before* the quantity check, so a nonexistent `product_id` with `quantity: 0` returns 404, not 400. Also, `request.get_json(silent=True) or {}` means a non-JSON or empty body is treated as `{}` rather than erroring, so `product_id` defaults to `None` and the request always 404s in that case (no distinct "missing product_id" error). **Note:** `int(body.get("quantity", 1))` will raise `ValueError`/`TypeError` (uncaught → 500) if `quantity` is present but not int-coercible (e.g. `"abc"`); this is not guarded against, unlike the delivery checkout's `cash_change_for` parsing which does catch it. ### `PATCH /api/cart/items/` Sets an existing cart line's quantity (or removes it if the new quantity is `<= 0`). - Auth: required. - Path param: `product_id` (int). - Request body: | Field | Type | Required | Notes | |---|---|---|---| | `quantity` | int | no, default `1` | Same `int(...)` coercion caveat as above. | Example: `{ "quantity": 3 }` - Success — `200 OK`, updated cart envelope. If `quantity <= 0`, the line item is deleted entirely rather than set to zero. - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "item not in cart"}` | no `CartItem` for this user + `product_id` | ### `DELETE /api/cart/items/` Removes one line item if present; idempotent if not. - Auth: required. - Path param: `product_id` (int). - Request body: none. - Success — `200 OK`, updated cart envelope, **even if the item wasn't in the cart** (no 404 in that case — this route silently no-ops). - Errors: none. ### `DELETE /api/cart` Empties the entire cart. - Auth: required. - Request body: none. - Success — `200 OK`, `{"items": [], "items_total": 0, "count": 0}`. - Errors: none. --- ## Orders (`orders_bp`, prefix `/api/orders`) Source: `bober_bbq/api/orders.py`. **All routes require `@require_customer_auth`** (see Authentication above — never returns 401) and are scoped to the caller's own orders (`Order.query.filter_by(..., user_id=g.telegram_user.telegram_id)`) — there is no way for a user to view or act on another user's order via this blueprint. Order object shape (`_serialize_order`), used by every route in this section: ```json { "id": 42, "number": "№1042", "order_type": "delivery", "status": "cooking", "status_label": "Готується", "payment_method": "cash", "payment_status": "unpaid", "items_total": 298, "delivery_fee": 60, "discount_amount": 0, "promo_code": null, "promo_discount_amount": 0, "total": 358, "created_at": "2026-08-27T10:15:00", "pickup_time": null, "address_street": "вул. Прикладна", "address_house": "1", "table_number": null, "can_cancel": false, "can_pay": false, "items": [ { "name": "Чізбургер", "quantity": 2, "price": 149 } ] } ``` Notes on fields: - `number`: `order.display_number()` — `"№{number}"` if a sequence number has been assigned, else `"#{id}"` as a fallback. It's a **string**, not the raw int. - `status`: one of `new | confirmed | cooking | ready | courier | completed | cancelled` (`ORDER_STATUSES` in `models.py`); `status_label` is translated per the order's own customer's `TelegramUser.locale` (via `t("order.status_label.", locale)` in `bober_bbq/utils/i18n.py` — a separate translation namespace from the Ukrainian-only `ORDER_STATUS_LABELS` the admin panel uses; falls back to Ukrainian, then to the raw key, if somehow unrecognized). - `can_cancel`: `true` only while `status` is `new` or `confirmed` (`CANCELLABLE_STATUSES`). - `can_pay`: `true` only if `payment_method == "card"`, `payment_status == "unpaid"`, and `status` not in `("cancelled", "completed")`. - `items`: a snapshot taken at order-creation time (`OrderItem.product_name`/`product_price`), so it reflects prices/names as they were when ordered, not current catalog values; note there's no `product_id` or `line_total` field in this list (unlike the cart envelope). - Delivery-only fields (`address_street`, `address_house`) are always present in the JSON but `null` for pickup/dine-in orders; likewise `pickup_time` is only non-null for pickup orders, `table_number` only for dine-in. (`address_apartment`/`address_comment`/`address_city` are **not** included in this serialization at all, even though they exist on the model.) ### `GET /api/orders` Lists the caller's most recent orders. - Auth: required. - Request body: none. - Success — `200 OK`, JSON array of up to **50** orders (`ORDER BY created_at DESC LIMIT 50`), each shaped as above. No pagination beyond this fixed limit. - Errors: none. ### `POST /api/orders//cancel` Customer self-service cancellation. - Auth: required. - Path param: `order_id` (int). - Request body: none. - Success — `200 OK`, the updated order object (`status: "cancelled"`). - Side effects: sets `cancel_reason = "Скасовано клієнтом"`; calls `restore_for_order(order)` (restores deducted inventory, per `bober_bbq/utils/inventory.py`); calls `notify_manager_order_cancelled(order)` which notifies staff. - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "not_found"}` | no such order for this user | | 400 | `{"error": "not_cancellable", "message": "..."}` | `status` not in `{"new", "confirmed"}` — `message` text is translated per the order's own customer's `TelegramUser.locale` (see `docs/I18N.md`), e.g. `"This order can no longer be cancelled on your own — it's already being prepared. Please contact a manager."` in English | ### `POST /api/orders//repeat` Re-adds a past order's items to the current cart (a "reorder" convenience feature). - Auth: required. - Path param: `order_id` (int). - Request body: none. - Success — `200 OK`: ```json { "cart": { "items": [...], "items_total": 0, "count": 0 }, "skipped": ["Старий бургер"] } ``` `cart` is the same envelope as the cart endpoints. `skipped` is an array of **product names** (strings) for order-items whose `product_id` no longer resolves to an active `Product` (deleted, deactivated, or the historic item had no `product_id` at all) — those are silently omitted from the cart instead of erroring. - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "not_found"}` | no such order for this user | **Note:** this endpoint has no cart-empty or product-active-count validation beyond the per-item skip logic above — it will happily merge quantities into an already-non-empty cart. ### `POST /api/orders//pay` (Re-)generates a Monobank payment link for an existing card order — e.g. if the customer navigated away from the original checkout payment page. - Auth: required. - Path param: `order_id` (int). - Request body: none. - Success — `200 OK`: `{ "pay_url": "https://pay.mbnk.biz/..." }` (or a LiqPay checkout URL — whichever provider is currently active, see `bober_bbq/payments/registry.py`; the response shape is identical either way). - Side effects: calls the active payment provider's invoice-create API (`get_active_provider().create_invoice(order)`), then persists that provider's invoice id/page URL on the order (`Order.payment_provider` records which one, so later polling/reconciliation always talks to the same gateway that created the invoice even if the admin switches the active provider afterward). - All `message` text below is translated per the order's own customer's `TelegramUser.locale` (see `docs/I18N.md`); Ukrainian shown here for brevity. - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "not_found"}` | no such order for this user | | 400 | `{"error": "not_payable", "message": "Це замовлення не передбачає оплату карткою"}` | `order.payment_method != "card"` | | 400 | `{"error": "already_paid", "message": "Замовлення вже оплачено"}` | `order.payment_status == "paid"` | | 400 | `{"error": "not_payable", "message": "Це замовлення вже неактивне"}` | `order.status in ("cancelled", "completed")` | | 400 | `{"error": "not_payable", "message": "Оплата карткою тимчасово недоступна"}` | active provider has no usable credentials (`ProviderNotConfigured`) | | 502 | `{"error": "payment_provider_error", "message": "Не вдалося створити платіж, спробуйте ще раз"}` | provider API call failed (`ProviderError`, e.g. non-200 response) — **note this key changed from `monobank_error`** once a second provider (LiqPay) was added | --- ## Checkout (`checkout_bp`, prefix `/api/checkout`) Source: `bober_bbq/api/checkout.py`. **All routes except `GET /track/` require `@require_customer_auth`** (see Authentication above — never returns 401; `GET /track/` has no auth at all, see below). This blueprint replaced a multi-step bot chat checkout flow — order details (name/phone/address/payment/pickup time) are now submitted as a single form from the Mini App, per the module's own docstring. ### Shared validation across the three order-creation routes **Every `message` string below is translated per the caller's own `TelegramUser.locale`** (via `t()` in `bober_bbq/utils/i18n.py` — see `docs/I18N.md`); Ukrainian is shown here for brevity, but an English-locale customer gets the English variant of the same text. Before any field-level validation, `POST /delivery`, `POST /pickup`, and `POST /dine-in` all call `_validate_cart()`, which: 1. Loads the caller's cart via `get_cart_items()` (same shared cart as `/api/cart`). If empty: ``` 400 { "error": "cart_empty", "message": "Кошик порожній" } ``` 2. Checks `orders_allowed()` (working hours **and** the `force_closed` admin kill switch, **or** the `allow_orders_outside_hours` setting). If orders aren't currently allowed: ``` 400 { "error": "closed", "message": "" } ``` `closed_message(locale)` returns the admin's custom `force_closed_message` verbatim if force-closed (business content, always shown as the admin typed it, regardless of locale), else a translated message stating the cafe name and working hours. All three also validate `name`/`phone` via `_contact_field_error()`: - `name`: must be at least 2 characters (after `.strip()`), else `"Вкажіть ім'я (мінімум 2 символи)"`. - `phone`: after stripping all non-digit characters, must have between 9 and 13 digits inclusive, else `"Перевірте номер телефону — здається, він неповний"`. Both return as: ``` 400 { "error": "validation", "message": "" } ``` All three also share promo-code resolution via `_resolve_promo()`: if `promo_code` is present and non-blank in the body, it's validated with `validate_promo_code(code, user_id, items_total, locale)`; on failure: ``` 400 { "error": "promo_invalid", "message": "" } ``` If `promo_code` is omitted/blank, no promo is applied and no error occurs. All three, on success, call `_finalize(order)`, which: 1. Calls `notify_manager_new_order(order)` — notifies the staff/manager Telegram chat (always Ukrainian; staff-facing, not translated). If the caller is a guest (`user_id <= 0`, no real Telegram chat to notify), this and the "thank you" message below are silently skipped rather than attempted — see `allow_negative` in `bober_bbq/utils/telegram_notify.py`. 2. Sends the customer a Telegram "thank you" message (translated) via `send_message` (real Telegram customers only, per the note above — a guest never gets a Telegram message at all, only the HTTP response itself). 3. If `payment_method == "card"`, calls `_offer_card_payment(order, locale)`, which now returns **both** a pay URL and a possible unavailability message (`(pay_url, card_unavailable_message)`): - Attempts `get_active_provider().create_invoice(order)` — whichever payment provider (Monobank or LiqPay) is currently active, see `bober_bbq/payments/registry.py`. - If it isn't configured (`ProviderNotConfigured`) or the call fails (`ProviderError`), it **does not fail the request** — `pay_url` in the response is `null`, and the same translated "card payment temporarily unavailable, a manager will contact you" text that's sent to a real Telegram customer is *also* returned in the response as `card_unavailable_message`, so a guest (who never gets that Telegram message) still sees it on-screen. - On success, persists that provider's invoice id/page URL on the order (`Order.payment_provider` records which one), returns the pay URL, and `card_unavailable_message` is `null`. The finalize response shape (used by all three creation routes) is: ```json { "order_id": 42, "order_number": "№1042", "total": 358, "payment_method": "cash", "pay_url": null, "card_unavailable_message": null, "track_url": "/webapp/?track=yT9UOofwEmb-dFdD2BVSGA" } ``` - `card_unavailable_message` (string, translated, or `null`): see point 3 above — only ever non-null when `payment_method == "card"` and the active provider was unreachable/unconfigured. - `track_url` (string, or `null` if the order somehow has no `tracking_token`): a same-origin path into the Mini App's own SPA (`/webapp/?track=`), **not** an absolute URL — see "Order tracking" below. `token` is `Order.tracking_token`, a `secrets.token_urlsafe(16)` value generated for **every** order (Telegram customers included, not just guests — see `bot/services/order_service.py`), so the same link mechanism works regardless of how the order was placed. ### `POST /api/checkout/delivery` - Auth: required. - Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | min 2 chars after strip | | `phone` | string | yes | 9–13 digits after stripping non-digits | | `address_city` | string | yes | | | `address_street` | string | yes | | | `address_house` | string | yes | | | `address_apartment` | string | no | blank/absent → `null` | | `address_comment` | string | no | blank/absent → `null` | | `payment_method` | string | yes | must be `"cash"` or `"card"` | | `cash_change_for` | int | no | only meaningful when `payment_method == "cash"`; must be numeric if provided and non-blank | | `promo_code` | string | no | see promo resolution above | Example: ```json { "name": "Олена", "phone": "+380971234567", "address_city": "Київ", "address_street": "вул. Хрещатик", "address_house": "10", "address_apartment": "5", "payment_method": "cash", "cash_change_for": 500 } ``` - Order-specific validation, in this exact sequence: 1. `_validate_cart()` (cart-empty / closed checks, see above). 2. Minimum order amount: if `Setting.get_int("min_delivery_order_amount") > 0` and the cart's `items_total` is below it: ``` 400 { "error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {min_amount} грн. Додайте ще товарів у кошик." } ``` 3. Promo resolution (`_resolve_promo`). 4. Required-field presence check over `["name", "phone", "address_city", "address_street", "address_house", "payment_method"]` — any missing/blank (after `str(...).strip()`) field: ``` 400 { "error": "validation", "missing": ["", "", ...] } ``` **Note:** this is the one error shape in the whole API that uses `"missing"` (a list of field names) instead of `"message"`. 5. `payment_method` must be `"cash"` or `"card"`: ``` 400 { "error": "validation", "message": "invalid payment_method" } ``` 6. If `payment_method == "cash"` and `cash_change_for` is present/non-blank, it must parse as an int: ``` 400 { "error": "validation", "message": "cash_change_for must be a number" } ``` 7. `name`/`phone` contact validation (`_contact_field_error`, see above). - Success — `201 Created`, the finalize response shape above. - Side effects: creates the `Order` + `OrderItem` rows, deducts ingredient inventory (`deduct_for_order`), **clears the cart**, increments the promo code's `used_count` if one was applied, notifies manager + customer over Telegram, and optionally creates a Monobank invoice (see `_finalize` above). - Full error table: | Status | Body | |---|---| | 400 | `{"error": "cart_empty", "message": "Кошик порожній"}` | | 400 | `{"error": "closed", "message": ""}` | | 400 | `{"error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {N} грн. Додайте ще товарів у кошик."}` | | 400 | `{"error": "promo_invalid", "message": ""}` | | 400 | `{"error": "validation", "missing": [...]}` | | 400 | `{"error": "validation", "message": "invalid payment_method"}` | | 400 | `{"error": "validation", "message": "cash_change_for must be a number"}` | | 400 | `{"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}` | | 400 | `{"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}` | ### `POST /api/checkout/pickup` - Auth: required. - Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | min 2 chars after strip | | `phone` | string | yes | 9–13 digits after stripping non-digits | | `pickup_time` | string | yes | must match `^([01]?\d|2[0-3]):[0-5]\d$` (e.g. `"18:30"`, `"9:05"`) | | `payment_method` | string | yes | must be `"cash"` or `"card"` | | `promo_code` | string | no | | Example: ```json { "name": "Ігор", "phone": "0971234567", "pickup_time": "18:30", "payment_method": "card" } ``` - Validation order: `_validate_cart()` → required-field presence check (`["name", "phone", "pickup_time", "payment_method"]`, error shape `{"error": "validation", "missing": [...]}`) → `payment_method` in `("cash", "card")` → promo resolution → contact-field validation → `pickup_time` regex format check: ``` 400 { "error": "validation", "message": "Невірний формат часу (використайте ГГ:ХХ)" } ``` **Note:** unlike `/delivery`, the required-field and payment-method checks here run *before* promo resolution (the delivery route resolves the promo before its required-field check) — the order in which multiple simultaneous errors get reported can therefore differ slightly between the two routes. Also note there is no minimum-order-amount check on pickup (that setting, `min_delivery_order_amount`, is delivery-only per its name and the code). - Success — `201 Created`, the finalize response shape. `pickup_time` is stored as the raw stripped string, with no cross-check against actual working hours (e.g. nothing stops picking `"03:00"` when the cafe is closed then, beyond the general `orders_allowed()` gate at request time). - Side effects: same category as `/delivery` (order/items created, inventory deducted, cart cleared, promo use counted, notifications sent, optional Monobank invoice) but with no delivery fee. - Full error table: | Status | Body | |---|---| | 400 | `{"error": "cart_empty", "message": "Кошик порожній"}` | | 400 | `{"error": "closed", "message": ""}` | | 400 | `{"error": "validation", "missing": [...]}` | | 400 | `{"error": "validation", "message": "invalid payment_method"}` | | 400 | `{"error": "promo_invalid", "message": ""}` | | 400 | `{"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}` | | 400 | `{"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}` | | 400 | `{"error": "validation", "message": "Невірний формат часу (використайте ГГ:ХХ)"}` | ### `POST /api/checkout/dine-in` - Auth: required. - Request body (JSON): | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | min 2 chars after strip | | `phone` | string | yes | 9–13 digits after stripping non-digits | | `table_number` | string | no | blank/absent → `null` | | `payment_method` | string | yes | must be `"cash"` or `"card"` | | `promo_code` | string | no | | Example: ```json { "name": "Марія", "phone": "380501112233", "table_number": "7", "payment_method": "cash" } ``` - Validation order: `_validate_cart()` → required-field presence check (`["name", "phone", "payment_method"]`) → `payment_method` in `("cash", "card")` → promo resolution → contact-field validation. No pickup-time or address fields apply. - Success — `201 Created`, the finalize response shape. No delivery fee, no pickup discount (`discount_amount` is `0` for dine-in orders per `order_service.create_dine_in_order`). - Side effects: same as above (order created, inventory deducted, cart cleared, promo counted, notifications, optional Monobank invoice). - Full error table: | Status | Body | |---|---| | 400 | `{"error": "cart_empty", "message": "Кошик порожній"}` | | 400 | `{"error": "closed", "message": ""}` | | 400 | `{"error": "validation", "missing": [...]}` | | 400 | `{"error": "validation", "message": "invalid payment_method"}` | | 400 | `{"error": "promo_invalid", "message": ""}` | | 400 | `{"error": "validation", "message": "Вкажіть ім'я (мінімум 2 символи)"}` | | 400 | `{"error": "validation", "message": "Перевірте номер телефону — здається, він неповний"}` | ### `POST /api/checkout/promo/validate` Preview-validates a promo code against the caller's current cart, without placing an order. Used by the Mini App to show a live discount preview as the customer types a code. - Auth: required. - Request body: `{ "code": "" }` — `code` required (non-blank after strip). - Success — `200 OK`: ```json { "valid": true, "code": "WELCOME10", "discount_percent": 10, "discount_amount": 30 } ``` `code` is echoed back as stored on the `PromoCode` row (its canonical casing, not necessarily what the user typed — lookup is case-insensitive via `db.func.upper(...)`). `discount_amount` is computed via `calc_promo_discount(promo, items_total)` = `round(items_total * discount_percent / 100)`, or `0` if `items_total <= 0`. - Errors — note this endpoint returns `valid: false` with a **`400`** status (not `200`) for every rejection reason, all sharing the shape `{ "valid": false, "message": "" }`. Every `message` is translated per the caller's `TelegramUser.locale` (Ukrainian shown below for brevity): | Trigger | `message` | |---|---| | `code` blank/missing | `"Введіть промокод"` | | no matching code, or matched but `is_active = false` | `"Промокод не знайдено або він більше не діє"` | | `expires_at` in the past | `"Термін дії промокоду закінчився"` | | `used_count >= max_uses` | `"Ліміт використань цього промокоду вичерпано"` | | cart `items_total` below `min_order_amount` | `"Мінімальна сума замовлення для цього промокоду — {N} грн"` | | `first_order_only` and caller already has a non-cancelled prior order | `"Цей промокод діє лише на перше замовлення"` | **Note:** the checks run in the order listed (not-found/inactive → expired → usage-cap → min-amount → first-order-only), so if a code fails multiple checks at once, only the first one's message is returned. ### `GET /api/checkout/prefill` Returns the customer's most recent name/phone and latest delivery address, to pre-fill the checkout form for returning customers. - Auth: required. - Request body: none. - Success — `200 OK`. If the caller has never placed an order: ```json { "has_orders": false, "name": null, "phone": null, "delivery": null } ``` Otherwise: ```json { "has_orders": true, "name": "Олена", "phone": "+380971234567", "delivery": { "address_city": "Київ", "address_street": "вул. Хрещатик", "address_house": "10", "address_apartment": "5" } } ``` `name`/`phone` come from the single most recent order **of any type** (`ORDER BY created_at DESC LIMIT 1`, no `order_type` filter). `delivery` is populated from the most recent order specifically of `order_type == "delivery"` (a **separate** query) — it will be `null` if the customer has orders but none of them were deliveries, even if `has_orders` is `true`. - Errors: none. ### `GET /api/checkout/payment-status/` Polling endpoint the Mini App uses (its own "Перевірити оплату" / "Check payment" button) to find out if a card payment has cleared, as an alternative/backstop to the Monobank webhook. - Auth: required. - Path param: `order_id` (int). - Request body: none. - Success — `200 OK`, one of: - `{"paid": true}` — already marked paid, or the check with Monobank confirmed a paid status. - `{"paid": false}` — not paid, no Monobank invoice on file yet, or the Monobank status check failed/errored, or Monobank's returned status isn't in the "paid" set. - Side effects when payment is newly confirmed: calls `claim_order_paid(order)`, an atomic `UPDATE ... WHERE payment_status != 'paid'` used specifically to avoid double-processing when this poll races the webhook (`POST /api/payments/monobank/webhook`) or the periodic reconciliation job. Only the caller that actually wins the transition (i.e. `claim_order_paid` returns `True`) will: notify the staff chat (`💳 Замовлення {number} оплачено онлайн (✅).`) and trigger fiscalization (`checkbox_prro.fiscalize_in_background(order.id)`). It does **not** re-send the customer-facing "payment received" Telegram message that the webhook path sends (see `bober_bbq/api/payments.py`) — only the staff notification and fiscalization are shared between the two paths, per the code's own comment. - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "not_found"}` | no such order for this user | **Note:** if the Monobank status check itself throws `MonobankError` (e.g. Monobank API down), the response is `{"paid": false}` with a `200` — indistinguishable from a genuinely-still-unpaid order. Callers polling this endpoint can't tell "not paid yet" from "we couldn't check right now" from the response alone. ### `GET /api/checkout/track/` **Order tracking.** Added alongside guest ordering: a guest has no login and may lose their session cookie (private browsing, cleared site data), so every order also gets a `tracking_token` (see the finalize response shape above) as a second, independent way to look up its status — deliberately generated for Telegram orders too, not just guest ones, so the mechanism and the webapp code path are identical either way. - Auth: **none** — the only `checkout_bp` route without `@require_customer_auth`. The random, 16-byte-entropy `token` itself (`secrets.token_urlsafe(16)`, set once at order creation in `bot/services/order_service.py`, never reused) is the sole credential — the same trust model as a payment provider's "view your receipt" email link. There is no rate limiting on guesses. - Path param: `token` (string) — matched against `Order.tracking_token` exactly; **not** scoped to any particular `TelegramUser`, unlike every other route in this document. - Request body: none. - Success — `200 OK`, the same order object shape documented under the "Orders" section above (`_serialize_order`, reused directly from `bober_bbq/api/orders.py`). - Errors: | Status | Body | Trigger | |---|---|---| | 404 | `{"error": "not_found"}` | no `Order` with that `tracking_token` | The webapp surfaces this as a same-origin SPA deep-link rather than a server-rendered page: `GET /webapp/?track=` is handled entirely in `webapp/src/App.tsx` (checked **before** the normal menu/settings load), which calls this endpoint and renders the result via a dedicated `TrackOrderPage` component — a read-only view (no cancel/repeat/pay actions), since this route has no concept of "is this caller the order's owner", only "does the token match". --- ## Payments (`payments_bp`, prefix `/api/payments`) Source: `bober_bbq/api/payments.py`. This is a server-to-server webhook, not part of the Mini App's own API surface, but it lives under `/api/*` so it's documented here for completeness. ### `POST /api/payments/monobank/webhook` Monobank's server calls this when an invoice's status changes. - Auth: **not** `@require_telegram_auth`. Instead it verifies the **`X-Sign`** header as an ECDSA/SHA256 signature over the raw request body, using Monobank's own published public key (fetched from Monobank and cached in-process — `verify_webhook_signature()` in `bober_bbq/payments/monobank.py`). There is no Telegram user context for this route at all. - Request body (JSON, as sent by Monobank): consumed fields are `invoiceId` (string) and `status` (string); other fields Monobank sends are ignored. - Success responses: - `200 { "ok": true }` — for a genuinely-processed status change, **or** for an `invoiceId` that doesn't match any known order (logged as a warning server-side, but Monobank still gets acked so it doesn't retry indefinitely). - Behavior by `status`: - If `status` is in `PAID_STATUSES = {"success"}` and the order isn't already `paid`: calls `claim_order_paid(order)` (same atomic "first-caller-wins" transition used by `/api/checkout/payment-status`); if this call wins the race, sends the customer a "payment received" Telegram message, notifies the staff chat, and triggers `checkbox_prro.fiscalize_in_background(order.id)`. If `order.user_id` isn't a real Telegram chat (`<= 0` — a guest or the admin-panel walk-in sentinel, see Authentication above), the customer message is silently skipped (`send_message`'s `allow_negative` guard, `bober_bbq/utils/telegram_notify.py`) — only the staff notification and fiscalization still happen. - If `status` is in `FAILED_STATUSES = {"failure", "reversed", "expired"}` and the order isn't already paid: sends the customer a "payment failed, try again or pay on delivery/pickup" Telegram message (same guest/walk-in skip as above applies). No staff notification, no state change on the order recorded here beyond the message. - Any other `status` value: no action taken (still acked `200`). - Errors: | Status | Body | Trigger | |---|---|---| | 400 | `{"error": "invalid signature"}` | `X-Sign` header missing/doesn't verify against Monobank's public key | | 400 | `{"error": "missing invoiceId"}` | signature OK but body has no `invoiceId` | **Note:** this endpoint has no idempotency guard against replayed/duplicate valid webhooks beyond `claim_order_paid`'s own "already paid" short-circuit — a replayed *failure* webhook, however, would re-send the "payment failed" Telegram message to the customer every time, since that branch doesn't check the current `payment_status` against being already-failed (there's no `payment_status = "failed"` state — only `unpaid`/`paid` per `PAYMENT_STATUSES` — so this can't be fully deduped from database state alone). --- ## Me (`me_bp`, prefix `/api/me`) Source: `bober_bbq/api/me.py`. The customer's own preferences — currently just their interface language. Uses `@require_customer_auth` (see Authentication above), so this works for guests too, e.g. flipping the UA/EN switcher persists it to their guest `TelegramUser` row exactly like a real Telegram customer's. ### `PATCH /api/me/locale` Persists the customer's explicit language choice (made via the Mini App's UA/EN switcher) to `TelegramUser.locale`, so it also applies outside the Mini App session — e.g. the Telegram bot's own order-status push notifications, which have no other way to learn about an in-app-only preference. Called fire-and-forget by the webapp whenever the switcher changes (`webapp/src/i18n/index.tsx`'s `setLocale`); its own success/failure is never surfaced to the customer, since the choice already applies locally via `localStorage` regardless. - Auth: required. - Request body: `{ "locale": "" }`. - Success — `200 OK`: `{ "locale": "uk" }` — the **normalized** locale actually stored, not necessarily what was sent: any code not in `SUPPORTED_LOCALES` (`uk`/`en`) is silently normalized to the default (`uk`) rather than rejected (`normalize_locale()` in `bober_bbq/utils/i18n.py` — same never-crash convention used throughout the i18n layer). - Errors: none. --- ## Cross-cutting notes for integrators - **Currency**: all monetary values (`price`, `items_total`, `delivery_fee`, `total`, `discount_amount`, etc.) are **integers in whole UAH (hryvnia)**, not cents/kopecks. The one exception is inside `bober_bbq/payments/monobank.py` where amounts are multiplied by 100 when talking to the Monobank API — that conversion happens server-side and is invisible to `/api/*` consumers. - **No pagination** anywhere except `GET /api/orders`'s fixed limit of 50. - **No API versioning** — all routes are directly under `/api/`, no `/v1/`. - **No rate limiting** was found on any of these routes (the sibling admin panel's `/admin/login` has rate limiting per recent commit history, but that's outside `bober_bbq/api/`). - **No 401s in `cart_bp`/`orders_bp`/`checkout_bp`/`me_bp`**: every route in those four blueprints uses `@require_customer_auth` (see Authentication), which falls back to a cookie-bound guest instead of rejecting the request — so `{"error": "invalid or missing Telegram init data"}`, `401` is now only a theoretical body (still returned by the unused `require_telegram_auth`), not something any documented route above actually sends. There is also no route-specific 403 (e.g. "not your order") anywhere — those cases are folded into a plain `404` instead, so from the client's perspective "doesn't exist" and "exists but isn't yours" are indistinguishable. - **Malformed JSON bodies** are generally *not* a hard error: every POST/PATCH handler in `cart.py` and `checkout.py` uses `request.get_json(silent=True) or {}`, so a missing/invalid JSON body is treated as an empty object and falls through to the normal missing-required-field validation rather than raising a JSON-parse error.