Metadata-Version: 2.5
Name: norialabs-pay
Version: 0.1.1
Summary: Python client for Noria Pay: collect, pay out and refund across M-PESA, SasaPay and Paystack through one internal service instead of wiring a provider into every product.
Project-URL: Homepage, https://github.com/norialabs/pay
Project-URL: Source, https://github.com/norialabs/pay
Project-URL: Issues, https://github.com/norialabs/pay/issues
Author-email: Joseph Gitonga <thekiharani@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: kenya,mpesa,noria,pay,payments,paystack,sasapay
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: httpx>=0.28.1
Description-Content-Type: text/markdown

# `norialabs-pay`

Python client for **Noria Pay**. Collect, pay out and refund across M-PESA, SasaPay and
Paystack through one internal service instead of wiring a provider into every product.

Sync and async, typed, `httpx` the only dependency.

```bash
pip install norialabs-pay
```

## Collecting

An idempotency key is a required argument, not an option you can forget. Use something your
own system already owns — the invoice id plus the attempt — not a fresh uuid per call, or the
key protects nothing.

```python
from noria_pay import Pay

pay = Pay(api_key=os.environ["PAY_API_KEY"], base_url="https://pay.noria.co.ke")

charge = pay.charges.create(
    {
        "amount_minor": 150_000,       # KES 1,500.00
        "channel": "mpesa",
        "reference": invoice.number,   # yours; the service never interprets it
        "description": "April rent",
        "payer_phone": customer.phone,
        "metadata": {"invoice_id": str(invoice.id)},
    },
    f"invoice:{invoice.id}:{attempt}",
)

# charge["next_action"]["type"] is "await_payer" (the STK prompt is already sent)
# or "redirect" (send them to ["url"]).
```

`AsyncPay` has the same surface with `await`, and both work as context managers.

## The one error that is not like the others

A `504` carrying `outcome_unknown` means the provider never answered. The payer **may already
have been debited**. The client never retries it, and neither should you.

```python
from noria_pay import PayError

try:
    pay.charges.create(charge, key)
except PayError as error:
    if error.outcome_unknown:
        settled = pay.wait_for_settlement(error.transaction_id)
        # resolve against settled["status"], never by charging again
    raise
```

## Receiving webhooks

Verify against the **raw body**. A framework that hands you a parsed dict has already lost the
byte order, and re-serialising it will not match the signature.

```python
from noria_pay import verify_webhook

@app.post("/hooks/pay")
async def hook(request: Request):
    event = verify_webhook(
        await request.body(),
        request.headers["pay-signature"],
        os.environ["PAY_WEBHOOK_SECRET"],
    )

    if event["type"] == "succeeded":
        # settled_minor is what actually moved, which is not always amount_minor: a payer can
        # underpay an STK prompt, and Paystack deducts its fee before settlement.
        await settle(event["data"]["reference"], event["data"]["settled_minor"], event["data"]["fee_minor"])

    return Response(status_code=204)
```

It raises on a bad signature, a wrong secret, or a timestamp outside the five-minute
tolerance, so a replay cannot post twice.

## Amounts

Always minor units. The service rejects a KES amount that is not a whole number of shillings
rather than rounding it, because the rails settle whole shillings and a silent round only
surfaces in reconciliation.

## Surfaces

`pay.charges` · `payouts` · `refunds` · `transactions` · `payment_methods` · `payment_links` ·
`webhooks`

## This is not `noriapay`

That package wraps the providers directly and this service uses it internally. This one talks
to Noria Pay, which owns the persistence, the callbacks and the reconciliation that `noriapay`
deliberately leaves to you.

Events are not delivered in order. Delivery runs in parallel and retries, so a later event can
arrive first; order on `created_at`, which is when the event happened rather than when the
attempt went out. Every payload also carries the transaction's status as it stood at delivery,
so acting on that is safe whatever order they arrive in.
