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

8.6 KiB

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.pyPOST /orders/<id>/pay (retry payment on an existing order).
    • bober_bbq/utils/reconciliation.pyreconcile_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.