Commit graph

148 commits

Author SHA1 Message Date
5501fe3094 Tech cards: remove photo without replacement, header spacing, order link
- New "Видалити фото (без заміни)" checkbox on the tech card form —
  previously the only way to clear a plating photo was uploading a
  replacement.
- Fixed missing top margin between the "← До тех карт" back-link and the
  product header on the tech card form.
- Order detail page now links each item to its tech card (when the viewer
  has tech-card access) so kitchen staff can jump straight to prep
  instructions while working an order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:19:32 +03:00
3697b4eb89 Add tech cards (kitchen prep instructions + plating photo) section
New admin-only "Тех карти" menu section: one tech card per product with
cooking instructions, cook time, and a plating photo for kitchen staff —
separate from the customer-facing menu (never exposed via the webapp API).

Access follows the same admin/manager-toggle pattern already used for
Suppliers: admins always have full access, managers only if an admin
explicitly flips on "tech_cards_manager_access" in Settings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:04:52 +03:00
47db28ccb0 Fix DB restore 500 on old backups, add photo archive restore
Restoring a DB backup taken before a schema change (e.g. an older cloud
backup) left the live app crashing with "no such column" on every request
touching the new column, because restore only copied the file over without
re-running the app's own db.create_all()/migrate() startup sequence. Restore
now re-applies both immediately after the copy, exactly like a fresh deploy
would.

