Metadata-Version: 2.5
Name: dominaite
Version: 0.1.1
Summary: Server-side Python client for the Dominaite merchant API: create hosted checkout sessions from your own backend.
License: MIT
License-File: LICENSE
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# dominaite-python

Server-side Python client for the Dominaite merchant API. One call from your backend opens a
hosted checkout session; a two-line script tag renders the payment widget on your page. Card
details go straight from your customer's browser into the payment widget - they never touch
your server, which keeps your PCI scope minimal (SAQ A).

Python 3.9+, standard library only. No `requests`, no framework, nothing to vendor.

## Install

The package name is `dominaite` on PyPI (verified free 2026-08-17; matches `import dominaite`,
the same pattern Stripe uses). It is **not published yet** - until it is, install from a checkout:

```bash
pip install /path/to/dominaite-python-sdk
# or, while you are working on the SDK itself:
pip install -e /path/to/dominaite-python-sdk
```

## Credentials

You get two values from the Dominaite dashboard, under **Online payments -> Website
integration**, when you create an API key. The secret is shown **once** - store both like
passwords:

- `dmk_...` - your API key id. Identifies you; not secret by itself.
- `dms_...` - your API secret. Server-side only: environment variable or a config file outside
  the web root. Never in a browser, never in git, never in logs.

Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock
on NTP - signatures older than 5 minutes are rejected.

## Quickstart against dev

Everything you need to go from nothing to a live session on the dev environment.

**1. Set your credentials.** Both come from the dashboard's Website-integration tab (dev
dashboard, dev key - a prod key will not authenticate against dev):

```bash
export DOMINAITE_KEY_ID='dmk_...'          # the key id shown on the tab
export DOMINAITE_SECRET='dms_...'          # the secret shown once at key creation
export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'
```

That base URL is the dev payments service. Production is
`https://api.dominaite.com/payments`, which is the SDK's default when you pass no `base_url`.

**2. Check your signing before you call anything.** This runs offline against the published
test vector and authenticates nothing, so it can never fail for credential reasons:

```bash
python -m pytest tests/test_signing.py
```

**3. Mint a session** (`mint.py`):

```python
import os

from dominaite import CheckoutRefusedError, DominaiteClient, TransportError

client = DominaiteClient(
    os.environ["DOMINAITE_KEY_ID"],
    os.environ["DOMINAITE_SECRET"],
    base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
)

try:
    session = client.create_checkout_session(
        amount=2500,                    # minor units: 2500 = 25.00 EUR
        currency="EUR",
        order_reference="order-1042",   # your own order id, shows up in your dashboard
        customer={
            # Pass everything you already know - prefilled fields are hidden from the
            # payer, so the checkout form stays short.
            "firstName": "Ana",
            "lastName": "Kirova",
            "email": "ana@example.com",
        },
        language="bg",                  # widget UI language
        theme="dark",
    )
except CheckoutRefusedError as refusal:
    # Machine-readable: refusal.error_code - see the exception docstring for the codes.
    raise SystemExit("Payment unavailable: " + refusal.error_code)
except TransportError:
    # Network blip - safe to retry with the same idempotency_key.
    raise SystemExit("Payment temporarily unavailable")

print(session["transactionId"], session["cashierKey"], session["cashierToken"])
```

```bash
python mint.py
```

A transaction id, cashier key and cashier token on stdout means the whole chain works: your
credentials, your clock, your signing, and the dev gateway.

**If it fails**, the error tells you which one:

| What you see | What is wrong |
|---|---|
| `AuthenticationError` + `INVALID_API_KEY` | Wrong or revoked key id, or a prod key against dev. |
| `AuthenticationError` + `INVALID_SIGNATURE` | Secret does not match the key id. |
| `AuthenticationError` + `TIMESTAMP_OUT_OF_RANGE` | Your machine's clock is more than 5 minutes off. |
| `AuthenticationError` + `IP_NOT_ALLOWED` | The key has an IP allowlist that does not include you. |
| `CheckoutRefusedError` | You authenticated fine; the gateway declined to open a session. |
| `TransportError` | Wrong base URL, or the service is down. Retry with the same key. |

**4. Render the widget.** Store `session["transactionId"]` against your order, then hand the
two cashier values to the page:

```html
<div id="checkout"></div>
<script src="https://bp-checkout.dominaite.com/v2/launcher"
        data-cashier-key="{{ cashier_key }}"
        data-cashier-token="{{ cashier_token }}"></script>
```

HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment
session values, not your credentials.

That's the whole integration: the session call, the script tag, and your domain bound to your
checkout by Dominaite during onboarding.

## Amounts are minor units

`amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. A float or a
string raises `ValueError` before anything is sent. The amount is locked server-side - what you
pass here is what gets charged; nothing in the browser can change it.

## Retries and double-charges

Every `create_checkout_session` call carries an idempotency key (auto-generated, or pass your
own as `idempotency_key`). Retrying with the same key never opens a second payment - on a
timeout, retry with the same key rather than generating a new one.

There is a helper that does exactly that:

```python
session = client.create_checkout_session_with_retry(
    amount=2500,
    currency="EUR",
    order_reference="order-1042",
    max_attempts=3,
)
```

It retries only `TransportError` (network failures, 5xx, `MERCHANT_API_UNAVAILABLE`), reuses the
one key across all attempts, and backs off between them. Refusals and authentication failures
are raised immediately.

## Sessions expire

A session is valid for 2 hours. If the payer comes back later, create a new session.

## Status polling

```python
status = client.get_status(session["transactionId"])
# {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
#  "amount": 2500, "currency": "EUR", ...}
```

`status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,
`partially_refunded`, `cancelled`, `disputed`, `abandoned`. While the session is still payable
the response also carries `expiresAt`; after that instant a `pending` session can only become
`abandoned`. An unknown transaction id raises `ApiError` with `http_status == 404`.

Poll after the payer returns to you, or on your order timeout - not in a tight loop; the
endpoint is rate limited per key.

## Errors

| Exception | Means | Retry? |
|---|---|---|
| `AuthenticationError` | Bad credentials, bad signature, clock skew, IP not allowlisted | No - fix config |
| `CheckoutRefusedError` | The gateway refused to open the session (`error_code`) | Depends on the code |
| `ApiError` | Unexpected response, or a 4xx like an unknown transaction id (`http_status`) | No |
| `TransportError` | Network failure or 5xx; you don't know if it landed | Yes, same idempotency key |

All four inherit from `DominaiteError` if you only care that the call failed.

## Running the tests

```bash
python -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest
```

`tests/test_signing.py` reproduces the signing test vector published on the dashboard's
Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never
the expected value.
