Metadata-Version: 2.4
Name: irgateway
Version: 0.3.2
Summary: Iranian payment gateway SDK: Zarinpal and Zibal, sync and async.
Author-email: Javad Dalirani <jdalirani82@gmail.com>
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-Expression: MIT
License-File: LICENSE
Requires-Dist: requests>=2.28,<3
Requires-Dist: aiohttp>=3.9,<4 ; extra == "async"
Requires-Dist: pytest>=7 ; extra == "dev"
Requires-Dist: aiohttp>=3.9,<4 ; extra == "dev"
Requires-Dist: build>=1 ; extra == "dev"
Project-URL: Homepage, https://github.com/javad2nd/irgateway
Provides-Extra: async
Provides-Extra: dev

# irgateway — Iranian payment gateway SDK for Python

Python SDK for **Zarinpal (v4)** and **Zibal**. Synchronous (`requests`) and asynchronous (`aiohttp`) clients are supported.

> **Version 0.3.2 restores legacy defaults and call shapes.** Payment applications with outstanding old transactions still need the [migration guide](#migration-from-0211) because old paid amounts cannot safely be guessed.

## Installation

Requires Python **3.10+**.

```bash
pip install irgateway               # sync requests client
pip install 'irgateway[async]'      # enables aiohttp async clients
pip install -e '.[dev]'             # contributors: pytest and build tools
```

## Amount and fee rules (important)

- The default **caller-side** currency is `IRT` (toman), preserving the old nominal units. Use `currency='IRR'` for rial inputs.
- Monetary input values **must be integers**. Floats and booleans are rejected. No floating-point fee estimates are used.
- The SDK sends amounts to both APIs in **IRR** (rial). Zarinpal explicitly receives `currency='IRR'`; Zibal's IPG accepts rials.
- Persist the **actual payable amount** from `PaymentRequest.payable_amount`, its `amount_rials`, and the payment identifier along with the order. Verify against those stored values, **not callback-supplied amounts**.
- The default `fee_type='BUYER'` from **0.2.11 is restored** in 0.3.2. A legacy call with no `buyer_fee` keeps the *old automatic surcharge formula*, without extra API calls, to reduce the risk of breaking existing applications. **This formula is a compatibility estimate, not your merchant-specific gateway fee**; use `fee_type='MERCHANT'` for no local surcharge.
- To choose the new explicit fee behaviour, pass `buyer_fee=<integer>` (manual), `buyer_fee=0` (free), or `buyer_fee='auto'` (improved opt-in estimate/quote). With `fee_type='MERCHANT'`, omit `buyer_fee`; the SDK will not add a local surcharge.
- The provider's real fee and payer attribution can differ by contract. `calculate_fee()` returns the Zarinpal provider quote. Calling `calculate_fee(100000)` directly continues treating the input as **100000 IRR**, consistent with 0.2.11; use `calculate_fee(100000, amount_currency='IRT')` to pass toman.
- **Zarinpal `auto`:** calls official `feeCalculation` API first (one extra HTTP request). For `fee_type=Merchant`, uses `suggested_amount` as the proposed gross amount, rounding up to whole configured units. If Zarinpal reports `fee_type=Payer`, returns zero *additional local* surcharge to avoid billing twice. A missing/malformed quote raises instead of guessing.
- **Zibal `auto`:** uses **an estimate**, not an account-specific API quote: published IPG rate 1%, minimum 2,000 IRT, maximum 20,000 IRT, plus 10% VAT **on commission** (checked 2026-10-22). Solves fee-on-total consistently using integer math. Set a custom `ZibalFeePolicy` for negotiated tariffs; public pricing can change.
- You **must display/disclose the final amount** before redirect. The extra `buyer_fee` is part of the charged order amount, not a guaranteed match to the provider's final merchant-specific processing costs. Provider and contractual rules can restrict passing charges to customers.
- Wages, multiplexing amounts and cart values follow the same **caller-side currency** and are converted to rials in the request. Percentage-based split modes are **not** implemented here.

## Zarinpal — synchronous

```python
from irgateway.zarinpal import ZarinpalClient, ZarinpalConfig
from irgateway.zarinpal.enums import Environment

with ZarinpalClient(
    merchant_id='YOUR-36-CHARACTER-MERCHANT-ID',
    environment=Environment.SANDBOX,  # PRODUCTION in live environment
    config=ZarinpalConfig(currency='IRT', fee_type='MERCHANT', timeout=15),
) as client:
    payment = client.request(
        amount=100_000,                  # 100,000 toman
        callback_url='https://example.com/payments/zarinpal/callback',
        description='پرداخت سفارش ۱۲۳',
    )

    # Store payment.authority, payment.payable_amount, payment.amount_rials
    # and your local order ID in DB BEFORE redirecting the customer.
    print(payment.payment_url)

    # After a trusted callback with Status=OK, find the LOCAL pending order
    # by stored Authority; NEVER use an amount supplied by the callback.
    verification = client.verify(
        authority=payment.authority,
        amount=payment.payable_amount,    # caller currency (IRT in this example)
    )
    if verification.is_success:
        print(verification.ref_id)
        # Mark the order paid ATOMICALLY, once (unique payment identifier).
```

### Automatic buyer surcharge — Zarinpal

```python
with ZarinpalClient(
    merchant_id='YOUR_MERCHANT_ID',
    config=ZarinpalConfig(currency='IRT', fee_type='BUYER'),
) as client:
    # Optional preview: calls feeCalculation; the returned local surcharge is in toman.
    estimated_extra = client.calculate_buyer_fee(100_000)

    # Recommended: calculate just once during request() to avoid a stale quote.
    payment = client.request(
        100_000, 'https://example.com/callback', 'خرید', buyer_fee='auto'
    )
    # Store payment.payable_amount (including extra charge) for later verify().
    print(payment.payable_amount, payment.buyer_fee)

# Manual override remains available: buyer_fee=2500 or buyer_fee=0.
```

`auto` adds **one API call** before the payment request; do not use it with `fee_type='MERCHANT'`. For `Payer` fee mode on Zarinpal, the payer is already charged by the gateway, so **no additional local fee** is added. In merchant-paid mode, `suggested_amount` is Zarinpal's quote of the proposed total; actual terminal charges may differ. If the fee API fails, payment creation is aborted rather than guessed. `calculate_fee()` exposes the unmodified provider quote.

### Other Zarinpal features

```python
from irgateway.zarinpal.types import PaymentMetadata, Wage

# Metadata: optional mobile, email and order_id. referrer_id is also available.
payment = client.request(
    amount=100_000,
    callback_url='https://example.com/callback',
    description='خرید',
    metadata=PaymentMetadata(mobile='09120000000', order_id='ORDER-123'),
    referrer_id='YOUR_REFERRER_ID',
    wages=[Wage(iban='IR123456789012345678901234', amount=20_000, description='سهم همکاری')],
)

# All monetary values in the returned quote are IRR.
fee = client.calculate_fee(1_000_000)  # legacy input is IRR
# or: client.calculate_fee(100_000, amount_currency='IRT')
status = client.inquiry(payment.authority)  # status only; NOT verification
pending = client.unverified()               # up to 100 recent unverified transactions
# client.reverse(payment.authority)         # destructive; authorized terminals only
```

`reverse()` preserves the 0.2.11 result contract: `True` for success, `False` for a **recognizable gateway business rejection**. Transport, HTTP protocol and invalid response failures can still raise exceptions. Reverse has eligibility restrictions; it is **not** an unlimited general refund API.

## Zibal — synchronous

```python
from irgateway.zibal import ZibalClient, ZibalConfig

with ZibalClient(merchant='zibal', config=ZibalConfig(currency='IRT', fee_type='MERCHANT')) as client:
    # merchant='zibal' is Zibal's test merchant; use your own merchant in production.
    payment = client.request(
        amount=100_000,
        callback_url='https://example.com/payments/zibal/callback',
        description='پرداخت سفارش ۱۲۳',
        order_id='ORDER-123',
    )
    # Persist track_id, payable_amount, amount_rials and local order ID.
    print(payment.payment_url)

    # After callback, look up your saved local pending order by track_id.
    verified = client.verify(
        payment.track_id,
        expected_amount=payment.payable_amount,  # strongly recommended
    )
    if verified.is_success:
        print(verified.ref_number)
        # Persist the payment in a DB transaction with duplicate protection.

    # Optional: investigation/reconciliation; does not confirm payment:
    details = client.inquiry(payment.track_id)
```

Zibal `PaymentVerification.amount` contains the **raw rial amount** returned by the API. The optional `expected_amount` input is in the configured caller currency, and a mismatch raises `PaymentAmountMismatch`. When an expected amount is given, a missing amount in the response is rejected. Always supply an expected amount in production.

### Fee and splitting example

```python
from irgateway.zibal.types import MultiplexingInfo
from irgateway.zibal import ZibalConfig

client = ZibalClient('YOUR_MERCHANT', config=ZibalConfig(currency='IRT', fee_type='BUYER'))
payment = client.request(
    amount=20_000,
    buyer_fee=500,  # 500 toman explicit customer surcharge; no estimated fee
    callback_url='https://example.com/cb',
    multiplexing_infos=[MultiplexingInfo(iban='IR123456789012345678901234', amount=5_000)],
)
# payment.payable_amount == 20500 IRT, payment.amount_rials == 205000 IRR
client.close()
```

### Automatic buyer surcharge — Zibal

```python
from irgateway.zibal import (
    ZibalClient, ZibalConfig, ZibalFeePolicy, estimate_buyer_fee,
)

config = ZibalConfig(currency='IRT', fee_type='BUYER')
with ZibalClient('YOUR_MERCHANT', config=config) as client:
    fee = client.calculate_buyer_fee(100_000)  # local estimate, no HTTP call
    payment = client.request(100_000, 'https://example.com/cb', buyer_fee='auto')
    print(payment.payable_amount, payment.buyer_fee)  # total, local surcharge

# If your contract has other rates, configure them explicitly.
custom_config = ZibalConfig(
    fee_type='BUYER',
    fee_policy=ZibalFeePolicy(
        rate_bps=50,      # 0.5%; 100 basis points = 1%
        minimum_irt=0,    # fee cap/floor always expressed in TOMAN
        maximum_irt=10_000,
        vat_bps=1000,     # 10% on fee
    ),
)
# A standalone estimator is also available:
print(estimate_buyer_fee(100_000, currency='IRT', policy=custom_config.fee_policy))
```

**Zibal:** the public tariff is not guaranteed for a particular merchant account, special contract, promotion or exemption. For an exact amount required by your own agreement, use an explicit integer `buyer_fee`, or configure the policy only after checking your merchant contract. The estimator accounts for the extra fee itself (a gross-up), and always rounds upward to the smallest unit available (`IRT` or `IRR`). VAT and percentages use integer math, never floats.

## Asynchronous clients

```python
from irgateway.zarinpal import AsyncZarinpalClient
from irgateway.zarinpal.enums import Environment
from irgateway.zibal import AsyncZibalClient

async def create_payment():
    async with AsyncZarinpalClient('YOUR_MERCHANT_ID', Environment.SANDBOX) as client:
        p = await client.request(10000, 'https://example.com/cb', 'خرید')
        return p.payment_url

async def create_zibal_payment():
    async with AsyncZibalClient('zibal') as client:
        p = await client.request(10000, 'https://example.com/cb')
        return p.payment_url
```

Both Sync and Async clients accept `buyer_fee='auto'` in `request()` when configured with `fee_type='BUYER'`. The omitted `buyer_fee` case in the default `BUYER` mode uses the 0.2.11 formula for compatibility and does NOT call the new quote API. For async clients, use `await client.calculate_buyer_fee(amount)` to preview the extra charge; Zarinpal performs an API call, Zibal estimates locally. Sync and async clients have the same request, verify and inquiry semantics. Async clients support `async with` and `await close()`; sync clients support `with` and `close()`. Do not share a `requests.Session` between threads.

## Transaction safety / error handling

```python
from irgateway import (
    GatewayTransportError,
    GatewayHTTPError,
    GatewayProtocolError,
    PaymentAmountMismatch,
)
from irgateway.zarinpal.errors import ZarinpalException, AlreadyVerifiedException
from irgateway.zibal.errors import ZibalException

try:
    result = client.verify(...)  # see gateway-specific call signatures above
except AlreadyVerifiedException:
    # Don't mark an arbitrary order paid. Reconcile using local DB + gateway.
    pass
except PaymentAmountMismatch:
    # Halt fulfillment and investigate.
    pass
except (GatewayTransportError, GatewayHTTPError, GatewayProtocolError):
    # Outcome can be UNKNOWN; reconcile before retrying any mutating call.
    pass
except (ZarinpalException, ZibalException) as exc:
    print(exc.code)
```

**Previously verified does not mean a new successful payment.** Zarinpal `101` and Zibal `201` raise dedicated gateway exceptions by default. `verify(..., allow_already_verified=True)` can return a result whose `already_verified` is `True`; **`is_success` remains `False`** so application code cannot accidentally treat duplicates as new approvals. Use this for reconciliation, not fulfillment.

**Important:**

1. Validate the callback's parameters, but do not trust `Status=OK` or `success=1` as proof of payment.
2. Locate the pending order from your own database and fetch its stored payable amount and gateway ID.
3. Call `verify`; for Zibal also pass `expected_amount`.
4. Persist `PAID` with atomic compare-and-set/transaction plus a unique constraint for the gateway payment ID. Fulfill exactly once.
5. If a request times out, the gateway may already have completed it. There are **no implicit automatic retries**. Use inquiry/unverified and your local ledger to reconcile; inquiry alone never verifies a payment.
6. Do not log full customer card numbers, personal data, merchant secrets, or raw unredacted gateway payloads.

## Migration from 0.2.11

**Special case — users who already adopted 0.3.1:** that version defaulted to `MERCHANT`. To keep that no-surcharge behaviour in 0.3.2, set `ZarinpalConfig(fee_type="MERCHANT")` or `ZibalConfig(fee_type="MERCHANT")` explicitly. There is no safe way to infer which old default the application intended.

**Goal of 0.3.2:** existing `request(...)`, `verify(...)` and `reverse(...)` calls, dataclass names and legacy `to_dict()` keys remain usable without adding parameters. That is a compatibility objective—not a guarantee for every outstanding transaction, custom monkey patch, or code that relied on undocumented internals.

| Behaviour | v0.2.11 | v0.3.2 |
|---|---|---|
| Default `fee_type` | `BUYER` | `BUYER` (restored) |
| Omitting `buyer_fee` | Old formula, no fee quote HTTP call | **Same old formula**, one HTTP payment request |
| `buyer_fee='auto'` | Not available | Explicit opt-in: official Zarinpal fee quote / configurable Zibal tariff estimate |
| `buyer_fee=<integer>` | Not available | Optional manual customer charge |
| `fee_type='MERCHANT'` | No local surcharge | No local surcharge |
| Legacy request result `to_dict()` | Old keys | Same keys by default; `to_dict(include_extended=True)` adds new amount fields |
| Old `ZarinpalConfig` / `ZibalConfig` `to_dict()` | Old keys | Old keys by default; extended format is opt-in |
| `reverse()` rejection from gateway | `False` | `False` (restored); network/protocol errors still raise |
| `calculate_fee(amount)` (Zarinpal) | Input was sent as-is (IRR) | Still accepts IRR by default; `amount_currency='IRT'` is now available |
| `verify(authority, amount)` (Zarinpal) | Amount was sent as-is (financial bug) | Uses last successful request's real total **if the same client instance still knows the authority**; otherwise amount is in `config.currency`, default IRT |
| Multiplexing/wages | Incorrect unit conversion possible | All amounts use caller currency and are sent in IRR |
| Zibal `verify(track_id)` | Returns paid response | Preserved; optional `expected_amount` now validates the amount |

### Critical: verifying an existing order

A callback may happen **in another worker or after restart**. `_recent_payments` is an in-memory, bounded, best-effort cache and **is not a persistent ledger**. Never rely on it for payment security or exactly-once fulfillment. Save `authority`/`track_id` and `amount_rials` when the request is created.

For a **new order** persisted using 0.3.2, verify after reload with the stored `payable_amount` (in config currency), or with the **exact original stored rial total**:

```python
result = client.verify(authority, stored_amount_rials, amount_is_rials=True)
```

For **outstanding orders created with 0.2.11**, do **not** recalculate their fees with new pricing. Retrieve the exact rial total charged when the request was created (from your stored request/payload or trusted settlement records). If you used the old manual-rial workaround, the same explicit flag is required when upgrading:

```python
result = client.verify(authority, old_stored_gross_rials, amount_is_rials=True)
```

If your old implementation saved only the base price in tomans and then created a separate client for verification, **there is no safe universal way to reconstruct the charged amount**. Recover the original stored payment data before switching versions; an automatic guess is unsafe. The library deliberately does not retry verification with several guessed amounts.

### Reading the extra fields

```python
payment = client.request(100000, 'https://example.com/cb', 'test')  # Zarinpal example
print(payment.payable_amount, payment.amount_rials, payment.buyer_fee)
legacy_record = payment.to_dict()  # old keys only
full_record = payment.to_dict(include_extended=True)  # add amount-related keys
```

**Python requirement:** 3.10+ (the old README's 3.7 claim was inaccurate). The 0.3.x version includes important fixes for HTTP errors, response parsing and duplicate verification. Always test active payments against the actual merchant's sandbox or a dedicated test terminal before rolling this out.

## Testing

```bash
python -m pytest -q
```

Tests are offline and use mocked gateway responses; **no payment was made**. They do not replace live sandbox/merchant integration testing. API functionality that depends on merchant-specific permissions (including reverse, shared settlements and added customer charges) must be confirmed against your own merchant account before deployment.

## API references (checked October 2026)

- [Zarinpal connection and verification](https://www.zarinpal.com/docs/paymentGateway/connectToGateway)
- [Zarinpal currency](https://www.zarinpal.com/docs/paymentGateway/moreFeatures/currency)
- [Zarinpal fee calculation](https://www.zarinpal.com/docs/paymentGateway/otherMethods/feeCalculation)
- [Zarinpal inquiry](https://www.zarinpal.com/docs/paymentGateway/otherMethods/Inquiry)
- [Zarinpal reversal](https://www.zarinpal.com/docs/paymentGateway/moreFeatures/reverse)
- [Zarinpal unverified](https://www.zarinpal.com/docs/paymentGateway/otherMethods/unVerified)
- [Zibal IPG docs](https://help.zibal.ir/ipg/) (documentation is rendered dynamically)
- [Zibal official public payment tariffs](https://zibal.ir/pricing)
- [Zibal official Node.js gateway SDK](https://github.com/zibalco/gateway-nodejs)

## License

MIT — see [LICENSE](LICENSE).

