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>
856 lines
48 KiB
Markdown
856 lines
48 KiB
Markdown
# 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 | 9–13 digits after stripping non-digits |
|
||
| `address_city` | string | yes | |
|
||
| `address_street` | string | yes | |
|
||
| `address_house` | string | yes | |
|
||
| `address_apartment` | string | no | blank/absent → `null` |
|
||
| `address_comment` | string | no | blank/absent → `null` |
|
||
| `payment_method` | string | yes | must be `"cash"` or `"card"` |
|
||
| `cash_change_for` | int | no | only meaningful when `payment_method == "cash"`; must be numeric if provided and non-blank |
|
||
| `promo_code` | string | no | see promo resolution above |
|
||
|
||
Example:
|
||
```json
|
||
{
|
||
"name": "Олена",
|
||
"phone": "+380971234567",
|
||
"address_city": "Київ",
|
||
"address_street": "вул. Хрещатик",
|
||
"address_house": "10",
|
||
"address_apartment": "5",
|
||
"payment_method": "cash",
|
||
"cash_change_for": 500
|
||
}
|
||
```
|
||
- Order-specific validation, in this exact sequence:
|
||
1. `_validate_cart()` (cart-empty / closed checks, see above).
|
||
2. Minimum order amount: if `Setting.get_int("min_delivery_order_amount") > 0` and the cart's `items_total` is below it:
|
||
```
|
||
400 { "error": "min_order_amount", "message": "Мінімальна сума замовлення на доставку — {min_amount} грн. Додайте ще товарів у кошик." }
|
||
```
|
||
3. Promo resolution (`_resolve_promo`).
|
||
4. Required-field presence check over `["name", "phone", "address_city", "address_street", "address_house", "payment_method"]` — any missing/blank (after `str(...).strip()`) field:
|
||
```
|
||
400 { "error": "validation", "missing": ["<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 | 9–13 digits after stripping non-digits |
|
||
| `pickup_time` | string | yes | must match `^([01]?\d|2[0-3]):[0-5]\d$` (e.g. `"18:30"`, `"9:05"`) |
|
||
| `payment_method` | string | yes | must be `"cash"` or `"card"` |
|
||
| `promo_code` | string | no | |
|
||
|
||
Example:
|
||
```json
|
||
{ "name": "Ігор", "phone": "0971234567", "pickup_time": "18:30", "payment_method": "card" }
|
||
```
|
||
- Validation order: `_validate_cart()` → required-field presence check (`["name", "phone", "pickup_time", "payment_method"]`, error shape `{"error": "validation", "missing": [...]}`) → `payment_method` in `("cash", "card")` → promo resolution → contact-field validation → `pickup_time` regex format check:
|
||
```
|
||
400 { "error": "validation", "message": "Невірний формат часу (використайте ГГ:ХХ)" }
|
||
```
|
||
**Note:** unlike `/delivery`, the required-field and payment-method checks here run *before* promo resolution (the delivery route resolves the promo before its required-field check) — the order in which multiple simultaneous errors get reported can therefore differ slightly between the two routes. Also note there is no minimum-order-amount check on pickup (that setting, `min_delivery_order_amount`, is delivery-only per its name and the code).
|
||
- Success — `201 Created`, the finalize response shape. `pickup_time` is stored as the raw stripped string, with no cross-check against actual working hours (e.g. nothing stops picking `"03:00"` when the cafe is closed then, beyond the general `orders_allowed()` gate at request time).
|
||
- Side effects: same category as `/delivery` (order/items created, inventory deducted, cart cleared, promo use counted, notifications sent, optional Monobank invoice) but with no delivery fee.
|
||
- Full error table:
|
||
| Status | Body |
|
||
|---|---|
|
||
| 400 | `{"error": "cart_empty", "message": "Кошик порожній"}` |
|
||
| 400 | `{"error": "closed", "message": "<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 | 9–13 digits after stripping non-digits |
|
||
| `table_number` | string | no | blank/absent → `null` |
|
||
| `payment_method` | string | yes | must be `"cash"` or `"card"` |
|
||
| `promo_code` | string | no | |
|
||
|
||
Example:
|
||
```json
|
||
{ "name": "Марія", "phone": "380501112233", "table_number": "7", "payment_method": "cash" }
|
||
```
|
||
- Validation order: `_validate_cart()` → required-field presence check (`["name", "phone", "payment_method"]`) → `payment_method` in `("cash", "card")` → promo resolution → contact-field validation. No pickup-time or address fields apply.
|
||
- Success — `201 Created`, the finalize response shape. No delivery fee, no pickup discount (`discount_amount` is `0` for dine-in orders per `order_service.create_dine_in_order`).
|
||
- Side effects: same as above (order created, inventory deducted, cart cleared, promo counted, notifications, optional Monobank invoice).
|
||
- Full error table:
|
||
| Status | Body |
|
||
|---|---|
|
||
| 400 | `{"error": "cart_empty", "message": "Кошик порожній"}` |
|
||
| 400 | `{"error": "closed", "message": "<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.
|
||
|