Metadata-Version: 2.4
Name: payriff
Version: 0.1.0
Summary: Python client for the Payriff payment gateway
Project-URL: Homepage, https://github.com/martian56/payriff
Project-URL: Repository, https://github.com/martian56/payriff
Author: martian56
License-Expression: MIT
License-File: LICENSE
Keywords: azerbaijan,gateway,payment,payriff,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
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
Description-Content-Type: text/markdown

# payriff

Python client for the [Payriff](https://payriff.com) payment gateway. Sync and async, typed
throughout, with `httpx` as the only dependency.

```bash
pip install payriff
```

## Quick start

```python
from payriff import PayriffClient

client = PayriffClient("your-secret-key")

order = client.create_order(
    amount=25.50,
    description="Order 1024",
    callback_url="https://shop.example/payriff/callback",
)
print(order.payment_url)
```

Send the customer to `order.payment_url`. Payriff posts to your `callback_url` as the status
changes.

## A whole checkout

The shape of a real integration, here with Flask. Two routes: one to start the payment, one to
receive the result.

```python
from flask import Flask, jsonify, redirect, request

from payriff import GatewayError, PayriffClient

app = Flask(__name__)
client = PayriffClient.from_env()


@app.post("/checkout/<cart_id>")
def checkout(cart_id):
    cart = load_cart(cart_id)
    try:
        order = client.create_order(
            amount=cart.total,
            description=f"Order {cart_id}",
            callback_url="https://shop.example/payriff/callback",
        )
    except GatewayError as exc:
        app.logger.warning("payriff refused the order: %s %s", exc.code, exc.message)
        return jsonify(error="could not start the payment"), 502

    cart.payriff_order_id = order.order_id
    cart.save()
    return redirect(order.payment_url)


@app.post("/payriff/callback")
def callback():
    event = client.parse_callback(request.get_json(force=True))

    # The callback is unsigned, so nothing here is trusted until the API agrees.
    order = client.get_order(event.order_id)
    cart = load_cart_by_order(event.order_id)

    if order.settled:
        cart.mark_paid()
    elif order.payment_status in {"DECLINED", "CANCELED"}:
        cart.mark_failed(order.payment_status)

    return "", 200
```

Two details worth copying. The callback handler decides on `order`, the value it fetched, never
on `event`, the value it was sent. And it answers `200` whatever the outcome, because a non-2xx
tells Payriff to deliver the same event again.

Callbacks can arrive more than once for one order, so make `mark_paid` idempotent.

## Two things that catch people out

### A success code doesn't mean the payment worked

The `code` field tells you whether Payriff accepted the call. Whether money actually moved is a
separate field, `paymentStatus`. `auto_pay` is where this hurts most: a declined card still comes
back as `00000`.

```python
charge = client.auto_pay(card_uuid, 10.00, "Subscription")

charge.ok              # True
charge.payment_status  # "CANCELED"
charge.settled         # False
```

Gate your fulfilment on `settled`.

A hold is different again. A genuine pre-authorisation reports `PREAUTH_APPROVED`, and that is
the only status where money is reserved rather than taken. `settled` stays `False` for it and
`authorized` turns `True`.

```python
hold = client.get_order(order_id)
hold.authorized  # True, the funds are reserved
hold.settled     # False, nothing has moved yet
```

Watch out for accounts without pre-authorisation enabled. There, an order sent with
`operation=PRE_AUTH` is charged like an ordinary sale and comes back `APPROVED`, which `settled`
correctly reports as paid. Check `authorized`, not the operation you asked for.

### Callbacks aren't signed

There is no HMAC and no shared secret in the body, so there's nothing to verify. Anyone who
learns your callback URL can post to it. Treat the callback as a nudge to go ask the API what
happened:

```python
callback = client.parse_callback(request.json)
order = client.get_order(callback.order_id)

if order.settled:
    fulfil(callback.order_id)
```

`parse_callback` reads the body and nothing more. The `get_order` call is what makes it safe.

## Configuration

```python
client = PayriffClient(
    "your-secret-key",
    merchant_id="ES100000",
    currency="AZN",
    language="EN",
    callback_url="https://shop.example/payriff/callback",
)
```

`PayriffClient.from_env()` reads `PAYRIFF_SECRET_KEY`, `PAYRIFF_MERCHANT_ID`, `PAYRIFF_BASE_URL`
and `PAYRIFF_CALLBACK_URL` instead.

The secret goes into the `Authorization` header raw, with no `Bearer` prefix. The client handles
that for you.

## Async

Every method has an awaitable twin:

```python
from payriff import AsyncPayriffClient

async with AsyncPayriffClient("your-secret-key") as client:
    order = await client.create_order(25.50, "Order 1024")
    status = await client.get_order(order.order_id)
```

## Methods

| Method | Endpoint |
|---|---|
| `create_order` | `POST /api/v3/orders` |
| `reserve` | `POST /api/v3/orders` with `operation=PRE_AUTH` |
| `get_order` | `GET /api/v3/orders/{id}` |
| `complete` | `POST /api/v3/complete` |
| `refund` | `POST /api/v3/refund` |
| `auto_pay` | `POST /api/v3/autoPay` |
| `save_card`, `refund_card_save` | the two step card save flow |
| `transfer` | `POST /api/v3/payout` |
| `create_invoice` | `POST /api/v2/invoices` |
| `get_invoice` | `POST /api/v2/get-invoice` |

A couple of things worth knowing. The docs describe order information as a `POST`, but the API
answers that with a 405 and only takes `GET`, which is what this does. And `/api/v3/payout` moves
money between Payriff merchant wallets, so `transfer` seemed the honest name for it; if you were
hoping for a bank payout, this isn't it.

The v2 invoice endpoints want `merchant` set and refuse the call without it. The v3 order
endpoints do not care, so set `merchant_id` on the client if you touch invoices.

Bulk invoice and bulk payout are dashboard features driven by spreadsheet upload. There is no API
behind them, so there's nothing here for them either.

For anything else, go straight at it:

```python
client.request("POST", "/api/v3/directPay", {...})
```

## Pre-authorisation

`reserve` puts a hold on the card instead of charging it. You have 30 days to capture with
`complete`, after which the hold expires on its own. Capture less than you held and Payriff
releases the difference.

```python
hold = client.reserve(100.00, "Hotel booking", three_ds=True)
client.complete(hold.order_id, 80.00)
```

## Saving a card

To verify a card, Payriff charges 0.01 AZN and expects you to hand it straight back. Skipping the
refund leaves the customer out of pocket, so treat it as step two rather than tidying up:

```python
verification = client.save_card(callback_url="https://shop.example/payriff/callback")
# the customer completes verification.payment_url, then
client.refund_card_save(verification.order_id)
```

The `cardUuid` you need for `auto_pay` arrives in the callback.

## Responses

Branch on `code` rather than the HTTP status. The interesting fields live under `payload`, and
`Response` reads through to them:

```python
order.code            # "00000"
order.code_name       # "SUCCESS"
order.ok              # the call was accepted
order.settled         # money actually moved
order.authorized      # funds held by a pre-authorisation, not yet captured
order.operation_type  # "PURCHASE", "PRE_AUTH", ...
order.order_id, order.session_id, order.transaction_id, order.payment_url
order.payment_status  # "PAID", "CANCELED", ...
order.transactions    # per attempt: card mask, RRN, channel
order.payload         # raw payload, occasionally a plain string such as "APPROVED"
order.raw             # untouched body
```

## Enums

Values are case sensitive and always go over the wire as strings.

```python
from payriff import Currency, Language, PaymentStatus

client.create_order(10, "x", currency=Currency.USD, language=Language.AZ)

if order.payment_status == PaymentStatus.APPROVED:
    ...
```

| Enum | Values |
|---|---|
| `Operation` | PURCHASE, PRE_AUTH, COMPLETE, REFUND, REVERSE |
| `Language` | AZ, EN, RU, AR |
| `Currency` | AZN, USD, EUR, PKR, AED, SAR |
| `PaymentType` | ONETIME, DAILY, WEEKLY, MONTHLY, ANNUALLY |
| `InstallmentProduct` | BIRKART, ALBALI, BOLKART, TAMKART |
| `InvoiceStatus` | PENDING, ERROR, EXPIRED, PARTIAL, COMPLETE, CASH |
| `PaymentStatus` | CREATED, APPROVED, CANCELED, DECLINED, REFUNDED, PREAUTH_APPROVED, EXPIRED, REVERSE, PARTIAL_REFUND, PARTIAL, ACCEPTED, REFUND_IN_PROGRESS, CASH, PENDING, PREAUTH_EXPIRED |
| `ResultCode` | SUCCESS, WARNING, ERROR, INVALID_PARAMETERS, UNAUTHORIZED, TOKEN_NOT_PRESENT, INVALID_TOKEN, INVALID_ORIGIN, CHECKING |
| `GatewayResult` | 00, APPROVED, PREAUTH-APPROVED |

Members hash by value, so a raw string off the wire and an enum member reach the same dict entry.
`CURRENCY_NUMERIC` and `PAYMENT_STATUS_CODES` give you the ISO 4217 and internal numeric codes.

`PaymentStatus` carries two extras, `PAID` and `COMPLETED`. Payriff's enum reference leaves them
out, but the order information and autoPay examples both return them, so they are in here.
`SETTLED_PAYMENT_STATUSES` and `HELD_PAYMENT_STATUSES` are the sets behind `settled` and
`authorized`.

## Errors

`GatewayError` means Payriff refused the call: any result code other than `00000` or `01000`, or
a `4xx` that still carried an envelope. It gives you `code`, `message`, `internal_message` and
`response_id`.

`TransportError` covers the rest, so network failures and responses that either weren't JSON or
carried no envelope at all.

Both subclass `PayriffError` if you want to catch broadly.

```python
from payriff import GatewayError, PayriffError, TransportError

try:
    order = client.create_order(25.50, "Order 1024")
except GatewayError as exc:
    # Payriff answered and said no. exc.code tells you why.
    log.warning("refused: %s %s", exc.code, exc.message)
except TransportError as exc:
    # Nothing came back. Safe to retry.
    log.error("payriff unreachable: %s", exc)
```

Retry `TransportError` if you like, but not `GatewayError`: the gateway already made up its mind
and the same call will be refused again.

The `01000` code is a bit odd. On a `2xx` it's a warning and comes back as a normal response. On
a `4xx` it's a refusal and raises, which is how Payriff answers an unknown application key.

## Testing

Payriff runs a sandbox. Point `PAYRIFF_BASE_URL` at it, use a sandbox key, and pay with the
published test cards:

| Brand | Number | Expiry | CVV | OTP |
|---|---|---|---|---|
| Visa | 4000007546012078 | 04/29 | 893 | 123456 |
| Mastercard | 5100007346013947 | 04/29 | 783 | 123456 |

`tests/test_sandbox.py` runs when `PAYRIFF_SECRET_KEY` is set and skips when it isn't, so a
checkout without a key still passes.

## What has been checked against the live gateway

Payriff's docs turned out to disagree with the API in a few places, so the flows below were run
against a real account and the parsing is built from the recorded bodies.

| Verified | Inferred from the docs only |
|---|---|
| create order, and the CREATED to PENDING to APPROVED or DECLINED lifecycle | complete, a capture |
| that a pre-auth order settles outright on an account without pre-auth | auto pay |
| order lookup, which is a GET despite the docs saying POST | card save and its refund |
| refund, which answers with a null payload, on both a purchase and a pre-auth | invoice create and lookup |
| the callback envelope, for a declined purchase | wallet transfer |
| result codes 15400, 15000 and 01000, and the 4xx behaviour | |

Pre-authorisation, autopay and invoicing are not switched on for the account used, so those
paths could not be exercised. Treat the right hand column as untested and please report anything
that looks wrong.

## Licence

MIT. Not affiliated with Payriff.
