Metadata-Version: 2.4
Name: surepay
Version: 0.1.0
Summary: Official Python SDK for the SurePay Payment Gateway API
Project-URL: Homepage, https://github.com/surepay-one/surepay-python-sdk
Project-URL: Repository, https://github.com/surepay-one/surepay-python-sdk
License: MIT
License-File: LICENSE
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# surepay-python-sdk

Official Python client library for the [SurePay](https://surepay.one) Merchant API.

[![CI](https://github.com/surepay-one/surepay-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/surepay-one/surepay-python-sdk/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/surepay)](https://pypi.org/project/surepay/)

## Requirements

- Python 3.9+
- Zero non-stdlib dependencies

## Install

```bash
pip install surepay
```

## Quick start

```python
import os
from surepay import SurePay, SurePayException

client = SurePay.builder(
    os.environ["SUREPAY_API_KEY"],    # tpay_live_... or tpay_test_...
    os.environ["SUREPAY_API_SECRET"], # tpay_sec_... — enables auto HMAC signing
).build()

# Check wallet balance
balance = client.balance.get()
print(f"Available: {balance.available} VND")

# Create a deposit order (thu hộ)
deposit = client.deposits.create(
    amount=100_000,
    request_id="ORD-20260610-001",
)
print("Checkout URL:", deposit.checkout_url)

# Create a payout (chi hộ)
try:
    payout = client.payouts.create(
        amount=500_000,
        bank_code="VCB",
        bank_account="1234567890",
        account_name="NGUYEN VAN A",
        memo="Salary June 2026",
    )
    print("Payout ID:", payout.id)
except SurePayException as e:
    if e.is_insufficient_balance():
        print("Not enough balance — top up first")
```

## Configuration

```python
client = SurePay.builder(api_key, api_secret) \
    .base_url("https://api.surepay.one/merchant/v1") \
    .timeout(15) \
    .max_retries(3) \
    .build()
```

Or use direct instantiation:

```python
client = SurePay(
    api_key=os.environ["SUREPAY_API_KEY"],
    api_secret=os.environ["SUREPAY_API_SECRET"],
)
```

| Option | Default | Description |
|--------|---------|-------------|
| `base_url(str)` | `https://api.surepay.one/merchant/v1` | Override base URL for local dev or staging |
| `timeout(int)` | `30` | HTTP request timeout in seconds |
| `max_retries(int)` | `3` | Retry attempts on 5xx and network errors |

## Authentication

Every request requires an API key sent as an `X-API-Key` header. When `api_secret` is provided to `builder()`, every outgoing request is automatically signed with HMAC-SHA256 — the `X-Signature` and `X-Timestamp` headers are attached with no extra code needed.

## API reference

### Balance

#### `client.balance.get()`

Get current wallet balance. **Requires:** `balance:read` scope.

```python
balance = client.balance.get()
# balance.balance   — total wallet balance in VND
# balance.hold      — reserved for in-flight transactions
# balance.available — balance.balance - balance.hold
# balance.currency  — always "VND"
```

---

### Deposits

#### `client.deposits.list(params)`

Paginated list of deposit (thu hộ) orders. **Requires:** `deposits:read` scope.

```python
from surepay.params import DepositsListParams

result = client.deposits.list(
    DepositsListParams(
        page=1,
        page_size=20,
        status="success",        # pending|processing|success|failed|expired|cancelled
        from_date="2026-06-01",  # YYYY-MM-DD
        to_date="2026-06-30",
    )
)
# result.items       — list[Deposit]
# result.total       — total matching records
# result.total_pages — total pages
```

#### `client.deposits.create(**kwargs)`

Create a new deposit order. Returns a `checkout_url` (redirect) and `qr_code` (VietQR). **Requires:** `deposits:write` scope.

```python
deposit = client.deposits.create(
    amount=100_000,                    # VND, required
    request_id="ORD-20260610-001",     # your order ID — optional, for idempotency
    # Chính chủ verification — all optional:
    sender_bank_id="970436",
    sender_bank_name="Vietcombank",
    sender_account="1234567890",
    sender_name="NGUYEN VAN A",
)
print(deposit.checkout_url)
print(deposit.qr_code)
```

**Response fields:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | str | SurePay transaction UUID |
| `request_id` | str | Your order ID |
| `amount` | int | Amount in VND |
| `status` | str | pending, processing, success, failed, expired, cancelled |
| `checkout_url` | str | Redirect URL for payer |
| `qr_code` | str | VietQR data string |
| `created_at` | str | ISO 8601 timestamp |
| `updated_at` | str | ISO 8601 timestamp |

#### `client.deposits.get(id)`

Fetch a single deposit order by UUID. **Requires:** `deposits:read` scope.

```python
deposit = client.deposits.get("uuid-here")
# deposit.status: 'pending' | 'success' | ...
```

---

### Payouts

#### `client.payouts.list(params)`

Paginated list of payout (chi hộ) orders. **Requires:** `payouts:read` scope.

```python
from surepay.params import PayoutsListParams

result = client.payouts.list(
    PayoutsListParams(
        page=1,
        page_size=20,
        status="success",        # pending|processing|success|failed
        from_date="2026-06-01",
        to_date="2026-06-30",
    )
)
```

#### `client.payouts.create(**kwargs)`

Initiate a payout bank transfer. Funds are deducted from your wallet immediately on success. **Requires:** `payouts:write` scope.

> Payouts are irreversible once status moves past `pending`. Verify bank details with `client.bank_inquiry.verify()` first.

```python
payout = client.payouts.create(
    amount=500_000,              # VND, required
    bank_code="VCB",             # required (VCB, MB, TCB, ACB, ...)
    bank_account="1234567890",   # required
    account_name="NGUYEN VAN A", # required — use UPPERCASE
    memo="Salary June",          # required — transfer memo
    bank_name="Vietcombank",     # optional
)
```

#### `client.payouts.get(id)`

Fetch a single payout by UUID. **Requires:** `payouts:read` scope.

```python
payout = client.payouts.get("uuid-here")
# payout.status: 'pending' | 'success' | ...
```

---

### Bank Inquiry

#### `client.bank_inquiry.verify(bank_code, bank_account)`

Look up the account holder name for a bank account. Call this before creating a payout to confirm the recipient. **Requires:** `payouts:read` scope.

```python
result = client.bank_inquiry.verify(
    bank_code="VCB",
    bank_account="1234567890",
)
print("Account name:", result.account_name)
```

---

## Idempotency

Pass an `idempotency_key` to any `create()` method. The key is forwarded as an `Idempotency-Key` header — safe to retry on network errors without risk of duplicate transactions.

```python
deposit = client.deposits.create(
    amount=100_000,
    request_id="ORD-001",
    idempotency_key="ORD-001",
)
```

## Webhook verification

Every inbound webhook event from SurePay is HMAC-signed. Pass the **raw** request body bytes (before any JSON parsing) to `client.webhooks.verify()`:

```python
# FastAPI example
from fastapi import FastAPI, Request, Response
import json

app = FastAPI()

@app.post("/webhook/surepay")
async def handle_webhook(request: Request):
    body = await request.body()

    if not client.webhooks.verify(body):
        return Response(status_code=401)

    event = json.loads(body)

    match event.get("event"):
        case "deposit.success" | "deposit.failed":
            handle_deposit(event)
        case "payout.success" | "payout.failed":
            handle_payout(event)

    return Response(status_code=200)
```

Or pass the `X-Surepay-Signature` header value explicitly:

```python
sig   = request.headers.get("X-Surepay-Signature")
valid = client.webhooks.verify(body, sig)
```

## Error handling

```python
from surepay import SurePayException

try:
    payout = client.payouts.create(...)
except SurePayException as e:
    # Convenience helpers
    if e.is_not_found():             # 404 not_found
        pass
    if e.is_rate_limit():            # 429 rate_limit_exceeded
        pass
    if e.is_insufficient_balance():  # 422 insufficient_balance
        pass
    if e.is_duplicate():             # 409 duplicate_request
        pass

    # Full details
    print(f"HTTP {e.http_status}  code={e.code}  {e.message}")
```

**Error codes:**

| HTTP | `code` | Meaning |
|------|--------|---------|
| 400 | `validation_error` | Invalid request body or parameters |
| 401 | `unauthorized` | Missing or invalid API key |
| 401 | `signature_invalid` | HMAC signature failed or timestamp > 5 min |
| 403 | `permission_denied` | API key lacks required scope |
| 403 | `ip_not_allowed` | Request IP not in allowlist |
| 404 | `not_found` | Resource not found |
| 409 | `duplicate_request` | Idempotency key conflict |
| 422 | `insufficient_balance` | Top up wallet first |
| 422 | `invalid_state_transition` | Operation not allowed for current status |
| 429 | `rate_limit_exceeded` | Slow down — back off and retry |
| 500 | `internal_error` | Server error |

## HMAC signing

When `api_secret` is set, all requests are signed automatically. The signing algorithm for manual use:

```
body_hash   = sha256(body_bytes).hexdigest()
signing_str = f"{timestamp}\n{method}\n{path}\n{body_hash}"
signature   = "sha256=" + hmac.new(api_secret.encode(), signing_str.encode(), sha256).hexdigest()
```

Attach as headers: `X-Signature: <signature>` and `X-Timestamp: <unix_timestamp>`.

Signatures expire after **300 seconds** — generate per-request, never cache or reuse.

## License

MIT