Also fills the other half of the disaster-recovery story the user flagged:
until now only the DB could be restored, never the product photos zipped
alongside it. Adds a local "photo archives" card plus a new
restore-uploads route (same confirm-word + auto-snapshot safety as the DB
restore), and broadens the cloud listing to show both DB and photo backups
so both can be pulled down and restored after redeploying on a new server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 04:25:35 +03:00
73d4add7ac Add one-click cloud backup restore for disaster recovery
Lets an admin browse the cloud-backed remote (?show_remote=1) and
download any listed backup file back into the local backups/ folder
with one click, reusing the existing local restore/confirm/auto-snapshot
flow instead of duplicating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:40:49 +03:00
9a30d84bf4 Fix estimate-badge checkbox alignment
The two checkboxes were placed as direct <label> children of a .row,
but .row's flex-sizing rule (.row > div { flex: 1 }) only targets div
children — so the pair didn't line up the way every other .row-based
field pair in this form does. Wrapped each in its own div to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:32:40 +03:00
71210b6bdb Add independent on/off toggles for the "≈" estimate badge
Previously the badge always showed next to the price for any per_100g
product and never next to the weight at all. Adds two per-product
checkboxes (show_estimate_badge_price, default on; show_estimate_badge_weight,
default off) so the admin can turn either one on or off independently —
covers a shashlik-style product where the weight itself is also just an
estimate, not only the price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:28:05 +03:00
6a0579f24d Allow manually overriding the estimated price for per_100g products
Previously the customer-facing estimate for a weight-priced product was
always computed from price-per-100g * reference_weight_g / 100, with no
way to just type a round number directly. Adds Product.estimated_price_override
(nullable) — effective_unit_price prefers it when set, falling back to the
computed estimate otherwise. The admin form's price field auto-fills from
the price/weight inputs until the admin types their own value, at which
point it's protected from being silently recalculated (same "touched"
pattern used for the order-line price override). Weight stays its own
always-editable field regardless of which pricing path is used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 02:59:44 +03:00
d27cbb9bf0 Fix "directory not found" on first-ever connection test
test_connection() used `lsjson` on the target path, which fails if the
folder hasn't been created yet — true for any brand-new destination, since
rclone only creates it lazily on an actual `copy`. Switched to `mkdir`
(idempotent, no-op if it already exists), which also verifies write
access rather than just read access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:33:05 +03:00
e773b19508 OAuth Google Drive: use a plain folder path instead of a folder ID
A real OAuth-authorized account has its own "My Drive" with actual
storage, so unlike the service-account path (which has none of its own
and can only write into an explicitly shared-by-ID folder), there's no
need to hunt down a numeric folder ID — a plain path segment works and
rclone creates it automatically on first upload if it doesn't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:26:38 +03:00
0cea565fd5 Move the shared Drive folder-id field above both auth methods
It only appeared inside the service-account block, so it wasn't visible
when scrolled to the OAuth section — moved to the top of the Google Drive
group since the same field applies to whichever auth method is filled in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:16:49 +03:00
601411451a Add OAuth auth option for the Google Drive backup remote
The admin form only supported rclone's service-account auth for Google
Drive. rclone's drive backend also supports OAuth (client_id/client_secret/
scope/token, as produced by running `rclone config`/`rclone authorize` on
your own machine) — added as a second, independent set of fields so
someone who already has OAuth credentials doesn't need to set up a service
account instead. root_folder_id is required for the service-account path
(a service account has no Drive storage of its own) but optional for
OAuth (a real account's own My Drive is fully accessible without one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 23:10:59 +03:00
375022e739 Add backup_remote.py and wire up the rest of the rclone rework
Continuation of the previous commit (gdrive_backup.py removal landed
separately by accident) — adds the new module itself, the admin
routes/template using it, generalized Setting keys, updated docs/
provisioning notes, and tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 22:57:59 +03:00
f53a142cf4 Rework backups to use rclone, configurable entirely in the admin panel
The backup system was hard-wired to Google Drive via googleapiclient and a
service account — replaced with rclone (invoked as an external binary via
subprocess, credentials passed as RCLONE_CONFIG_BACKUP_* env vars, never
written to a config file on disk) so the destination isn't locked to one
provider. The admin panel now has a storage-type picker (Google Drive / S3-
compatible / custom) with real form fields for each — no rclone.conf to
paste in, matching the same non-technical-friendly spirit as the previous
Google-only settings form, just generalized.

bober_bbq/utils/gdrive_backup.py -> bober_bbq/utils/backup_remote.py;
Setting keys generalized from gdrive_* to backup_*/backup_remote_* (clean
replacement, no migration needed — confirmed via production that Google
Drive backup was never enabled there). Local backup create/list/download/
delete/restore is untouched. Removes the now-unused google-api-python-
client/google-auth/google-auth-httplib2 dependencies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 22:57:23 +03:00
699a73c8a0 Add per-100g product pricing and editable order line prices
Some items (grilled shashlik) can't have a flat price since the cooked
weight varies. Product gains pricing_mode ("fixed"/"per_100g") and
reference_weight_g; Product.effective_unit_price computes an estimated
per-unit charge (price-per-100g * reference_weight_g / 100) that every
cart/order call site now reads instead of raw Product.price, so the
customer sees a ready "≈" estimate everywhere (menu, cart, checkout) with
zero changes to the actual cart/checkout math.

Also adds a general per-line price override to the admin order create/edit
forms (unit_price), so staff can correct the charged amount after weighing
an item — for any product, not just weight-priced ones. This incidentally
fixes an existing bug where editing an order for an unrelated reason (e.g.
a phone number) silently re-snapshotted every line item to today's catalog
price instead of preserving what was actually charged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 20:33:04 +03:00
87870d6e4b docs/TODO.md: add packaging notes (Docker Compose over .deb)
Records the discussion on distributing this as an installable package for
new clients: .deb was considered and rejected (per-client interactive
config — domain, bot token, TLS, a frontend build step — fights debconf/
postinst rather than fitting it), Docker Compose picked as the direction
if this gets built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:59:15 +03:00
848d6f0d75 Add docs/TODO.md: future CRM-enhancement ideas
Notes down what's discussed but not requested yet — per-customer notes,
customer tags/segments, segmented broadcast, finer admin roles, non-
Telegram broadcast channels, lead funnel, deeper analytics — so the idea
isn't lost, without committing to build any of it now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:54:07 +03:00
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
ac3e34b173 Fix "From Telegram" phone button showing for guests without Telegram
index.html loads Telegram's telegram-web-app.js SDK unconditionally, and
that script always defines window.Telegram.WebApp — requestContact
included — as a harmless stub even in a plain desktop browser with no
real Telegram client. canRequestContact only checked "does the method
exist", which was true for everyone, so a guest ordering without Telegram
(see the guest-ordering feature) saw a "From Telegram" button that could
never do anything for them.

Adds isInsideTelegram(), which checks initData is actually a non-empty
signed string (only ever true inside a real Telegram client), and gates
both the manual button and requestContactPhone()'s auto-prompt on it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:18:04 +03:00
5cfcf71154 Fix desktop layout and cart badge position; redirect / to the webapp
The Mini App was designed only ever to render inside Telegram's phone-width
WebView, so it stretched edge-to-edge with no width cap — fine there, but
now that guests without Telegram open it directly in an ordinary desktop
browser (see the guest-ordering feature) it looked broken on wide screens.
Caps .app/.bottom-nav at 560px, centered, matching the width already used
for the product detail modal's sheet.

That same unbounded width was also the root cause of the cart badge
floating away from its icon on wide screens: it was positioned at
`right: 18%` of the whole nav button, which drifts far from the icon once
the button itself grows wide — fixed by anchoring the badge to the icon's
own wrapper instead of a percentage of the button.

Also makes the bare domain (webapp.boberbbq.com) redirect straight into
/webapp/ instead of showing a JSON route dump — guests are now expected to
land there directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:32:54 +03:00
7fe8ce9030 Add guest ordering for customers without Telegram
The Mini App required a valid Telegram WebApp signature for every
cart/checkout/order call, so anyone without Telegram couldn't order at all.
require_customer_auth now falls back to a cookie-bound guest identity
(a fresh negative telegram_id per guest, see allocate_guest_telegram_id)
instead of rejecting the request, reusing the existing webapp bundle with
no new frontend needed. Orders also get a random tracking_token so a guest
who loses their session cookie can still check status via a /track/<token>
link. Staff/admin surfaces (notifications, customer stats, broadcast) are
updated to treat guests the same as the existing walk-in sentinel user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 20:22:31 +03:00
3ecdefa6c8 Lock background scroll under the product detail modal; ignore swipes on its backdrop
- Body scroll lock (position:fixed + restore exact scrollY on close) while
  the modal is open — previously a swipe starting on the backdrop scrolled
  the Menu page underneath, and iOS rubber-band overscroll bled through.
- Backdrop dismissal now requires a genuine tap: down/up positions must be
  within 10px of each other, and both must land directly on the backdrop
  (not bubbled from the sheet). A swipe/drag over the backdrop no longer
  closes the modal.
2026-08-27 17:42:04 +03:00
0e09344245 Fix admin product form layout; add long-press product detail modal
- product_form.html: weight/weight_en/price were squeezed into one
  3-column flex row after weight_en was added, overflowing on the form's
  520px width. Split into weight+weight_en (2 columns) and price (own row).

- Mini App: long-press (450ms hold) on a product's thumbnail opens a
  bottom-sheet modal with a large photo, full description, weight,
  allergens, and its own quantity/add-to-cart controls — for browsing a
  dish in more detail than the compact card allows. A quick tap still does
  nothing (matches existing behavior), and movement beyond 10px during the
  hold (a scroll starting on the thumbnail) cancels it so scrolling past a
  product never accidentally opens its modal. A small 🔍 badge on every
  thumbnail hints the interaction exists; it grows and the thumbnail dims
  slightly while holding, both animated over the same 450ms as the hold
  itself for tactile feedback.
2026-08-27 17:32:29 +03:00
96737e4f3f Add optional English translation for product weight/volume
Product.weight_en (e.g. "300 г" -> "300 g"), same opt-in-per-item fallback
as name_en/description_en. Editable from the product admin form.
2026-08-27 17:14:36 +03:00
a1bda220df Add optional English translation for the cafe's address
Setting["address_en"] (Адмінка → Налаштування → Заклад), opt-in fallback
like Product.name_en — used in place of "address" for English-locale
customers in /api/settings (Mini App contacts + closed screen), the bot's
/contacts command, and the pickup-ready notification's address line.
Untranslated (blank) address_en falls back to the Ukrainian address as before.
2026-08-27 16:52:27 +03:00
ff232d1a0f Bilingual menu content + finish EN coverage across bot and Mini App
Two gaps in the earlier i18n scaffolding: the Mini App's UA/EN switcher
existed but most screens and all menu content stayed hardcoded Ukrainian.

Menu content (business data, not UI chrome):
- Category.name_en, Product.name_en/description_en (nullable, opt-in per
  item — untranslated items fall back to Ukrainian rather than showing
  blank). Editable from the existing category/product admin forms.
- Allergen labels (a small fixed EU-mandated set) get an English label
  directly in ALLERGENS, no DB column needed.
- /api/categories and /api/products take an optional ?locale= param (no
  session on these unauthenticated routes to read a stored preference from).

Full EN coverage for everything else:
- Every remaining webapp screen: MenuPage/ProductCard, CartItemRow (fixes a
  pre-existing gap in the "already translated" cart screen), OrdersPage,
  ContactsPage, ClosedScreen, BottomNav, AnnouncementPopup, plus app-level
  status/error text.
- Every reachable bot message: /menu, /contacts (including its
  locale-dependent reply-keyboard button, matched by text across all
  locales since the label itself varies), and the rating thank-you flow.
- Every customer-facing checkout/order error message (empty cart, closed,
  min order amount, payment/promo validation, cancel/pay errors) via
  locale params threaded through workhours.closed_message() and
  promo.validate_promo_code().

New: PATCH /api/me/locale persists an explicit in-app language choice back
to TelegramUser.locale (fire-and-forget from the webapp switcher), so it
also applies to the bot's own push notifications, not just the Mini App
session that made the choice.

Fixed a real bug caught while testing: product_form.html's allergen
checklist unpacked ALLERGENS values as 2-tuples, which broke (ValueError)
once they became 3-tuples (uk_label, emoji, en_label) — admin's "add
product" page 500'd until this was caught in browser testing.
2026-08-27 16:38:10 +03:00
25988b3ef1 Make the collapsible sidebar itself an optional setting
The collapsible nav groups added earlier were always-on for every admin.
Added Налаштування → Вигляд → "Дозволити згортати розділи бічного меню"
(default on) — when turned off, groups render exactly as before this
feature existed: plain non-interactive labels, everything always visible.
2026-08-27 15:47:58 +03:00
7643f43a43 Animate sidebar group collapse/expand smoothly
max-height transition (0.2s) instead of an instant display:none swap.
Respects prefers-reduced-motion.
2026-08-27 15:38:57 +03:00
2e1c7a8819 Make sidebar nav groups optionally collapsible
Each group (Замовлення, Меню, Аналітика, Система, ...) is now a clickable
header that collapses/expands its links, remembered per-browser via
localStorage — nothing forced, nothing synced across staff. A group
containing the currently active page always shows expanded on load even
if it was previously collapsed, so navigating there never hides the
active link.
2026-08-27 15:29:10 +03:00
391da771f7 Document Postgres backup gap as a TODO before any real migration 2026-08-27 15:19:10 +03:00
40758c5191 Fix test_payments.py for the payment-provider abstraction merge
checkout.py/reconciliation.py no longer import PAID_STATUSES/
get_invoice_status directly since the provider-abstraction merge — they
go through whichever module registry.get_order_provider() returns.
Patch monobank.get_invoice_status directly instead.
2026-08-27 13:43:01 +03:00
52eec01477 Merge pluggable payment-provider layer; add LiqPay alongside Monobank 2026-08-27 13:41:27 +03:00
27f1d5f149 Add pluggable payment-provider layer; implement LiqPay alongside Monobank
Introduces bober_bbq/payments/base.py as the shared provider contract
(ProviderNotConfigured/ProviderError, the atomic claim_order_paid race
guard, and a documented function-shape convention) and
bober_bbq/payments/registry.py for provider selection/dispatch, so
checkout.py, orders.py, and reconciliation.py no longer hardcode
Monobank. monobank.py's public names/behavior are unchanged -- its
exceptions now subclass the generic ones and claim_order_paid is
re-exported from base.py, but every existing import keeps working.

Adds liqpay.py (Checkout/CNB API: base64(JSON)+sha1 signing, form-encoded
webhook, status polling, a local checkout-redirect bridge page since
LiqPay has no server-side "create invoice" call) and
api/payments_liqpay.py for its webhook + redirect routes. Wires a
"payment_provider" setting (admin-configurable, env fallback) and adds
matching LiqPay admin settings fields/test button.

No live LiqPay credentials were available to test against; verification
is a re-derived payment-race regression test (webhook/poll/reconciliation
racing to mark an order paid, for both providers) plus mocked-HTTP
structural checks. Uncertain LiqPay details (verify_token's error-code
heuristic) are flagged in liqpay.py and docs/PAYMENTS.md, which also
documents adding a third provider (Fondy, not implemented).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:38:24 +03:00
399e6a48e9 Merge i18n scaffolding: bilingual bot notifications + cart/checkout 2026-08-27 13:37:00 +03:00
3e8314925c Add i18n scaffolding: bot/notification locale support + bilingual cart/checkout
Backend: dependency-free t(key, locale) helper (bober_bbq/utils/i18n.py) in
the spirit of Setting.get(), a new TelegramUser.locale column (migrated via
the existing _add_column_if_missing pattern) populated from Telegram's
language_code on first contact, and order-status notification messages
(notify_customer.py) plus the bot's /start welcome translated to en as proof.

Webapp: a ~30-line I18nProvider/useI18n context (webapp/src/i18n/) with a
per-locale translations dict, auto-detecting locale from a saved choice,
Telegram's language_code, or the browser, with a manual UA/EN switcher in
the header. CartPage and CheckoutPage are fully bilingual end-to-end as the
representative slice; the rest of the app is unchanged Ukrainian-only.

Documented in docs/I18N.md: how to add strings/languages, and the explicit
boundary of what's translated today vs. left for later (admin panel is
intentionally untouched).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:35:47 +03:00
11a155bca5 Merge repo-committed pytest test suite 2026-08-27 13:33:34 +03:00
8bba2873b5 Add repo-committed pytest test suite
Converts the ad-hoc scratch-script verification pattern used throughout
this session's development (temp SQLite DB + create_app() + monkeypatched
send_message/fiscalize_in_background/get_invoice_status, print-based
check() assertions) into a real, repo-committed pytest suite under tests/.

