bober-bbq-bot/docs/PAYMENTS.md
byrsapty 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

153 lines
8.6 KiB
Markdown

# 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/<order_id>`) 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/<id>`
(the Mini App's own "Перевірити оплату" poll button).
- `bober_bbq/api/orders.py``POST /orders/<id>/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"` `<option>` to the
`payment_provider` `<select>` in
`bober_bbq/templates/admin/settings.html`, a new `<h3>Fondy</h3>`
fields block following the LiqPay block's shape, wire any checkbox
setting into `CHECKBOX_SETTINGS` in `bober_bbq/admin/routes.py`, and
(optionally) a `settings_test_fondy` route mirroring
`settings_test_liqpay`.
6. **Config fallback**: add `FONDY_...` env vars to `config.py`
following the `LIQPAY_...` / `MONOBANK_...` precedent (Setting wins,
env var is only the pre-first-admin-visit fallback).
7. **Reconciliation**: extend the `or_(...)` filter in
`reconcile_pending_payments()` (`bober_bbq/utils/reconciliation.py`)
to also match Fondy's new invoice-id column, and optionally add a
`check_fondy_token_health()` mirroring `check_liqpay_token_health()`,
wired into `run_web.py`'s scheduler the same way.
8. **Test it** the same way this session's LiqPay work was tested (no
live Fondy sandbox was available for LiqPay either): monkeypatch
`fondy.requests.post`/`.get` and re-run the payment-race regression
test, extended to also create a Fondy-provider order and race it the
same way the Monobank/LiqPay ones are raced. Get a real Fondy sandbox
test before taking real money through it — mocked-HTTP unit coverage
is a structural safety net, not a substitute for hitting the real API
at least once.
## What's genuinely uncertain about the LiqPay implementation
Flagged here (and inline in `liqpay.py`) because this was built without
access to live LiqPay credentials or a sandbox:
- The stable, well-documented parts (verified against LiqPay's public
documentation, though not against a live server): the
`base64(JSON)` + `base64(sha1(private_key + data + private_key))`
signing scheme, the checkout endpoint
(`https://www.liqpay.ua/api/3/checkout`), the fact that its webhook
posts `data`+`signature` as form fields rather than JSON, and the
status-check endpoint (`https://www.liqpay.ua/api/request`,
`action: "status"`).
- Genuinely guessed, not confirmed: the exact `err_code` values LiqPay
returns for "bad public/private key" vs. "no such order" (used by
`liqpay.verify_token()`'s heuristic — see its docstring), and the
complete set of possible `status` values beyond the well-known
`success` / `failure` / `error` / `reversed` / `expired` / `sandbox`.
- Before this goes live: get a real LiqPay sandbox account, run one real
payment through it, and confirm `verify_token()`'s "keys look valid"
heuristic actually distinguishes bad keys from a nonexistent order the
way it's designed to.