Metadata-Version: 2.4
Name: hrpay
Version: 0.1.0
Summary: Official Python SDK for HR-Skills Pay — Mobile Money, Wallet, Airtime, Bills, Payroll, Virtual Cards and more.
Project-URL: Homepage, https://docs.hrskills-pay.com/sdk/python
Project-URL: Documentation, https://docs.hrskills-pay.com/sdk/python
Project-URL: Repository, https://github.com/hrskills/pay-python-sdk
Project-URL: Issues, https://github.com/hrskills/pay-python-sdk/issues
Author-email: HR-Skills Pay <dev@hrskills-pay.com>
License: MIT License
        
        Copyright (c) 2026 HR-Skills Pay
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: airtime,cameroon,fintech,hrskills,hrskills-pay,mobile-money,mtn,orange,payment,payroll,sdk,wallet
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
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 :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# HR-Skills Pay — Python SDK

> Official Python SDK for [HR-Skills Pay](https://hrskills-pay.com) — Mobile Money, wallets, airtime, data, bills, payroll, virtual cards and payment links.

B2B payment infrastructure for Africa — **16 countries**: Cameroon, Côte d'Ivoire, Senegal, Gabon, DR Congo, Mali, Burkina Faso, Togo, Benin, Guinea, Gambia and more.

|                 |                                                                    |
| --------------- | ------------------------------------------------------------------ |
| **Base URL**    | `https://api.hrskills-pay.com`                                     |
| **Version**     | v1 · REST JSON                                                     |
| **Python**      | 3.10+                                                               |
| **Easy mode**   | `hrpay.token(...)` → `collect` / `send` / `check`                 |
| **Full API**    | `HRPayClient` and `AsyncHRPayClient`, identical surface            |
| **Sandbox**     | Amount **even → SUCCESS** · **odd → FAILED**                       |

---

## Table of contents

1. [Installation](#installation)
2. [Quick start — the easy way](#quick-start--the-easy-way)
3. [Sandbox vs production](#sandbox-vs-production)
4. [Checking status (no blocking)](#checking-status-no-blocking)
5. [Operators by country](#operators-by-country)
6. [Async](#async)
7. [The full client](#the-full-client)
8. [Authentication](#authentication)
9. [Cash-In — collect Mobile Money](#cash-in--collect-mobile-money)
10. [Cash-Out — send funds](#cash-out--send-funds)
11. [Statuses &amp; transactions](#statuses--transactions)
12. [Wallet](#wallet)
13. [VAS — airtime, data, bills](#vas--airtime-data-bills)
14. [Commissions](#commissions)
15. [Payroll](#payroll)
16. [Virtual cards](#virtual-cards)
17. [Payment links](#payment-links)
18. [Webhooks](#webhooks)
19. [Errors](#errors)
20. [Configuration &amp; resilience](#configuration--resilience)
21. [Idempotency](#idempotency)

---

## Installation

```bash
pip install hrpay
```

The only runtime dependencies are [`httpx`](https://www.python-httpx.org/) (transport) and [`pydantic`](https://docs.pydantic.dev/) v2 (validation and typed models).

---

## Quick start — the easy way

Two steps. **①** Exchange your keys for a token. **②** Act off that token — nothing ever blocks.

```python
import hrpay

# ① Get a token. The exchange happens here, so bad keys fail right away.
tk = hrpay.token("hrsk_pk_test_...", "hrsk_sk_test_...")
print(tk.environment)          # "TEST"

# ② Collect a payment. Returns immediately with the server's response.
tx = tk.collect(phone="237655500393", amount=5000, operator="orange")
print(tx.reference, tx.status)  # "TX123", "PENDING"  ← never blocks

# Send money out — same shape, same immediate return.
out = tk.send(phone="237690000000", amount=10000, operator="mtn")

# Check the outcome whenever you want (see the next section).
latest = tk.check(tx.reference)
print(latest.status)            # "SUCCESS" / "FAILED" / "PENDING" …
```

- **Keys** can be omitted and read from `$HRPAY_PUBLIC_KEY` / `$HRPAY_SECRET_KEY`.
- **`operator`** accepts a plain string (`"orange"`, `"mtn"`) or `hrpay.Operator.ORANGE`.
- **`country`** defaults to `CM`; the currency is derived from it (XAF, XOF, …).
- `collect` / `send` return the raw, typed server response (`reference`, `status`, `fee`, `net_amount`, …). Fees come straight from the response — **rates vary per merchant, never recompute them.**
- The token auto-refreshes before it expires; `tk.value` is the raw string, `tk.merchant_id` / `tk.environment` come from the exchange.
- Close it when done — `tk.close()`, or use `with hrpay.token(...) as tk:`.

Everything the full client can do is still reachable from the token: `tk.wallet`, `tk.bills`, `tk.payroll`, `tk.client`, … See [The full client](#the-full-client).

---

## Sandbox vs production

> ⚠️ **The single most important thing to understand.** Sandbox and production share **exactly the same URL** (`https://api.hrskills-pay.com`). The **API key** selects the environment, not the host. There is **no** `/sandbox` path.

|                   | SANDBOX · TEST                              | PRODUCTION · LIVE                              |
| ----------------- | ------------------------------------------- | --------------------------------------------- |
| **Keys**          | `hrsk_pk_test_...` / `hrsk_sk_test_...`     | `hrsk_pk_live_...` / `hrsk_sk_live_...`       |
| **URL**           | `https://api.hrskills-pay.com`              | `https://api.hrskills-pay.com` (identical)    |
| **Payments**      | No real payment                             | Real MTN / Orange payments                    |
| **Prerequisite**  | None                                        | **Approved KYC** (else `403 KYC_NOT_APPROVED`)|
| **Outcome**       | Driven by **amount parity**                 | Driven by the real customer                   |

### 🎲 The parity rule in sandbox

In sandbox, the **final** status is determined by the amount's parity:

| Amount                        | Final status   | Use for                    |
| ----------------------------- | -------------- | -------------------------- |
| **Even** (5000, 1000, 200…)   | `SUCCESS` ✅   | Testing the happy path     |
| **Odd** (5001, 999, 301…)     | `FAILED` ❌    | Testing failure handling   |

```python
tk = hrpay.token("hrsk_pk_test_...", "hrsk_sk_test_...")

# Force SUCCESS: even amount
ok = tk.collect(phone="237655500393", amount=5000, operator="orange")

# Force FAILED: odd amount
ko = tk.collect(phone="237655500393", amount=5001, operator="orange")
```

> ℹ️ The initial status is **always** `PENDING`. Parity decides the **final** status, which you read with `tk.check(reference)` or via a webhook. The minimum amount is **100**, so `101` is the smallest failing amount and `100` the smallest succeeding one.

### Switching sandbox → production

No code change. Only the keys differ:

```python
# Sandbox
tk = hrpay.token(os.environ["HRPAY_PK_TEST"], os.environ["HRPAY_SK_TEST"])

# Production — same calls, same URLs
tk = hrpay.token(os.environ["HRPAY_PK_LIVE"], os.environ["HRPAY_SK_LIVE"])
```

---

## Checking status (no blocking)

Mobile Money settles asynchronously: `collect` and `send` come back `PENDING` immediately. You learn the outcome two ways — **prefer webhooks; fall back to `check`.**

**`tk.check(reference)`** — one call, never blocks. Ask for the current status whenever it suits you: right after a payment, on a timer, or when a webhook nudges you.

```python
tx = tk.collect(phone="237655500393", amount=5000, operator="orange")

# …some time later — a poll, a cron, a button press:
latest = tk.check(tx.reference)
if latest.succeeded:
    fulfil_order(tx.reference)
elif latest.status == "FAILED":
    notify_customer(tx.reference)
# still "PENDING"? just check again later.
```

`check` returns the server's current record (`status`, `amount`, `fees`, `updated_at`, …). It does **not** loop or wait — you stay in control of timing, which matters when a webhook is delayed or hasn't arrived.

> Need a blocking helper for a script or test? The full client still has one: `tk.client.transactions.poll(reference)` waits until the transaction reaches a terminal state.

---

## Operators by country

Which Mobile Money operators can you use where? This ships as built-in reference data — no network call — so you can populate a dropdown or validate input before sending.

```python
# One country → its operators
hrpay.operators_for_country("CM")
# [Operator.MTN, Operator.ORANGE, Operator.CAMTEL, Operator.NEXTTEL]

# From a token, same idea
tk.operators("SN")     # [Operator.ORANGE, Operator.FREE, Operator.WAVE, Operator.EXPRESSO]

# Full overview: country, name, currency, operators
for info in hrpay.operators_by_country():
    ops = ", ".join(op.value for op in info.operators)
    print(f"{info.name} ({info.country.value}, {info.currency.value}): {ops}")
```

```text
Cameroun        (CM, XAF) : MTN, ORANGE, CAMTEL, NEXTTEL
Sénégal         (SN, XOF) : ORANGE, FREE, WAVE, EXPRESSO
Côte d'Ivoire   (CI, XOF) : ORANGE, MTN, MOOV, WAVE
Gabon           (GA, XAF) : AIRTEL, MOOV
RD Congo        (CD, CDF) : AIRTEL, ORANGE, MPESA, AFRIMONEY
Mali            (ML, XOF) : ORANGE, MOOV
Burkina Faso    (BF, XOF) : ORANGE, MOOV, CORIS
Togo            (TG, XOF) : TMONEY, FLOOZ
Bénin           (BJ, XOF) : MTN, MOOV
Guinée          (GN, GNF) : ORANGE, MTN
Gambie          (GM, GMD) : AFRIMONEY, QMONEY
```

> This table is a convenience. The API stays the source of truth: an operator it doesn't support for a country is rejected at request time with `422 OPERATOR_NOT_AVAILABLE`.

---

## Async

Prefer async? `hrpay.atoken(...)` returns an `AsyncToken` with the same verbs — just `await` them.

```python
import asyncio
import hrpay

async def main():
    async with await hrpay.atoken() as tk:       # keys from the environment
        tx = await tk.collect(phone="237655500393", amount=5000, operator="orange")
        latest = await tk.check(tx.reference)
        print(latest.status)

asyncio.run(main())
```

`operators(...)` and webhook verification are CPU-only and stay synchronous on both — see [Webhooks](#webhooks).

---

## The full client

The token is a friendly layer over `HRPayClient`, which exposes every endpoint grouped by resource. Reach it directly, or via `tk.client`.

```python
import hrpay

with hrpay.HRPayClient("hrsk_pk_test_...", "hrsk_sk_test_...") as client:
    tx = client.cash_in.mobile_money(
        phone_number="237655500393",
        operator=hrpay.Operator.ORANGE,
        amount=5000,
        country=hrpay.Country.CM,   # currency defaults to XAF
    )
    print(tx.reference, tx.status)  # PENDING

    # Blocking helper, for scripts/tests where waiting is fine:
    settled = client.transactions.poll(tx.reference)
    print(settled.status)           # SUCCESS
```

The rest of this README uses the full client to document each resource; every one is also reachable from a token (`tk.wallet`, `tk.bills`, `tk.payroll`, …).

---

## Async client

`AsyncHRPayClient` mirrors the sync client method-for-method; every request is a coroutine.

```python
import asyncio
import hrpay

async def main():
    async with hrpay.AsyncHRPayClient() as client:   # keys from the environment
        tx = await client.cash_in.mobile_money(
            phone_number="237655500393",
            operator=hrpay.Operator.ORANGE,
            amount=5000,
        )
        settled = await client.transactions.poll(tx.reference)
        print(settled.status)

asyncio.run(main())
```

Webhook verification is CPU-only and stays synchronous on both clients — see [Webhooks](#webhooks).

---

## Authentication

Every request carries two credentials: the public key (a bearer token) and a short-lived **transaction token** in `X-Transaction-Token`. The SDK fetches, caches and refreshes that token for you — you rarely touch `client.auth`.

```python
client.auth.get_token()        # cached token, minted on first use
client.auth.refresh()          # force a new one
client.auth.is_token_valid()   # bool
client.auth.merchant_id        # from the last token exchange
client.auth.environment        # "LIVE" or "TEST"
```

The same is exposed on a `token(...)` handle, more directly:

```python
tk.value          # the raw transaction token (auto-refreshed)
tk.refresh()      # force a new one
tk.is_valid       # bool
tk.merchant_id    # from the exchange
tk.environment    # "LIVE" or "TEST"
```

Share one token across processes with a custom [token cache](#configuration--resilience).

---

## Cash-In — collect Mobile Money

```python
tx = client.cash_in.mobile_money(
    phone_number="237655500393",
    operator=hrpay.Operator.MTN,
    amount=5000,
    country=hrpay.Country.CM,
    description="Order #1234",
    metadata={"order_id": "1234"},
    idempotency_key="order-1234",   # optional, makes a retry safe
)
```

The returned `CashInResponse` starts `PENDING`: the customer still has to confirm the prompt on their handset. Read the fees the API actually charged from `tx.fee` and `tx.fee_percent` — **rates vary per merchant, so never recompute them locally.**

Funds credited by a Cash-In sit in `balance.held` for 48 hours before becoming available.

---

## Cash-Out — send funds

```python
tx = client.cash_out.mobile_money(
    phone_number="237655500393",
    operator=hrpay.Operator.ORANGE,
    amount=10000,
    idempotency_key="payout-9001",   # strongly recommended — see Idempotency
)
```

The amount plus its fee is debited from `balance.available`, so a Cash-In still inside its 48h hold cannot fund it.

---

## Statuses &amp; transactions

Statuses: `PENDING` → `SUCCESS` / `FAILED` / `REFUNDED`, or `HOLD` (AML review in progress — **not** terminal).

```python
client.transactions.status(reference)   # quick status
client.transactions.get(reference)       # full record
page = client.transactions.list(status="SUCCESS", limit=50)
for tx in page:
    print(tx.reference, tx.amount)

# Block until terminal (SUCCESS / FAILED / REFUNDED)
settled = client.transactions.poll(
    reference,
    interval=3.0,
    timeout=600.0,
    on_status=lambda status, attempt: print(attempt, status),
)
```

`poll` raises `APIError` with code `POLL_TIMEOUT` or `POLL_MAX_ATTEMPTS_REACHED` if the transaction never settles. Prefer a webhook where you can; poll where you can't.

---

## Wallet

```python
bal = client.wallet.balance()
print(bal.balance.available, bal.balance.held, bal.currency)
print(bal.is_frozen, bal.limits)

page = client.wallet.movements(limit=100)
for m in page:
    print(m.type, m.amount, m.balance_after)
```

---

## VAS — airtime, data, bills

```python
# Airtime — one number, or up to 500 at once
client.airtime.recharge(operator=hrpay.Operator.MTN, phone="237650000000", amount=1000)
batch = client.airtime.batch([
    {"phone": "237650000000", "operator": "MTN", "amount": 1000},
    {"phone": "237690000000", "operator": "ORANGE", "amount": 500},
])
for item in batch.items:      # a batch can be partially successful
    print(item.phone, item.status)

# Data
client.data.packages(operator="MTN")
client.data.send(operator=hrpay.Operator.MTN, phone="237650000000", amount=1000)

# Bills, grouped by biller
client.bills.eneo.invoice("123456789")
client.bills.eneo.prepaid(meter="123456789", amount=5000)      # response has the recharge token
client.bills.eneo.postpaid(meter="123456789", amount=5000)
client.bills.camwater.pay(meter="000111222", amount=8000)
client.bills.canal_plus.pay(decoder_number="12345678", amount=15000)
client.bills.customs.pay(declaration_ref="DEC-2026-001", amount=250000)
```

---

## Commissions

Reseller commissions on VAS transactions. **Rates are per-merchant — fetch them, never assume them.**

```python
client.commissions.rates()                       # authoritative rate per service
client.commissions.history(service="AIRTIME")
client.commissions.summary(from_="2026-01-01", to="2026-01-31")
```

---

## Payroll

Mass disbursement in two steps: import a draft, then execute it.

```python
draft = client.payroll.import_(
    label="January salaries",
    currency=hrpay.Currency.XAF,
    recipients=[
        {"phone_number": "237650000000", "operator": "MTN", "amount": 150000, "name": "Awa"},
        {"phone_number": "237690000000", "operator": "ORANGE", "amount": 200000, "name": "Ben"},
    ],
)
client.payroll.execute(draft.batch_id, idempotency_key=f"payroll-{draft.batch_id}")
client.payroll.status(draft.batch_id)
report = client.payroll.report(draft.batch_id)   # per-recipient outcomes
```

You can also import via `file_base64=` or `csv_data=` instead of `recipients=`.

---

## Virtual cards

```python
card = client.cards.create(label="Ads budget", currency=hrpay.Currency.USD, amount=100)
client.cards.topup(card.id, 50)
client.cards.freeze(card.id)
client.cards.unfreeze(card.id)
client.cards.cancel(card.id)     # permanent
```

---

## Payment links

```python
link = client.payment_links.create(amount=25000, description="Invoice #42")
print(link.url)
client.payment_links.list(limit=20)
```

---

## Webhooks

Always verify the signature against the **raw** request body before trusting a delivery. `construct_event` does both steps and raises `WebhookSignatureError` on a bad signature or invalid JSON.

```python
# Flask example
@app.post("/webhooks/hrpay")
def hrpay_webhook():
    raw = request.get_data()                       # raw bytes, not a re-parsed dict
    sig = request.headers["X-Hub-Signature"]
    try:
        event = client.webhooks.construct_event(raw, sig, os.environ["HRPAY_WEBHOOK_SECRET"])
    except hrpay.WebhookSignatureError:
        return "", 400

    if event.type_value == "payment.succeeded":
        ...
    return "", 200
```

`construct_event` / `verify_signature` are synchronous static methods, so they work identically on `AsyncHRPayClient`.

---

## Errors

Every failure derives from `hrpay.HRPayError`. The machine-readable code is on `error.code`.

```python
try:
    client.cash_out.mobile_money(phone_number="237650000000", operator="MTN", amount=999999)
except hrpay.WalletError as e:               # 402 — insufficient balance / frozen
    print(e.code, e.details)
except hrpay.ValidationError as e:           # 400 / 422 — includes e.issues
    print(e.issues)
except hrpay.RateLimitError as e:            # 429
    print(e.retry_after_seconds)
except hrpay.HRPayError as e:                # catch-all
    print(e)
```

| Exception                  | HTTP        | Meaning                                    |
| -------------------------- | ----------- | ------------------------------------------ |
| `AuthenticationError`      | 401 / 403   | Bad credentials, or unmet KYC gate         |
| `WalletError`              | 402         | Insufficient balance, or frozen wallet     |
| `ValidationError`          | 400 / 422   | Rejected payload (`.issues`)               |
| `ConflictError`            | 409         | Idempotency key reused with a new payload  |
| `RateLimitError`           | 429         | Too many requests (`.retry_after_seconds`) |
| `NetworkError`             | —           | DNS / TCP / TLS failure                    |
| `TimeoutError`             | —           | Exceeded the configured timeout            |
| `CircuitBreakerOpenError`  | —           | Breaker open, request blocked              |
| `APIError`                 | other       | Any other API error                        |

---

## Configuration &amp; resilience

```python
client = hrpay.HRPayClient(
    "hrsk_pk_test_...", "hrsk_sk_test_...",
    timeout=30.0,                       # seconds
    max_retries=3,                      # retries 429 & 5xx with backoff
    throttle=0.2,                       # min 200ms between requests
    logger=hrpay.LoggerConfig.all(),    # logs requests/responses/errors, keys redacted
    failure_threshold=5,                # circuit breaker
    reset_timeout=15.0,
    on_response=lambda r: print(r.status_code),
)
```

**Built-in resilience**, applied to every call:

- **Automatic retries** on 429 and 5xx, with exponential backoff (honouring `Retry-After`).
- **Circuit breaker** — after `failure_threshold` consecutive system failures the breaker opens and fails fast for `reset_timeout` seconds, then lets one probe through.
- **Auto token refresh** — the transaction token is minted, cached and refreshed a minute before expiry.
- **Secret redaction** — API keys are masked in every log line.

Persist tokens across restarts, or share them across workers:

```python
from hrpay import FileTokenCache
client = hrpay.HRPayClient(..., token_cache=FileTokenCache("~/.hrpay/tokens.json"))
```

Implement the `TokenCache` (or `AsyncTokenCache`) protocol for a Redis-backed cache, etc.

---

## Idempotency

Any mutating request accepts an `idempotency_key`. The SDK auto-generates one per request; **pass your own for anything that moves money** so a network retry can't charge twice. A caller-supplied key always wins.

```python
client.cash_out.mobile_money(
    phone_number="237655500393", operator="ORANGE", amount=10000,
    idempotency_key="payout-9001",
)
```

Reusing a key with a *different* payload raises `ConflictError` (409).

---

## License

MIT © HR-Skills Pay