Covers: claim_order_paid() atomicity, cross-path payment race dedup
(webhook/checkout-poll/reconciliation all observing the same paid
invoice), fiscalization error alerting + cooldown, admin login
brute-force lockout, the insecure-defaults startup check, supplier-import
path-traversal protection, basic checkout smoke, and the supplier
restock -> stock/supplier-link bug fixed earlier this session.

Adds requirements-dev.txt (pytest only, keeping the existing
monkeypatch-only style rather than a new mocking framework), pytest.ini
so `pytest` runs from the repo root with no flags, and docs/TESTING.md
covering how to run it and what's explicitly not covered yet (the React
webapp, bot conversation flows beyond the checkout API, live
Checkbox/Monobank/Google Drive integrations).

38 tests, all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:32:09 +03:00
fb97a3c1e0 Merge opt-in PostgreSQL support 2026-08-27 13:31:51 +03:00
1ede4b9692 Add opt-in PostgreSQL support alongside default SQLite
SQLite stays the default (fine for a small single-cafe deployment); this
makes Postgres a supported alternative via DATABASE_URL for clients who
outgrow SQLite's single-writer lock (multiple concurrent admin users,
high write volume).

- requirements.txt: add psycopg2-binary (Postgres driver for SQLAlchemy).
- bober_bbq/migrate.py: the admin_users.created_at/last_login_at ALTER
  TABLE DDL used the type name DATETIME, which SQLite accepts (it only
  has type affinity) but Postgres doesn't recognize as a real type — use
  TIMESTAMP instead, valid on both.
