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>
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 aProtocolfor type-checkers; nothing enforces it at runtime, matching the rest of this codebase's plain-module style). Also hostsProviderNotConfigured/ProviderError(the two exceptions every provider's own exceptions must subclass) andclaim_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 existingOrderto 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) plusnotify_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-encodeddata+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.pyrather than a hardcoded provider:bober_bbq/api/checkout.py—_offer_card_payment()(new order from the Mini App checkout form) andGET /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).
- New module
bober_bbq/payments/fondy.py. Define, matching the shape documented inbase.py:NAME = "fondy"FondyNotConfigured(ProviderNotConfigured),FondyError(ProviderError)create_invoice(order) -> dictreturning at least{"invoiceId": ..., "pageUrl": ...}get_invoice_status(invoice_id) -> dictreturning at least{"status": ...}in Fondy's own status vocabularyverify_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 inliqpay.py's ownverify_token()docstring)PAID_STATUSES/FAILED_STATUSES— Fondy's own status strings- Re-export
claim_order_paidfrombase.py(from bober_bbq.payments.base import claim_order_paid) rather than reimplementing it.
- Register it in
bober_bbq/payments/registry.py'sPROVIDERSdict:fondy.NAME: fondy. Also extendget_order_invoice_id/set_order_invoice/get_order_page_urlwith a Fondy branch if it needs its own id/url columns (see next step) — right now those three functions are a simpleif provider is liqpay: ... else: monobank-shaped columns, which stops being adequate once there's a third shape; switch that to anif/elif/elseper provider at that point. - New
Ordercolumns if Fondy's invoice reference doesn't fit the existing ones: addfondy_invoice_id/fondy_page_url(or whatever Fondy actually calls them) tobober_bbq/models.py'sOrderclass, and a matching_add_column_if_missing(...)pair inbober_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. - 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.pymirroringpayments_liqpay.py— importpayments_bp,notify_order_paid/notify_order_payment_failedfrombober_bbq.api.payments, define@payments_bp.post("/fondy/webhook"), and import that new module at the bottom ofapi/payments.py(same pattern already used forpayments_liqpay) so its routes register. - Settings: add
fondy_...keys toDEFAULT_SETTINGSinmodels.py(credentials + any provider-specific toggle, e.g. a sandbox flag if Fondy has one), add a"fondy"<option>to thepayment_provider<select>inbober_bbq/templates/admin/settings.html, a new<h3>Fondy</h3>fields block following the LiqPay block's shape, wire any checkbox setting intoCHECKBOX_SETTINGSinbober_bbq/admin/routes.py, and (optionally) asettings_test_fondyroute mirroringsettings_test_liqpay. - Config fallback: add
FONDY_...env vars toconfig.pyfollowing theLIQPAY_.../MONOBANK_...precedent (Setting wins, env var is only the pre-first-admin-visit fallback). - Reconciliation: extend the
or_(...)filter inreconcile_pending_payments()(bober_bbq/utils/reconciliation.py) to also match Fondy's new invoice-id column, and optionally add acheck_fondy_token_health()mirroringcheck_liqpay_token_health(), wired intorun_web.py's scheduler the same way. - 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/.getand 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 postsdata+signatureas 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_codevalues LiqPay returns for "bad public/private key" vs. "no such order" (used byliqpay.verify_token()'s heuristic — see its docstring), and the complete set of possiblestatusvalues beyond the well-knownsuccess/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.