Metadata-Version: 2.4
Name: transaierp-payment
Version: 0.1.2
Summary: Server-side client for TransAI Payment Center V1
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# TransAI Payment SDK for Python

Requires Python 3.10+. Runtime uses only the standard library.
This is a **server-side** SDK, not a desktop/mobile/browser SDK.

## Install

```sh
python -m pip install transaierp-payment
```

The distribution is named `transaierp-payment`; the import is `transai_payment`.

## Configuration

Ask your Payment Center administrator for:

| Value | Purpose |
| --- | --- |
| Base URL | Trusted HTTPS origin or deployment prefix, without `/api/payment-center/v1` |
| API key | Application credential beginning with `pc_sandbox_` or `pc_live_` |
| Application ID | Expected `application_id` for orders and notifications |
| Webhook secret | Secret beginning with `whsec_`; this is not the API key |
| Notification URL | Your server endpoint, registered in the Payment Center |

The environment variable names below are examples for your application, not
settings automatically read by the SDK. Keep sandbox and live keys separate.
Your server must store the local business order, amount and intended buyer
before creating a payment order. These examples require real credentials and
a configured Payment Center; creating an order is a real server operation.

## Orders

```python
import os
from transai_payment import APIError, Client, CreateOrderRequest

client = Client(
    os.environ["PAYMENT_CENTER_BASE_URL"],
    os.environ["PAYMENT_CENTER_API_KEY"],
    timeout=15,
)

try:
    result = client.create_order(CreateOrderRequest(
        business_order_no=os.environ["BUSINESS_ORDER_NO"],
        title="Enterprise service",
        amount=9900,
        currency="CNY",
        buyer_reference="customer-123",
        metadata={"local_order_id": os.environ["BUSINESS_ORDER_NO"]},
    ))
except APIError as error:
    # Handle error.status and error.message without logging credentials.
    raise

checkout_url = result["checkout_url"]  # Return only this URL to your frontend.
order = client.get_order(result["order"]["id"])
same_order = client.find_order(os.environ["BUSINESS_ORDER_NO"])
```

The base URL is an HTTPS origin (or deployment prefix), without
`/api/payment-center/v1`, credentials, query, or fragment. Amounts are integer
CNY cents. A business order number identifies one immutable order; retry with
the same number and same fields after a network failure, not a new number.
HTTP redirects are disabled. API responses are limited to 1 MiB.
Network errors retain the standard library exception types; malformed responses
raise `ProtocolError`. There are no automatic retries.

## Notifications

Define this function in your server and invoke it from your framework's
notification route with the original request body and headers:

```python
import os
from transai_payment import WebhookError, verify_webhook

def authenticate_notification(
    raw_body: bytes,
    headers: dict[str, str | list[str]],
):
    # May raise WebhookError. The route must reject invalid notifications.
    event = verify_webhook(
        raw_body,
        headers,
        [os.environ["PAYMENT_CENTER_WEBHOOK_SECRET"]],
    )
    if event["order"]["application_id"] != os.environ["PAYMENT_CENTER_APPLICATION_ID"]:
        raise ValueError("Unexpected payment application")
    return event
```

`raw_body` must be the exact incoming bytes, before JSON parsing. Do not
serialize parsed JSON again. This function authenticates the event only; it
does not deliver products or write your database.

For a blocking binary request stream use
`read_webhook(stream, headers, secrets)`, which reads at most 64 KiB + 1.
Keep an equivalent request body limit in your web server. Preserve duplicate
header values so the SDK can reject them.

Verification checks HMAC-SHA256 over `<timestamp>.<raw-body>`, a +/-300 second
window, event ID, `payment.succeeded`, version `v1`, and paid CNY order fields.
Pass both old and new notification secrets during rotation.

Before granting entitlements, compare the application ID, business order number,
amount, and currency to your own stored order. Deduplicate both event ID and
business order within your fulfillment transaction. Only then return HTTP 2xx.
A repeated, already committed event should also receive 2xx. A browser return
URL is not proof of payment.

Deduplication is necessary even for simple paid orders without refunds:
the same success notification can arrive more than once. Your route must
reject invalid notifications, look up the local order and its intended owner,
verify application/amount/currency, and atomically mark it paid and grant the
entitlement once. Do not use notification metadata alone to choose a recipient.
Return 2xx only after committing; do not acknowledge a temporary database failure.

## API Reference

### `Client(base_url: str, api_key: str, *, timeout: float = 15)`

Creates a synchronous HTTP client. Appends `/api/payment-center/v1` to the
base URL and uses `Authorization: Bearer <api_key>`. `timeout` must be finite
and positive, in seconds; it is a socket timeout, not an overall workflow
deadline. Invalid configuration raises `ValueError`. Never include credentials,
a query or a fragment in the base URL. No client secret belongs in end-user
software. Run blocking requests outside an async application's event loop.

### `CreateOrderRequest`

A frozen dataclass. Validation occurs in `Client.create_order()`, not when the
dataclass is constructed.

| Field | Type / default | Constraint |
| --- | --- | --- |
| `business_order_no` | `str`, required | Nonblank, at most 128 UTF-8 bytes; stable local order number |
| `title` | `str`, required | Nonblank, at most 120 Python characters |
| `amount` | `int`, required | CNY cents, `1..100_000_000`; not bool, float or yuan |
| `currency` | `str`, `"CNY"` | Only `"CNY"` is accepted |
| `buyer_reference` | `str`, `""` | At most 128 UTF-8 bytes; your customer reference |
| `metadata` | `dict[str, str]`, `{}` | At most 20 entries and 4096 bytes after SDK JSON serialization |