- bober_bbq/admin/inventory.py: the ingredient-name duplicate check used
  db.func.lower() in SQL, which is ASCII-only on SQLite and silently
  fails to match Cyrillic names (same bug already fixed this session in
  admin/suppliers.py's supplier-name dedup) — compare in Python instead.
  Not a Postgres crash risk (Postgres's LOWER() is Unicode-aware), but
  worth fixing for consistency since moving to Postgres would otherwise
  silently change this check's behavior.
- bober_bbq/utils/gdrive_backup.py and admin/system.py were audited for
  SQLite-specific behavior (PRAGMA integrity_check, direct file
  copy/restore) but already guard every such path behind
  `uri.startswith("sqlite:///")` / `_sqlite_path()` checks that
  gracefully no-op with a clear message on Postgres — no changes needed.
- scripts/migrate_sqlite_to_postgres.py (new): standalone, manually-run,
  one-off tool for actually moving a specific client's data off SQLite.
  Reuses the app's own SQLAlchemy models (db.metadata) rather than raw
  table introspection, copies rows in FK-safe order, refuses to run
  against a non-empty target unless --truncate is passed, and resets
  Postgres auto-increment sequences after copying explicit ids. Not
  wired into run_web.py or any automatic startup path.
- docs/DEPLOYMENT.md: new section on when/how to use Postgres instead of
  SQLite, standing up a Postgres instance, the DATABASE_URL format, and
  when/how to run the migration script.

Verified: create_app() + db.create_all() + migrate() + seed() (including
the ALTER-TABLE-ADD-COLUMN code path exercised against a DB missing
those columns) still work cleanly against a fresh temp SQLite DB. The
migration script's row-copy/FK-ordering/truncate/non-empty-guard logic
was verified end-to-end using SQLite as a stand-in target, since no
Postgres server is available in this environment — the Postgres-specific
pieces (actual connection, pg_get_serial_sequence/setval) are
unverified beyond code review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:31:12 +03:00
fddde6e0a5 Merge non-technical owner's guide 2026-08-27 13:28:54 +03:00
a3c907a1ec Merge API reference docs (Markdown + OpenAPI) 2026-08-27 13:28:54 +03:00
dd5688ab9f Add API reference docs for /api/* (Markdown + OpenAPI)
Documents every route in bober_bbq/api/* (menu, settings, cart, orders,
checkout, payments) sourced by reading each handler and its downstream
model/service/util calls in full: auth via require_telegram_auth and the
Telegram initData HMAC validation (including the debug-only dev_user_id
bypass), exact request/response shapes, and every distinct error status
and body. Adds docs/API.md as the primary narrative reference and
docs/openapi.yaml as a machine-readable OpenAPI 3.0 spec covering the
same surface.
2026-08-27 13:27:43 +03:00
78df456653 docs: додати нетехнічний посібник власника для адмін-панелі
Покроковий посібник простою мовою для власника кафе/персоналу без
технічного бекграунду: замовлення, меню, промокоди, відгуки, склад,
постачальники, налаштування, персонал, резервні копії — за реальними
кнопками й пунктами меню з коду bober_bbq/admin/*.py та шаблонів.
2026-08-27 13:27:35 +03:00
b636d0584a Rate-limit /admin/login and alert on insecure default credentials
No brute-force protection existed on the admin login form. Added an
in-memory per-IP lockout (5 failed attempts / 15 min -> 15 min lockout),
alerting the owner via Telegram when a lockout triggers.

Also warn loudly at startup (log + owner Telegram alert, not a hard
refusal) if SECRET_KEY or the admin password are still the config.py
defaults — previously a skipped .env line would silently run production
with session-forgeable SECRET_KEY or an admin/admin login.
2026-08-27 13:07:09 +03:00
759a590022 Alert owner on fiscalization failure; close payment-status race condition
Fiscal errors previously only wrote fiscal_status/fiscal_error and logged,
so an order could sit fiscally unissued indefinitely unless someone opened
its detail page — now routed through the existing error_alerts.notify().

Three independent code paths (Monobank webhook, the Mini App's own
"Перевірити оплату" poll, and the periodic reconciliation job) could all
observe the same "just paid" transition via a read-then-write check and
double up notifications/fiscalization. Added claim_order_paid() as a single
atomic UPDATE ... WHERE payment_status != 'paid', with side effects gated
on whichever caller actually won the transition.
2026-08-27 12:49:17 +03:00
f5e46ef648 Fix two audit findings: fiscalization gap in checkout polling, path traversal in supplier import
1. GET /api/checkout/payment-status/<id> (the Mini App's "Перевірити
   оплату" poll, used before the Monobank webhook arrives) marked an
   order paid but skipped everything the webhook and reconciliation
   paths do afterward — no fiscal receipt, no staff notification.
   Confirmed by cross-checking every other payment_status="paid" call
   site in the codebase (api/payments.py, admin/routes.py x3,
   utils/reconciliation.py) — all of them call fiscalize_in_background;
   this was the only one that didn't. Added the missing staff-chat
   notification and fiscalization call, matching the webhook's pattern.
   (Skipped the customer Telegram message the webhook also sends — the
   customer is already looking at the Mini App screen that just told
   them "paid", a duplicate ping there would be noise, not signal.)

2. bober_bbq/utils/supplier_import.py's discard_upload/load_preview/
   parse_rows all built a path as `TMP_DIR / token`, where `token` is a
   client-submitted hidden form field round-tripped across the
   preview/confirm steps — not re-validated as one of save_upload()'s
   own uuid4-named files. Path("/some/dir") / "/etc/passwd" evaluates
   to "/etc/passwd" (an absolute right-hand side silently discards the
   left), so a crafted token let any authenticated suppliers-access
   user delete (via discard_upload's error-path cleanup) or, if the
   extension happened to match, read the contents of an arbitrary file
   on the server. Added _resolve_upload_path() — validates the token
   against the exact <uuid4 hex><supported extension> shape and that
   the resolved path's parent is still TMP_DIR — used by all three
   functions instead of the raw join.

Verified with two smoke tests: 22 checks confirming 6 different attack
payloads (absolute paths both slash styles, relative traversal both
separator styles, a malformed-but-real-extension token, an empty
token) are all rejected without touching a planted victim file, while
a real save_upload()/discard_upload() round trip still works exactly
as before; and 8 checks confirming the checkout endpoint now fiscalizes
and notifies staff on the first paid poll, and does neither again on a
second poll (no double-fiscalization/double-notification). Plus a full
regression rerun of the session's other smoke suites.
2026-08-27 00:33:23 +03:00
dcf5a9205a Fix TypeError crashing restock-from-ingredient-page on production
Missed this call site when _apply_stock_topup's signature dropped its
unused supplier_name parameter (previous commit) — my grep search was
scoped to suppliers.py only and missed inventory.py's own call in
inventory_adjust's "restock" path. This 500'd in production every time
someone tried to restock directly from an ingredient's own page: the
SupplierDelivery record got created and committed, but the crash
happened before the stock top-up / ingredient-supplier auto-link ever
ran, leaving orphaned delivery rows with no matching stock movement.

Added a smoke test exercising this exact route (POST .../adjust with
reason=restock) so a future signature change here gets caught locally
instead of in production.
2026-08-26 23:57:01 +03:00
d6f6efb270 Stop stuffing supplier name into stock-movement notes; break out cash/bank totals in statements
Two follow-ups from real usage:

1. Inventory reports were showing "Поставка від {supplier}" in their
   Примітка column because _apply_stock_topup always wrote that literal
   string as the StockMovement note, regardless of what the admin
   actually typed. Now it passes through the delivery's own note (or
   nothing) — the supplier is identified properly via a new
   "Постачальник" column in both inventory movement reports (all-
   ingredients and per-ingredient, Excel + PDF), sourced from the
   ingredient's own supplier link rather than free text. Also dropped
   the now-unused supplier_name parameter from _apply_stock_topup.

2. A supplier is routinely paid via a mix of cash and bank transfer
   across different deliveries — each delivery already carries its own
   payment_method, so this needed to show up in aggregate reporting
   rather than being requested as a schema change. Both
   supplier_statement_rows() and all_suppliers_summary_rows() now
   return paid_cash/paid_bank/paid_unspecified alongside the existing
   totals, surfaced as extra columns (summary) or a breakdown line
   (per-supplier detail) in both Excel and PDF. The two PDFs that grew
   more columns (per-supplier statement was already landscape; the
   summary now is too) to avoid cramming.

Verified with a 22-check smoke test (note no longer auto-stuffed,
inventory reports carry a proper supplier column, payment totals split
and add back up to the combined total, both statement shapes show the
breakdown) plus a full regression rerun of every smoke suite from this
session — fixed two of them (smoke25) whose old assertions did exact
dict equality against a totals dict that legitimately grew new keys.
2026-08-26 21:20:57 +03:00
662f0dcbc7 Track payment method (cash/bank) on supplier deliveries, surface it in statements
New SupplierDelivery.payment_method ("cash"/"bank", optional) — settable
from both the manual multi-add form (one накладна = one payment method,
shared like reference/dates) and the delivery edit form. Shown as a new
column in the deliveries table and on the per-supplier statement.

Also widened the detailed statement (Excel + PDF) with columns it was
missing: Постачальник (repeated per row, not just in the header —
useful once a row is copied out of context), Спосіб оплати, and
Примітка. The PDF version switched to landscape to fit the extra
columns without cramming.

Verified with a 17-check smoke test (payment_method round-trips through
add/edit, invalid values rejected rather than stored raw, new statement
columns present with correct content, landscape PDF still renders)
plus a full regression rerun of every supplier/inventory smoke suite
from this session.
2026-08-26 21:02:56 +03:00
2621c2395d Add downloadable movement history for a single ingredient
Completes the inventory reporting set: the snapshot (all ingredients,
now) and the movements log (everything, date-range) already existed —
this adds the per-ingredient detail, the direct analog of the
per-supplier statement vs. the all-suppliers summary.

inventory_movements_rows()/render_movements_xlsx()/render_movements_pdf()
gained an optional `ingredient` parameter: when given, the report scopes
to that ingredient only and drops the now-redundant "Інгредієнт" column;
omitted, behavior is byte-for-byte unchanged from before (verified). New
route GET /inventory/<id>/report/movements/<fmt>, with a download block
on the ingredient detail page next to its movement history table.

Verified with a 16-check smoke test (correct per-ingredient scoping,
date-range filtering on top of it, column-shape difference between the
single- and multi-ingredient report, 404 on an unknown id) plus a full
regression rerun of the supplier/inventory smoke suites from this
session.
2026-08-26 20:53:10 +03:00
6f361bc166 Add downloadable Excel/PDF stock reports (snapshot + movement history)
Mirrors the supplier statements added this session: a current-stock
snapshot (name, supplier, levels, cost, stock value, status) and a
date-range-filterable log of every StockMovement, both in Excel and PDF.
Reuses supplier_reports.py's PDF font-loading plumbing rather than
duplicating it. Download buttons added to the inventory list page.

Verified with a 20-check smoke test (snapshot/movement data correctness,
date-range filtering, real route responses reopened/parsed, missing-font
fallback) plus a full regression rerun of the supplier/inventory smoke
suites from this session.
2026-08-26 20:41:57 +03:00