Metadata-Version: 2.4
Name: payhub
Version: 1.0.0
Summary: Official PayHub SDK for Python.
Project-URL: Homepage, https://payhub.ly
Project-URL: Source, https://bitbucket.org/safwatech/payhub/src/main/sdks/python
Author: Safwa Tech
License: MIT
License-File: LICENSE
Keywords: libya,moamalat,mobicash,payhub,payments,sadad,tlync
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.9
Requires-Dist: httpx>=0.25
Provides-Extra: test
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
Requires-Dist: pytest>=7.4; extra == 'test'
Requires-Dist: respx>=0.21; extra == 'test'
Description-Content-Type: text/markdown

# payhub — Python SDK

Official PayHub SDK for Python. Sync + async clients, fully typed,
zero non-stdlib runtime dependencies beyond `httpx`.

```
pip install payhub
```

> **PayHub API:** v1 · **Python:** ≥3.9 · **License:** MIT

## 1. Authenticate

```python
from payhub import Payhub

client = Payhub(api_key="phk_<id>.<secret>", base_url="https://app.payhub.ly")
```

`AsyncPayhub` is the awaitable variant — same shape, same models. Both
support context-manager use (`with client:` / `async with client:`) for
deterministic connection cleanup.

## 2. Your first payment — Sadad OTP, end to end

```python
from payhub import Payhub, OtpRequired

with Payhub(api_key="phk_…") as client:
    payment = client.payments.create({
        "psp": "sadad",
        "merchant_order_ref": "ord-42",
        "amount_minor": 4500,                  # 4.5 LYD
        "customer": {
            "msisdn": "218910000001",          # mandatory for Sadad
            "birth_year": 1990,                # mandatory for Sadad
        },
    })

    match payment.next_action:
        case OtpRequired(masked_destination=mask):
            print(f"OTP sent to {mask}; ask the customer to type it in.")
        case _:
            ...

    # Customer types the code into your form; you POST it back.
    settled = client.payments.confirm_otp(payment.id, code="123456")
    print(settled.status)  # "succeeded" or "failed"
```

The SDK auto-mints a UUID4 `Idempotency-Key` for every `create` /
`confirm_otp` / `refund`. Pass `idempotency_key=...` to use your own
(safe across process restarts).

## 3. Webhook receiver (FastAPI)

> ⚠️ **Pass the raw request bytes, never a parsed body.** Re-encoding a
> JSON object changes whitespace and breaks the HMAC. Use
> `await request.body()` to get the raw bytes.

```python
from fastapi import FastAPI, Request, HTTPException
from payhub import WebhookEvent, WebhookSignatureError

app = FastAPI()
WEBHOOK_SECRET = b"…"  # bytes; configure via env

@app.post("/webhooks/payhub")
async def receive(request: Request):
    body = await request.body()                       # raw bytes
    sig = request.headers.get("Hub-Signature")
    if not sig:
        raise HTTPException(400, "missing signature")
    try:
        event = WebhookEvent.verify(WEBHOOK_SECRET, body, sig)
    except WebhookSignatureError:
        raise HTTPException(401, "invalid signature")

    if event.type == "payment.succeeded":
        # mark order paid
        ...
    elif event.type in {"payment.failed", "payment.expired"}:
        # release inventory / notify customer
        ...
    elif event.type == "payment.refunded":
        # update accounting
        ...
    return {"ok": True}
```

Default replay tolerance is 300 s. Override via
`WebhookEvent.verify(..., tolerance_seconds=60)`.

## 4. Errors

| Exception | Fires on |
| --- | --- |
| `AuthenticationError` | 401 — bad API key, IP not allowlisted |
| `PermissionError` | 403 |
| `NotFoundError` | 404 |
| `ValidationError` | 422 — `customer.msisdn` missing for Sadad, etc. |
| `IdempotencyConflict` | 409 — same `Idempotency-Key` reused with a different body |
| `RateLimited` | 429 — exposes `.retry_after` |
| `GatewayError` | 502 from a `gateway.<psp>.*` code |
| `ServerError` | other 5xx |
| `TimeoutError`, `ConnectionError`, `DecodeError` | network / serialization |
| `WebhookSignatureError` | base for the three webhook variants below |
| → `MalformedHeader` | `Hub-Signature` missing `t=` or `v1=` |
| → `TimestampOutOfTolerance` | `\|now − t\| > tolerance` (carries `.skew_seconds`) |
| → `InvalidSignature` | HMAC mismatch / non-JSON body |

All API errors carry `.code`, `.http_status`, `.details`, `.request_id`.
Log `request_id` to support tickets — it matches the server-side log line.

## Configuration

```python
Payhub(
    api_key="phk_…",
    base_url="https://app.payhub.ly",
    timeout=30.0,
    max_retries=2,                 # idempotent calls only
    http_client=my_httpx_client,   # injection seam (proxies, tests)
    user_agent_suffix="Acme/1.2",
)
```

## Versioning

Independent semver. Compatible with PayHub API v1.

## Development

```
pip install -e ".[test]"
pytest                  # runs the cross-language vectors + unit tests
```

Tests load `../shared/test-vectors/webhook-signing.json` — the canonical
spec consumed by every PayHub SDK and by the server's own
`tests/unit/test_webhook_signing_vectors.py`.
