# Payment providers Bober BBQ can take card payments through more than one provider. This document is the map of how that's wired, and the checklist for adding another one. ## Files - `bober_bbq/payments/base.py` — the shared contract every provider module follows (documented as a `Protocol` for type-checkers; nothing enforces it at runtime, matching the rest of this codebase's plain-module style). Also hosts `ProviderNotConfigured`/`ProviderError` (the two exceptions every provider's own exceptions must subclass) and `claim_order_paid` (the atomic "mark this order paid, exactly once" transition shared by every provider and every call site). - `bober_bbq/payments/monobank.py` — Monobank Merchant Acquiring API. The original/reference implementation. - `bober_bbq/payments/liqpay.py` — LiqPay Checkout (CNB) API. - `bober_bbq/payments/registry.py` — picks the active provider (`Setting.get("payment_provider")`, admin-configurable) and dispatches an existing `Order` to whichever provider actually created its invoice (`Order.payment_provider`), so switching the active provider never disturbs orders already in flight on the old one. - `bober_bbq/api/payments.py` — the Monobank webhook route (`POST /api/payments/monobank/webhook`) plus `notify_order_paid()` / `notify_order_payment_failed()`, the provider-agnostic "just got paid/failed" side effects (customer + staff Telegram messages, fiscalization) shared by every provider's webhook. - `bober_bbq/api/payments_liqpay.py` — LiqPay's webhook (`POST /api/payments/liqpay/webhook`, form-encoded `data`+`signature`, NOT JSON — see below) and the checkout-redirect bridge page (`GET /api/payments/liqpay/redirect/`) that LiqPay needs and Monobank doesn't. - Call sites that create/poll a payment, all going through `registry.py` rather than a hardcoded provider: - `bober_bbq/api/checkout.py` — `_offer_card_payment()` (new order from the Mini App checkout form) and `GET /api/checkout/payment-status/` (the Mini App's own "Перевірити оплату" poll button). - `bober_bbq/api/orders.py` — `POST /orders//pay` (retry payment on an existing order). - `bober_bbq/utils/reconciliation.py` — `reconcile_pending_payments()`, the periodic APScheduler job that catches missed webhooks for whichever provider each pending order actually used. ## The race-safety guarantee The same "card payment just cleared" event can be observed from three independent places racing each other, for either provider: that provider's webhook, the Mini App's own poll, and the reconciliation job. `claim_order_paid(order)` in `base.py` is a single `UPDATE ... WHERE payment_status != 'paid'` — it returns `True` for whichever caller actually won the transition, `False` for every other racing caller, so paid-order side effects (notification, fiscalization) fire exactly once no matter which path (or which provider) got there first. Every call site checks this return value before firing side effects; never skip that check when wiring up a new provider or a new call site. ## Adding a third provider (e.g. Fondy) Fondy is **not implemented** — this is the checklist for whoever adds it (or another provider) later, written against `liqpay.py` as the concrete template to copy from (it's the more recently added, more heavily commented of the two existing modules). 1. **New module** `bober_bbq/payments/fondy.py`. Define, matching the shape documented in `base.py`: - `NAME = "fondy"` - `FondyNotConfigured(ProviderNotConfigured)`, `FondyError(ProviderError)` - `create_invoice(order) -> dict` returning at least `{"invoiceId": ..., "pageUrl": ...}` - `get_invoice_status(invoice_id) -> dict` returning at least `{"status": ...}` in Fondy's own status vocabulary - `verify_webhook_signature(...)` for whatever shape Fondy's webhook actually posts (check Fondy's current docs — like LiqPay vs. Monobank, don't assume it matches either existing shape) - `verify_token(...)` — a credentials smoke-test, ideally against a real no-op endpoint if Fondy has one (Monobank does; LiqPay didn't, see the honesty note in `liqpay.py`'s own `verify_token()` docstring) - `PAID_STATUSES` / `FAILED_STATUSES` — Fondy's own status strings - Re-export `claim_order_paid` from `base.py` (`from bober_bbq.payments.base import claim_order_paid`) rather than reimplementing it. 2. **Register it** in `bober_bbq/payments/registry.py`'s `PROVIDERS` dict: `fondy.NAME: fondy`. Also extend `get_order_invoice_id` / `set_order_invoice` / `get_order_page_url` with a Fondy branch if it needs its own id/url columns (see next step) — right now those three functions are a simple `if provider is liqpay: ... else: monobank-shaped columns`, which stops being adequate once there's a third shape; switch that to an `if/elif/else` per provider at that point. 3. **New `Order` columns** if Fondy's invoice reference doesn't fit the existing ones: add `fondy_invoice_id` / `fondy_page_url` (or whatever Fondy actually calls them) to `bober_bbq/models.py`'s `Order` class, and a matching `_add_column_if_missing(...)` pair in `bober_bbq/migrate.py` (this project has no Alembic — see that file's own docstring). Do **not** reuse Monobank's or LiqPay's id/url columns for a different provider's identifiers. 4. **Webhook route.** If Fondy's webhook body shape differs from both existing ones (form vs. JSON, different field names), give it its own file `bober_bbq/api/payments_fondy.py` mirroring `payments_liqpay.py` — import `payments_bp`, `notify_order_paid`/`notify_order_payment_failed` from `bober_bbq.api.payments`, define `@payments_bp.post("/fondy/webhook")`, and import that new module at the bottom of `api/payments.py` (same pattern already used for `payments_liqpay`) so its routes register. 5. **Settings**: add `fondy_...` keys to `DEFAULT_SETTINGS` in `models.py` (credentials + any provider-specific toggle, e.g. a sandbox flag if Fondy has one), add a `"fondy"` `