bober-bbq-bot/docs/API.md
byrsapty ac3716712b Update API docs for guest ordering (require_customer_auth, /checkout/track)
docs/API.md and docs/openapi.yaml still described every cart/orders/
checkout/me route as requiring @require_telegram_auth with a possible 401
- no longer accurate now that those routes use @require_customer_auth
(falls back to a cookie-bound guest instead of rejecting the request, see
the guest-ordering feature). Documents the new decorator, the guest
identity scheme (GuestIdSequence/allocate_guest_telegram_id), the new
unauthenticated GET /checkout/track/<token> endpoint, and the checkout
response's new card_unavailable_message/track_url fields. Also adds the
previously-undocumented /me/locale path to openapi.yaml and updates
README's architecture notes accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:47:16 +03:00

48 KiB
Raw Blame History

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):
    • 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=<int> query parameter. If present, it fabricates {"user": {"id": <int>, "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=<that>, 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):
    [
      { "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:
    [
      {
        "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:
    {
      "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:0002: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_usesignores 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():

{
  "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:

    { "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/<int:product_id>

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/<int:product_id>

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:

{
  "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.<status>", 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/<int:order_id>/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/<int:order_id>/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:

    { "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/<int:order_id>/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/<token> require @require_customer_auth (see Authentication above — never returns 401; GET /track/<token> 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)>" }
    
    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": "<one of the two messages above>" }

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": "<see validate_promo_code error messages, listed under /checkout/promo/validate>" }

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 requestpay_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:

{
  "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=<token>), 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 913 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:

    {
      "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": ["<field1>", "<field2>", ...] }
      
      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": "<closed_message()>"}
    400 {"error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {N} грн. Додайте ще товарів у кошик."}
    400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}
    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 913 digits after stripping non-digits
    pickup_time string yes must match `^([01]?\d
    payment_method string yes must be "cash" or "card"
    promo_code string no

    Example:

    { "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": "<closed_message()>"}
    400 {"error": "validation", "missing": [...]}
    400 {"error": "validation", "message": "invalid payment_method"}
    400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}
    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 913 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:

    { "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": "<closed_message()>"}
    400 {"error": "validation", "missing": [...]}
    400 {"error": "validation", "message": "invalid payment_method"}
    400 {"error": "promo_invalid", "message": "<see /checkout/promo/validate>"}
    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": "<string>" }code required (non-blank after strip).

  • Success — 200 OK:

    { "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": "<reason>" }. 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:
    { "has_orders": false, "name": null, "phone": null, "delivery": null }
    
    Otherwise:
    {
      "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/<int:order_id>

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/<token>

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=<token> 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": "<string>" }.
  • 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.