Metadata-Version: 2.4
Name: nombaone
Version: 0.1.0
Summary: The official Python SDK for the NombaOne subscription-billing API — recurring billing for Nigeria.
Project-URL: Homepage, https://docs.nombaone.xyz
Project-URL: Documentation, https://docs.nombaone.xyz
Project-URL: Repository, https://github.com/nombaone/nombaone-python
Project-URL: Changelog, https://github.com/nombaone/nombaone-python/blob/main/CHANGELOG.md
Author: Nomba One
License: MIT
License-File: LICENSE
Keywords: api,billing,nigeria,nomba,nombaone,payments,sdk,subscriptions
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.7
Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# nombaone

The official Python SDK for the [Nomba One](https://nombaone.xyz) subscription-billing API — recurring billing for Nigeria over card, direct debit, bank transfer, and more, with dunning that recovers and a ledger that never loses a kobo.

```bash
pip install nombaone
```

Requires Python 3.9+. Sync and async clients, fully typed (ships `py.typed`), two dependencies (`httpx`, `pydantic`).

## Quickstart

Grab a sandbox key (`nbo_sandbox_…`) from the [dashboard](https://app.nombaone.xyz), set it as `NOMBAONE_API_KEY`, and you are three objects away from a live subscription:

```python
import os
from nombaone import Nombaone

nombaone = Nombaone(os.environ["NOMBAONE_API_KEY"])

plan = nombaone.plans.create(name="Pro")
price = nombaone.plans.prices.create(
    plan.id,
    unit_amount_in_kobo=250_000,  # ₦2,500.00 per month
    interval="month",
)
customer = nombaone.customers.create(email="ada@example.com", name="Ada Lovelace")

# Sandbox: mint a deterministic test card, then subscribe.
method = nombaone.sandbox.create_payment_method(customer_id=customer.id)
subscription = nombaone.subscriptions.create(
    customer_id=customer.id,
    price_id=price.id,
    payment_method_id=method.id,
)

print(subscription.status)  # "active"
```

The client derives the host from your key prefix — `nbo_sandbox_…` talks to `https://sandbox.api.nombaone.xyz`, `nbo_live_…` to `https://api.nombaone.xyz`. Server-side only; there is no publishable key to leak.

### Async

Every method has an async twin on `AsyncNombaone` with the identical surface:

```python
import asyncio
from nombaone import AsyncNombaone

async def main():
    async with AsyncNombaone() as nombaone:
        customer = await nombaone.customers.create(email="ada@example.com", name="Ada")
        async for c in await nombaone.customers.list():
            print(c.email)

asyncio.run(main())
```

## Sandbox first

The sandbox runs the real billing engine. `nombaone.sandbox.*` gives you the levers to make a month happen in a second:

```python
# A card that declines like a thin balance does — "not yet", not "no".
nombaone.sandbox.create_payment_method(
    customer_id=customer.id,
    behavior="decline_insufficient_funds",  # or success | requires_otp | decline_expired_card | decline_do_not_honor
)

# The test clock: force the next billing cycle through the real engine.
cycle = nombaone.sandbox.advance_cycle(subscription.id)
print(cycle.outcome)  # "paid" | "past_due" | …

# Fire a real, signed webhook at your registered endpoints.
nombaone.sandbox.simulate_webhook(type="invoice.payment_failed")
```

These methods raise locally (before any network call) if used with a live key.

## Money is integer kobo

Every amount in the API is an **integer in kobo**: `₦1.00 == 100`. `250_000` is ₦2,500 — not ₦250,000. No floats, no decimal strings, `currency` is always `"NGN"`. Multiply naira by 100 exactly once, at the edge of your system; every money field is suffixed `_in_kobo` so a mixup is hard to type.

## Pagination

Every `list()` works three ways:

```python
# One page.
page = nombaone.invoices.list(status="open", limit=50)
page.data
page.pagination.has_more
page.pagination.next_cursor

# Manual paging (cursor threaded for you).
if page.has_next_page():
    nxt = page.next_page()

# Or let the SDK walk every page.
for invoice in nombaone.invoices.list(status="open"):
    ...  # every item across every page

# Async:
async for invoice in await nombaone.invoices.list(status="open"):
    ...
```

## Errors are a feature

Failures raise typed exceptions carrying everything the API said — the stable `code` to branch on, a `hint` telling you exactly what to do next, a `doc_url` into the error reference, per-field details on validation failures, and the `request_id` to quote to support:

```python
from nombaone import NotFoundError, RateLimitError, ValidationError

try:
    nombaone.subscriptions.create(customer_id=cid, price_id=pid)
except ValidationError as err:
    print(err.fields)      # {"payment_method_id": ["..."]}
except RateLimitError as err:
    print(err.retry_after)  # seconds
except NotFoundError as err:
    print(err.code)         # "CUSTOMER_NOT_FOUND"
```

| Status | Class                              | Notes                                     |
| ------ | ---------------------------------- | ----------------------------------------- |
| 400    | `BadRequestError`                  | malformed request                         |
| 401    | `AuthenticationError`              | missing/invalid/wrong-environment key     |
| 403    | `PermissionDeniedError`            | missing scope, foreign resource           |
| 404    | `NotFoundError`                    | wrong id or wrong environment             |
| 409    | `ConflictError`                    | state conflicts, idempotency reuse        |
| 422    | `ValidationError`                  | `err.fields` has the per-field messages   |
| 429    | `RateLimitError`                   | `retry_after`, `limit`, `remaining`       |
| 5xx    | `ServerError`                      | safe to retry (the SDK already did)       |
| —      | `APIConnectionError` / `APITimeoutError` | transport-level                     |

All derive from `NombaoneError`, so `except NombaoneError` catches anything the SDK raises.

## Idempotency & retries

The SDK auto-generates an `Idempotency-Key` for every POST and **reuses it across its automatic retries** (network failures, timeouts, 408/429/5xx — 2 retries by default, honoring `Retry-After`), so a blip can never double-charge. Pass your own key when the operation must stay idempotent across _process_ restarts:

```python
from nombaone import RequestOptions

nombaone.settlements.create_payout(
    amount_in_kobo=5_000_000, bank_code="058", account_number="0123456789",
    options=RequestOptions(idempotency_key=f"payout-{my_payout.id}"),  # ⚠ doubles as the payout's durable merchantTxRef
)
```

Every method also accepts `options=RequestOptions(timeout=…, max_retries=…, headers=…)`, and every returned object carries `obj.last_response` (`request_id`, `status_code`, `headers`, `http_response`).

## Webhooks

Verify before you parse, and dedupe on the event id — delivery is at-least-once, never exactly-once. Verification needs only the signing secret (no API key):

```python
from flask import Flask, request
from nombaone import webhooks  # standalone helper

app = Flask(__name__)

@app.post("/nombaone/webhooks")
def hook():
    event = webhooks.construct_event(
        request.get_data(),  # the RAW body — never re-serialize
        request.headers.get("x-nombaone-signature", ""),
        os.environ["NOMBAONE_WEBHOOK_SECRET"],  # shown once when you created the endpoint
    )

    if already_processed(event.event.id):  # at-least-once ⇒ dedupe on event.event.id
        return "", 200

    if event.type == "invoice.paid":
        unlock(event.data["reference"])
    elif event.type == "invoice.action_required":
        send(event.data["checkoutLink"])
    elif event.type == "invoice.payment_failed":
        note(event.data["reason"])
    return "", 200  # respond 2xx fast, work async
```

**Feed it the raw request body** (`request.get_data()` in Flask, `await request.body()` in FastAPI, `request.body` in Django) — `json.loads` + re-serialization changes bytes and breaks the signature. `construct_event` checks the `X-Nombaone-Signature` (`t=<unix>,v1=<hex>`, HMAC-SHA256 over `` f"{t}.{body}" ``) in constant time, rejects stale timestamps (300s tolerance, configurable), and returns a parsed `WebhookEvent`. `webhooks.generate_test_header(...)` lets you unit-test your handler. Manage endpoints via `nombaone.webhook_endpoints` (create/rotate return the secret **exactly once**).

## The full surface

`customers` (+credit, discount) · `plans` (+nested `prices`) · `prices` · `subscriptions` (pause/resume/cancel/resubscribe/change, `schedule`, `dunning`, upcoming invoice, events) · `invoices` · `coupons` · `payment_methods` (hosted-checkout cards, virtual accounts) · `mandates` (NIBSS direct debit) · `settlements` (escrow, refunds, payouts) · `webhook_endpoints` (+deliveries, replay) · `events` (+catalog) · `organization` (+billing policy) · `metrics` · `sandbox` — every operation in the [API reference](https://docs.nombaone.xyz), 1:1.

Worth knowing:

- **Mandates are asynchronous.** They start `consent_pending` and activate when the customer's bank confirms — listen for `payment_method.updated`, don't poll, don't charge early.
- **Bank transfer is a push rail.** `payment_methods.create_virtual_account` issues a NUBAN; collection completes when the transfer arrives and reconciles.
- **`past_due` is not canceled.** Read `subscriptions.dunning.retrieve()` and honor `grace_access_until` before cutting anyone off.

## Configuration

```python
Nombaone(
    api_key,            # or NOMBAONE_API_KEY
    base_url=...,       # override the derived host
    timeout=30.0,       # per-attempt seconds
    max_retries=2,      # automatic retry budget
    default_headers=...,# sent on every request
    http_client=...,    # bring your own httpx.Client (tests, proxies)
)
```

## Examples & development

Runnable scripts live in [`examples/`](examples) — quickstart, pagination, the subscription lifecycle, a webhook receiver, and a dunning rehearsal with the test clock. To develop the SDK itself:

```bash
uv sync --extra dev
uv run ruff check . && uv run mypy && uv run pytest    # lint, type-check, unit + conformance
NOMBAONE_INTEGRATION=1 NOMBAONE_API_KEY=nbo_sandbox_… uv run pytest tests/integration  # live suite
```

The conformance suite guards `spec/openapi.json`; refresh it with `curl -s <host>/v1/openapi.json -o spec/openapi.json`.

## Requirements & versioning

Python ≥ 3.9. Semantic versioning; the API itself is versioned at `/v1` and additive changes never break you. MIT licensed.
