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

856 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<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):
```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/<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:
```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.<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`:
```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/<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 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=<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:
```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": ["<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|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": "<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:
```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": "<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`:
```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": "<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:
```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/<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.