Metadata-Version: 2.5
Name: yoonpay
Version: 0.7.0
Summary: Python client for Yoon, a self-hosted payment gateway for African payment providers, with Django, FastAPI and Flask helpers.
Project-URL: Homepage, https://github.com/crossben/yoonpay
Project-URL: Source, https://github.com/crossben/yoonpay/tree/main/clients/python
Project-URL: Issues, https://github.com/crossben/yoonpay/issues
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: dexpay,naboopay,orange-money,paydunya,payments,senegal,wave,xof,yoon
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.0
Requires-Dist: python-dateutil>=2.8.2
Requires-Dist: typing-extensions>=4.7.1
Requires-Dist: urllib3<3.0.0,>=2.1.0
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Provides-Extra: flask
Requires-Dist: flask>=2.3; extra == 'flask'
Provides-Extra: test
Requires-Dist: httpx>=0.24; extra == 'test'
Requires-Dist: pytest-httpserver>=1.0; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# yoonpay

Python client for [Yoon](https://github.com/crossben/yoonpay), a self-hosted payment gateway
for African payment providers, with Django, FastAPI and Flask helpers. Apache-2.0.

Requires Python 3.10+. Synchronous (urllib3); there is no async client yet.

```sh
pip install yoonpay              # the client
pip install "yoonpay[django]"    # or [fastapi], [flask]: the framework is an optional extra
```

## Plain Python

```python
import os
from yoonpay import Yoon, YoonException

yoon = Yoon("https://pay.example.com", os.environ["YOON_API_KEY"])  # timeout=30, connect_timeout=5

try:
    payment = yoon.create_payment(
        {
            "amount": 5000,              # XOF has no minor unit: 5 000 FCFA
            "currency": "XOF",
            "country": "SN",
            "method": "wave",            # wave, orange_money, free_money, card
            "customer": {"phone": "+221771234567"},
            "reference": "order_1042",
            "return_url": "https://shop.example/orders/1042",
        },
        idempotency_key="order-1042",    # tie it to your order: a retry can never charge twice
    )
    redirect_to = payment.checkout_url
except YoonException as e:
    e.problem_code     # e.g. no_provider_for_method, refund_exceeds_payment
    e.is_retryable()   # True: retry with the same idempotency key
```

Helpers: `create_payment`, `get_payment`, `refund(payment_id, idempotency_key, amount=None,
reason=None)`, `create_payout`, `export_csv("payments" | "refunds" | "payouts" | "ledger",
from_=None, to=None)`. Every other operation of the API is on the generated API objects —
`yoon.payments()`, `.refunds()`, `.payouts()`, `.events()`, `.ledger()`, `.meta()` — wrapped
with `yoon.call(lambda: …)` to turn errors into `YoonException`:

```python
page = yoon.call(lambda: yoon.payments().list_payments(reference="order_1042"))
```

The idempotency key is a required argument of every write: the client never makes one up and
never retries on its own (urllib3's retries are switched off). Retrying is your decision, with
the same key.

## Django

```python
# settings.py
YOON_WEBHOOK_SECRET = os.environ["YOON_WEBHOOK_SECRET"]  # YOON_APPS_<APP>_WEBHOOK_SECRET on the Yoon side
# optional: YOON_WEBHOOK_CACHE = "default", YOON_WEBHOOK_DEDUPE_SECONDS = 259200

# views.py
from django.http import HttpResponse
from yoonpay.django import yoon_webhook

@yoon_webhook
def yoon_events(request, event):
    if event.type == "payment.succeeded":
        Order.objects.filter(yoon_payment_id=event.object["id"]).update(status="paid")
    return HttpResponse(status=204)

# urls.py
urlpatterns = [path("yoon/webhook", yoon_events)]
```

The decorator exempts the view from CSRF, accepts only POST, and remembers handled event ids in
Django's cache (use a shared backend such as Redis when you run several servers).

## FastAPI

```python
import os
from fastapi import APIRouter, Depends, FastAPI
from yoonpay import Event
from yoonpay.fastapi import yoon_event, yoon_webhook_route

webhooks = APIRouter(route_class=yoon_webhook_route(secret=os.environ["YOON_WEBHOOK_SECRET"]))

@webhooks.post("/yoon/webhook", status_code=204)
def yoon_events(event: Event = Depends(yoon_event)) -> None:
    if event.type == "payment.succeeded":
        ...

app = FastAPI()
app.include_router(webhooks)
```

Only the routes of that router are checked. The default id store is in-process; with several
workers pass `store=CacheEventStore(shared_cache)`.

## Flask

```python
from yoonpay.flask import yoon_webhook

app.config["YOON_WEBHOOK_SECRET"] = os.environ["YOON_WEBHOOK_SECRET"]

@app.post("/yoon/webhook")
@yoon_webhook
def yoon_events(event):
    if event.type == "payment.succeeded":
        ...
    return "", 204
```

The default id store is in-process, per app; with several workers pass
`@yoon_webhook(store=CacheEventStore(cache))`, e.g. over Flask-Caching.

## Webhooks

All three helpers behave the same: a wrong or stale signature (more than 300 s off) gets 401
without reaching your code, an already-handled event gets 200 without reaching your code, and an
event id is remembered only after your code answered 2xx — if it fails or raises, Yoon's retry
reaches it again. Events are unordered and may arrive more than once: act on the state in
`event.object`, not on arrival order.

**Raw body.** The signature covers the bytes Yoon sent. The helpers read the raw body
(`request.body` in Django, `await request.body()` in FastAPI, `request.get_data()` in Flask);
if you verify by hand, do the same — a body parsed and re-encoded as JSON no longer matches
and is rejected.

Outside these frameworks:

```python
from yoonpay import Event, verify_signature

if not verify_signature(secret, headers.get("Yoon-Signature"), raw_body):
    ...  # answer 401
event = Event.from_json(raw_body)   # event.id, event.type, event.object
```

`sign_signature(secret, raw_body)` builds a header for your own tests.

## Billing (prepaid subscriptions)

Yoon 0.3.0 bills prepaid subscriptions: each period is an invoice the customer pays through
hosted checkout; nothing renews by itself. `yoon.customers()`, `yoon.plans()` and
`yoon.subscriptions()` (which also has `get_invoice` and `list_invoices`) are the generated
APIs; wrap calls in `yoon.call(...)` to get `YoonException`.

```python
from yoonpay.billing import Entitlement, changes_entitlement
from yoonpay.fastapi import NoCheckout, redirect_to_checkout
from yoonpay.generated import CreateSubscriptionRequest, RenewSubscriptionRequest

# 1. Subscribe (customer created once with external_id = your user id), then send to checkout.
sub = yoon.call(lambda: yoon.subscriptions().create_subscription(
    f"subscribe-{user.id}-{price_id}",
    CreateSubscriptionRequest(customer=customer_id, price=price_id, return_url="https://app.example/billing")))
return redirect_to_checkout(sub)            # 303 to sub.latest_invoice.checkout_url

# Renewing: an invoice that is paid, void or whose checkout expired has no checkout_url,
# so redirect_to_checkout raises NoCheckout; renew gives an open invoice with a fresh checkout.
sub = yoon.call(lambda: yoon.subscriptions().renew_subscription(
    sub_id, f"renew-{sub_id}-{period}", RenewSubscriptionRequest(return_url="https://app.example/billing")))
return redirect_to_checkout(sub)

# 2. Entitle only from webhooks (never from the return URL).
@webhooks.post("/yoon/webhook", status_code=204)
def yoon_events(event: Event = Depends(yoon_event)) -> None:
    if changes_entitlement(event.type):     # subscription.created|activated|renewed|changed|past_due|expired|canceled
        e = Entitlement.from_subscription(event.object)
        save_access(e.external_id, entitled=e.entitled, plan=e.plan, until=e.until, features=e.features)
```

Customer portal (Yoon 0.6+): `return redirect_to_portal(yoon, customer_id, return_url=..., allow={"cancel": True})` in a FastAPI route, or `yoon.create_portal_session(...)` anywhere — for the signed-in user's own customer only.

`Entitlement` is entitled for `active`, `past_due` (the grace period) and `trialing`; `until` is
`current_period_end`, `None` for an open-ended comp. An unknown status gives
`entitled=False` and an unknown event type is not in `ENTITLEMENT_EVENTS`: neither raises.
`INFORMATIONAL_EVENTS` (`subscription.renewal_upcoming`, `subscription.trial_will_end`,
`invoice.created|paid|voided`) never change access. `yoonpay.billing` needs no framework; only
`redirect_to_checkout` lives in `yoonpay.fastapi` (`checkout_url(obj)` gives the URL anywhere).

## Prompt for an AI coding agent

Copy this into your coding agent (Claude Code, Cursor, Copilot) to add Yoon to an existing app.

```text
Integrate Yoon payments (yoonpay 0.7.x) into this Python app.
Install: pip install "yoonpay[django]" (or [fastapi], [flask]; Python 3.10+)
Do:
1. One shared client: Yoon(os.environ["YOON_URL"], os.environ["YOON_API_KEY"]).
2. Create a payment keyed on the order: yoon.create_payment({...amount, "currency": "XOF",
   "country": "SN", "method": "wave", "reference": str(order.id), "return_url": ...},
   idempotency_key=f"order-{order.id}"); redirect to its checkout_url.
3. Webhook with this app's framework helper (it verifies the raw body and drops duplicates):
   Django: @yoon_webhook from yoonpay.django (settings.YOON_WEBHOOK_SECRET); FastAPI:
   APIRouter(route_class=yoon_webhook_route(secret=...)) and a yoon_event dependency from
   yoonpay.fastapi; Flask: @yoon_webhook from yoonpay.flask (app.config["YOON_WEBHOOK_SECRET"]).
   Update the order from the event's type and object.
   Subscriptions (Yoon 0.3.0+, prepaid): create with yoon.subscriptions().create_subscription(key,
   CreateSubscriptionRequest(...)) inside yoon.call(...), redirect with redirect_to_checkout(sub)
   from yoonpay.fastapi (or to sub.latest_invoice.checkout_url); grant access only in the webhook:
   if changes_entitlement(event.type): Entitlement.from_subscription(event.object) (yoonpay.billing).
   past_due is still entitled (grace); unknown statuses are not entitled.
4. Errors are YoonException: problem_code, http_status, is_retryable().
Rules (they protect real money; follow them exactly):
- Amounts are integers in minor units, never floats: XOF has no minor unit, 5000 = 5 000 XOF.
- Every write takes an idempotency key tied to the business object ("order-<id>",
  "refund-<order id>-<n>"). On a retryable error, retry with the SAME key; a new key could
  charge twice. The client never retries on its own.
- Store Yoon's payment id on the order. Fulfil ONLY when the webhook says payment.succeeded
  (or GET the payment and check status == "succeeded"). The customer returning to return_url
  proves nothing.
- "pending" means the provider has not answered yet, not failed: show "waiting for payment".
  payment.failed / payment.expired: let the customer try again (a new order or a new key).
- Webhooks: verify the signature over the RAW request body (the helper below does it), answer
  2xx quickly, and make the handler idempotent: the same event can arrive more than once.
  Events: payment.succeeded, payment.failed, payment.expired, refund.succeeded, refund.failed,
  payout.paid, payout.failed, payout.needs_review; billing: subscription.* and invoice.*.
- Never log the API key or full phone numbers; read YOON_URL, YOON_API_KEY and
  YOON_WEBHOOK_SECRET from the environment.
- Optional: "checkout": "hosted" (without "method") sends the customer to Yoon's own page to
  choose Wave, Orange Money, card, …; redirect them to the returned checkout_url.
Verify: run Yoon with YOON_DEMO_ENABLED=true and YOON_APPS_<APP>_PROVIDERS_DEMO_PRIORITY=1,
set YOON_APPS_<APP>_WEBHOOK_URL to this app's webhook URL, create a payment, open its
checkout_url, click Pay, and check the order becomes paid only after the webhook arrives.
Then click Decline on another payment and check the order is not paid.
Docs: https://yoonpay.benhattab.pro/docs/
```

## Errors

`YoonException` carries `http_status` (`None` when Yoon was not reached), `problem_code` (Yoon's
stable `code`, `None` when Yoon was not reached), `problem` (the full problem document) and
`is_retryable()` — true when Yoon was unreachable, busy (`idempotency_in_progress`) or failing
(5xx). A 2xx answer the client cannot read gets `problem_code = "unreadable_response"` and is
**not** retryable: the call may have worked, look it up first. The API key never appears in
errors or in `repr(yoon)`.

A status or event type added by a newer Yoon server does not break parsing: the value is kept
as a string (`payment.status == "new_status"`).

## Layout

- `generated/` — generated from `api/openapi.yaml` by `clients/generate.sh` (the package
  `yoonpay.generated`). Never edit by hand; CI fails if it drifts from the contract.
- `src/yoonpay/` — the hand-written layer: `Yoon`, `YoonException`, webhook helpers, framework
  integrations.
- `e2e/run.py` — the shared end-to-end scenario (`clients/e2e/scenario.md`) against a real server.

```sh
pip install -e ".[django,fastapi,flask,test]" && pytest
```