Server-side business rules may impose additional restrictions.

### Order Methods

| Method | Purpose | Return |
| --- | --- | --- |
| `create_order(order: CreateOrderRequest)` | Create or retry the same immutable business order | `OrderResult` |
| `get_order(order_id: str)` | Fetch by Payment Center order ID | `OrderResult` |
| `find_order(business_order_no: str)` | Fetch by your local business order number | `OrderResult` |

All three return a dictionary containing `order` and `checkout_url`, not the
order alone. Use `result["order"]["status"]`, not `result.status`.
`get_order()` rejects blank IDs, `.` / `..`, and IDs containing `/ ? # % \`.
`find_order()` requires a nonblank number and URL-encodes the query.
Invalid arguments raise `ValueError`. Server failures raise `APIError` or
`ProtocolError`; missing orders do not return `None`.

After an ambiguous network failure, query the existing business order before
retrying. Reuse the same number and request fields, not a new order number.

### `Order`, `OrderResult`, `Event`

These are `TypedDict` definitions for ordinary dictionaries, not objects with
attribute access. Unknown server fields may remain in returned dictionaries.

`OrderResult` contains `order: Order` and `checkout_url: str`.

| `Order` field | Type | Meaning |
| --- | --- | --- |
| `id` | `str` | Payment Center order ID |
| `application_id` | `str` | Owning payment application |
| `business_order_no` | `str` | Your local order number |
| `title` | `str` | Product/service label |
| `amount` | `int` | CNY cents |
| `currency` | `str` | `"CNY"` |
| `metadata` | `dict[str, str]` or `None` | Application metadata |
| `status` | `str` | Server state; success notifications require `"paid"` |
| `expires_at` | `str` | Server-provided expiry timestamp |
| `created_at`, `updated_at` | `str` | Server-provided timestamps |
| `buyer_reference` | optional `str` | Customer reference |
| `review_reason` | optional `str` | Review explanation |
| `paid_at` | optional `str` or `None` | Payment timestamp |

| `Event` field | Type | Meaning |
| --- | --- | --- |
| `id` | `str` | Notification ID matching `X-Payment-Event-ID` |
| `type` | `str` | Only `"payment.succeeded"` is accepted |
| `version` | `str` | Currently `"v1"` |
| `created_at` | `str` | Server-provided event timestamp |
| `order` | `Order` | Paid order |

Runtime checks cover essential order identity, amount, currency, response
envelope and event identity/type/status, not every field in these typing
definitions. Use `.get()` for optional/display fields and validate additional
fields your own business relies on. Timestamp strings are not converted to
`datetime` objects.

### `verify_webhook(body, headers, secrets, *, now=None) -> Event`

| Argument | Type / default | Meaning |
| --- | --- | --- |
| `body` | `bytes`, required | Original HTTP body, at most 65536 bytes |
| `headers` | `Mapping[str, str \| list[str]]`, required | Case-insensitive names; preserve duplicate values |
| `secrets` | `Iterable[str]`, required | Accepted secrets; pass a list, not a single string |
| `now` | Unix seconds / `None` | Current system time by default; override only in controlled tests |

Required headers are `X-Payment-Timestamp`, `X-Payment-Signature` and
`X-Payment-Event-ID`, each exactly once. Verifies HMAC-SHA256 over
`<timestamp>.<raw-body>`, an inclusive +/-300-second window, event ID,
version/type and paid CNY order fields. Use seconds, not milliseconds.
Synchronize the server clock. Pass `[current_secret, previous_secret]` during
secret rotation. Invalid notifications raise `WebhookError`.

Successful verification does not prevent replay within the allowed window,
nor check your expected application ID or local order amount. Your route must
match those fields and deduplicate transactionally.

### `read_webhook(stream, headers, secrets, *, now=None) -> Event`

Reads at most 65537 bytes from a **blocking binary** stream and delegates to
`verify_webhook()`. Arguments, result and authentication errors are otherwise
the same. Stream read errors propagate. This is not an async reader adapter.

### Exceptions

| Exception | Meaning / action |
| --- | --- |
| `APIError` | HTTP/API failure; inspect `.status` (HTTP integer) and `.message` (string) |
| `ProtocolError` | `ValueError` subclass; malformed/oversized server response |
| `WebhookError` | `ValueError` subclass; unauthenticated or invalid notification |
| `ValueError` | Invalid configuration or order arguments |
| `urllib.error.URLError`, `OSError` | Network failure; reconcile the existing order before retrying |

`APIError.status` can be `200` if HTTP succeeded but the JSON envelope reports
failure. Do not expose internal error details or credentials to end users.

## Read Documentation After Installation

The full README is embedded in installed distribution metadata:

```powershell
python -m transai_payment
python -c "from importlib.metadata import metadata; print(metadata('transaierp-payment')['Description'])"
python -m pydoc transai_payment
```

Use `help(Client)`, `help(CreateOrderRequest)` and `help(verify_webhook)` in
Python or inspect their docstrings in your IDE.

## Test

Run from `payment-sdk/python`:

```sh
python -m pip install -e .
python -m unittest discover -s tests -v
```

Tests use `../testdata/webhook.json` from the source repository. Consumers
do not need this fixture to use the installed SDK.

V1 has no refund methods, new payment-provider implementations, or entitlement
logic. API keys and webhook secrets must never be shipped to end-user software.
