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>
153 lines
8.6 KiB
Markdown
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.
|