Metadata-Version: 2.4
Name: irandargah
Version: 1.1.0
Summary: SDK رسمی Python برای درگاه پرداخت ایران‌درگاه (IPG v2)، افزونهٔ اقساط و IranDargah Connect
Author: IranDargah
Project-URL: Homepage, https://github.com/irandargah/plugins
Project-URL: Changelog, https://github.com/irandargah/plugins/blob/main/sdks/python/CHANGELOG.md
Keywords: irandargah,payment,ipg,iran,gateway
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

<div dir="rtl">

# SDK رسمی Python ایران‌درگاه

کلاینت بدون وابستگی (فقط کتابخانهٔ استاندارد) برای API نسخهٔ ۲ ایران‌درگاه (IPG v2)، افزونهٔ اقساط و IranDargah Connect.
نیازمند **Python ≥ 3.7**. تاریخچهٔ تغییرات: [`CHANGELOG.md`](CHANGELOG.md).

## نصب

از رجیستری اختصاصی ایران‌درگاه (SDK وابستگی ندارد، پس `--index-url` تنها کافی است):

```bash
pip install irandargah --index-url https://packages.irandargah.com/pypi/simple
```

در `requirements.txt` کنار بسته‌های PyPI، نشانی مستقیم فایل wheel با هش (هش هر نسخه در صفحهٔ رجیستری و `SHA256SUMS`):

```text
irandargah @ https://packages.irandargah.com/pypi/files/irandargah-1.1.0-py3-none-any.whl#sha256=<sha256>
```

**`--extra-index-url` توصیه نمی‌شود:** pip نسخه‌های همهٔ indexها را یکی می‌کند و بالاترین را برمی‌دارد؛ بستهٔ هم‌نامی
با نسخهٔ بالاتر روی PyPI جای SDK را می‌گیرد (dependency confusion).

> راهنمای همهٔ زبان‌ها، هش SHA-256 و دانلود مستقیم فایل‌ها: <https://packages.irandargah.com> — بسته‌ها فقط روی رجیستری اختصاصی ایران‌درگاه منتشر می‌شوند (نه رجیستری‌های عمومی).

## پرداخت و تأیید

```python
from irandargah import Client, IranDargahError

client = Client(
    "idg_live_xxx",
    sandbox=False,       # True برای محیط آزمایشی (توکن idg_test_…)
    secret_key="",       # فقط اگر امضای درخواست فعال است
)

# ۱) ایجاد تراکنش (مبلغ به ریال) و ذخیرهٔ authority روی سفارش
tx = client.create_payment(
    amount=100000,
    order_id=order.id,
    callback_url="https://shop.tld/callback",
    mobile="09123456789",
)
order.authority = tx["authority"]   # ← حتماً ذخیره کنید
order.save()
# کاربر را به tx["gateway_url"] هدایت کنید (یا client.payment_url(tx["authority"]))
```

### بازگشت از بانک (callback) — امن

پارامترهای callback (`status_code`، `amount`، `ref_id`، `card_pan`) **امضا ندارند** و فقط راهنما هستند؛ هرگز آن‌ها را
مدرک پرداخت ندانید (حتی با `direct_verify=True`). سفارش را با `order_id` خودتان پیدا کنید، authority بازگشتی را با
authority **ذخیره‌شده روی همان سفارش** مقایسه کنید و با **مبلغ ذخیره‌شدهٔ سفارش** verify کنید:

```python
@app.route("/callback", methods=["GET", "POST"])
def callback():
    p = {**request.args, **request.form}              # پیش‌فرض POST form؛ با action=GET در query
    order = Order.find(p.get("order_id"))
    if order is None or order.paid:
        return redirect("/checkout/failed")

    try:
        v = client.verify_for_order(
            stored_authority=order.authority,         # از دیتابیس خودتان
            callback_authority=p.get("authority"),    # از callback — فقط برای مقایسه
            stored_amount=order.amount,               # از دیتابیس خودتان — نه p["amount"]
        )
        order.mark_paid(ref_id=v["ref_id"])
        return redirect("/checkout/success")
    except IranDargahError as e:
        # AUTHORITY_MISMATCH: authority با سفارش جور نیست (هیچ درخواستی به سرور نرفت)
        # AMOUNT_MISMATCH: مبلغ تأییدشده با مبلغ سفارش یکی نیست
        # -19 و …: تأیید ناموفق
        app.logger.error("verify failed: %s %s", e.code, e)
        return redirect("/checkout/failed")
```

`verify(authority, amount)` هم همین بررسی مبلغ را دارد (`AMOUNT_MISMATCH`)؛ verify تکراری روی تراکنش تأییدشده همان
پاسخ موفق را برمی‌گرداند، پس موفقیت verify به‌تنهایی ثابت نمی‌کند authority مال همین سفارش است — به همین دلیل
`verify_for_order` اول authority را با سفارش ذخیره‌شده مقایسه می‌کند.

### وضعیت‌های غیرنهایی (بدون خطا)

```python
p = client.get_payment(authority)          # p["completed"], p["status_code"]: 200|100|400|408|409, p["status"]
s = client.get_verification(authority)     # s["verified"], s["status_code"]: 200|201
r = client.retry_verification(authority)   # r["verified"]=False + r["status_code"] منفی در شکست نرم
```

این سه متد برای پاسخ‌های «هنوز نهایی نیست» (HTTP 2xx با `success: false` و بدون شیء `error`) خطا پرتاب نمی‌کنند.
وقتی `verified` نادرست است، `verified_at` بی‌معناست.

## متدهای `Client`

| متد | توضیح |
|---|---|
| `create_payment(amount, order_id, callback_url, ...)` | ایجاد تراکنش ← `{authority, gateway_url, expires_at}` |
| `payment_url(authority)` | آدرس `startpay` |
| `verify(authority, amount)` | تأیید قطعی؛ `AMOUNT_MISMATCH` اگر مبلغ پاسخ ≠ amount |
| `verify_for_order(stored_authority, callback_authority, stored_amount)` | تأیید گره‌خورده به سفارش (`AUTHORITY_MISMATCH` بدون HTTP) |
| `get_verification(authority)` | `{verified, status_code, ref_id, amount, …}` |
| `retry_verification(authority)` | تلاش مجدد تأیید (بدون amount) — شکست نرم = `{verified: False, status_code, message}` |
| `get_payment(authority)` | `{completed, status_code, status, …}` |
| `cancel_payment(authority, force=False)` | لغو (تولید POST، sandbox PUT) ← `{authority, cancelled: True, raw}` |
| `list_transactions(from_date, to_date, page=1, per_page=10, status=None)` | تاریخ‌های **جلالی** `YYYY-MM-DD`؛ `page`/`per_page` همیشه ارسال می‌شوند |
| `get_transaction(authority)` | جزئیات (`verification_status: 1` = تأییدشده) |
| `daily_report("1405-07-21")` / `monthly_report("1405-07")` | گزارش؛ پارامتر جلالی الزامی |
| `health()` | `{ok, status, timestamp, version, httpStatus}` — 503 خطا نیست |
| `subscribe_webhook(url, events, secret=None, description=None)` | `{id, secret, created, …}` — secret را همین حالا ذخیره کنید |
| `unsubscribe_webhook(webhook_id)` | شناسهٔ عدد صحیح |
| `webhook_events()` | `{events, names, webhook_requirements}` |
| `refund(authority, reason, amount=None, description=None)` | ← `{refund_id (عددی), amount, status, …}` |
| `process_refund(refund_id)` | `{processed, pending, status, reference, …}` — HTTP 202 یعنی نتیجه نزد PSP نامعلوم |

- مبلغ همیشه به **ریال**. `Idempotency-Key` برای `create_payment`/`refund` خودکار ساخته می‌شود (یا `idempotency_key=` بدهید).
- اگر `sandbox` را ندهید و `base_url` به یک میزبان `sandbox.` اشاره کند، محیط آزمایشی تشخیص داده می‌شود.
- لغو فقط دفترداری ایران‌درگاه را تغییر می‌دهد؛ پاسخ 409 یعنی «پرداخت‌شده — استرداد کنید» یا «خریدار هنوز در بانک
  است» (بعداً تلاش کنید یا `force=True`).
- آیتم‌های `list_transactions` مدل خام سرورند؛ فقط `authority`، `amount`، `ref_code`، `workflow_state`، `created_at` قابل اتکا هستند.
- SDK هیچ retry خودکاری ندارد و redirect دنبال نمی‌کند.

### خطاها

همهٔ خطاها `IranDargahError` هستند:

| فیلد | معنی |
|---|---|
| `code` | عددی (IPG مثل `-19`، امضا `-101…-109`، `-2` ورودی نامعتبر سمت SDK، `-31` شبکه/پاسخ نامعتبر) یا رشته‌ای (`AMOUNT_MISMATCH`، `AUTHORITY_MISMATCH`، `FEATURE_DISABLED`، `STATE_MISMATCH`، `ISSUER_MISMATCH`، `MISSING_CODE`، `WEBHOOK_*`، کد OAuth مثل `invalid_grant`، کد Connect مثل `terminal_not_active`) |
| `http_status` | کد HTTP (۰ = بدون پاسخ) |
| `status_code` | `status_code` بدنه |
| `type` | `error.type` در IPG یا `error` در خطای NestJS |
| `details` / `blockers` | جزئیات اعتبارسنجی / موانع Connect |
| `retry_after` | ثانیه (از `Retry-After`) |
| `oauth_error`، `request_id`، `body`، `network_error` | |

## وب‌هوک

```python
from irandargah import parse_webhook, IranDargahError

@app.route("/webhooks/irandargah", methods=["POST"])
def irandargah_webhook():
    try:
        # امضا (X-Webhook-Signature روی v2|ts|id|data) + بازهٔ ۳۰۰ ثانیه + parse
        evt = parse_webhook(request.get_data(), request.headers, WEBHOOK_ENDPOINT_SECRET)
    except IranDargahError:
        return "", 401
    if already_seen(evt["id"]):                 # X-Webhook-ID برای حذف تکرار
        return "", 200
    # evt = {"event": "payment.completed", "timestamp": "…+03:30", "terminal_id": 4231, "data": {...}, "id": "evt_…"}
    return "", 200
```

- بدنهٔ **خام** (`bytes` یا `str`) را بدهید، نه JSON پارس‌شده.
- بدنهٔ واقعی فیلد `signature` ندارد؛ امضا فقط در هدر است. `data` را map باز در نظر بگیرید.
- `verify_webhook_signature(raw_body, signature, secret, timestamp, event_id)` همچنان برای بررسی فقط امضا در دسترس است.
- رویدادها: `WEBHOOK_EVENTS` یا `client.webhook_events()`.

## افزونهٔ اقساط

```python
from irandargah import InstallmentsClient

inst = InstallmentsClient("idg_live_xxx")  # پیش‌فرض: https://api-v2.irandargah.com/api/v1/plugin/installments

open_items = inst.get_open_installments("+98 912 123 4567")   # نرمال‌سازی به 09121234567
item = open_items[0]
tx = client.create_payment(amount=item["payableAmount"], order_id="INST-%s" % item["installmentId"],
                           callback_url=callback_url)
# … بازگشت + verify_for_order …
r = inst.attach_installment(item["installmentId"], authority=tx["authority"])   # یا transaction_id=123
# r = {"installmentId", "status": "PAID" | "PARTIALLY_PAID", "attached": True, "installment", "raw"}
```

- مبالغ **رشته‌ای** (ریال) هستند؛ همیشه از `payableAmount` استفاده کنید. `dueDate` میلادی ISO است.
- 403 یعنی افزونه برای حساب شما فعال نیست ← `code == "FEATURE_DISABLED"`.
- میزبان مستندات (`api.irandargah.com`) امروز در CDN خطای 504 می‌دهد؛ اگر لازم شد با `base_url` عوضش کنید.
- ارسال لینک پرداخت به مشتری مجاز نیست؛ پرداخت باید روی سایت خودتان انجام شود.

## IranDargah Connect (پلتفرم‌های شریک)

```python
from irandargah import ConnectClient

connect = ConnectClient(
    client_id="ic_…", client_secret="ics_…",
    redirect_uri="https://partner.example/oauth/callback",
    # issuer="https://api-v2.irandargah.com", auth_method="client_secret_basic"
)

# ۱) شروع: code_verifier و state را در نشست نگه دارید
pkce = connect.create_pkce()
session["pkce"] = pkce
url = connect.authorization_url(
    scope=["profile:read", "terminals:provision", "terminals:credentials"],
    state=pkce["state"], code_challenge=pkce["code_challenge"])
# redirect(url)

# ۲) بازگشت: state + iss بررسی می‌شوند؛ error= به خطا تبدیل می‌شود
cb = connect.validate_callback(request.url, session["pkce"]["state"])   # یا request.args
tokens = connect.exchange_code(cb["code"], session["pkce"]["code_verifier"])   # code فقط ۶۰ ثانیه اعتبار دارد
store.save(tokens["sub"], tokens)                                              # sub = کلید پایدار پذیرنده

# ۳) API شریک
t = connect.create_terminal(tokens["access_token"], domain="shop.example.ir", store_name="فروشگاه",
                            idempotency_key="%s:shop.example.ir" % tokens["sub"])
# t["status"]: PENDING | BLOCKED (t["blockers"][i]["message"] را عیناً نشان دهید) | ACTIVE | FAILED
cred = connect.get_credentials(tokens["access_token"], t["terminalId"], with_signature_key=True)
```

**قانون چرخش refresh token (حیاتی):** هر `refresh_token()` یک `refresh_token` تازه برمی‌گرداند و قبلی همان لحظه می‌میرد.
ارائهٔ دوبارهٔ توکن چرخیده یا **دو refresh هم‌زمان** با یک توکن، کل اتصال را باطل می‌کند (`invalid_grant`) و پذیرنده باید
دوباره رضایت دهد. پس: refresh هر پذیرنده را با قفل سریالی کنید، توکن تازه را **پیش از هر کار دیگری** ذخیره کنید، و روی
`invalid_grant` اتصال را قطع‌شده علامت بزنید (retry نکنید). روی `invalid_token` از API شریک یک بار refresh کنید.

- endpoint توکن فقط `application/x-www-form-urlencoded` می‌پذیرد (SDK همین را می‌فرستد).
- `get_credentials` را هرگز خودکار تکرار نکنید: `rotate_if_exists=True` هر بار توکن را می‌چرخاند و با `with_signature_key`
  کلید امضای قبلی بی‌درنگ می‌میرد. `apiToken` فقط یک بار برمی‌گردد — رمزگذاری‌شده ذخیره کنید و لاگ نکنید.
- `revoke_token(token)`، `jwks()`، `me(access_token)`، `get_terminal(access_token, terminal_id)` هم در دسترس‌اند.
- کلیدهای پاسخ API شریک (`terminalId`، `apiToken`، ...) camelCase و همان‌طور که سرور داده‌اند هستند؛ کلیدهای `create_pkce()` snake_case.

## امضای درخواست: v1 (پیش‌فرض) و v2 (اختیاری)

- **v1:** `X-Signature = hex(HMAC-SHA256(secret_key, METHOD:endpoint:body:timestamp))` + `X-Timestamp`.
  `endpoint` بدون `/` ابتدایی، بدون `v2/` و بدون query است. برای **POST** `body` بایت‌های خام بدنه است (برای POST بی‌بدنه
  مثل cancel/retry رشتهٔ خالی)؛ برای **GET** (و هر متد غیر POST) `body` = `http_build_query(ksort(query))` به سبک PHP
  (فاصله `+`، فقط `A-Z a-z 0-9 - _ .` لفظی؛ مقدارها trim و مقدارهای خالی حذف می‌شوند). SDK query را روی سیم هم
  دقیقاً با همین کدگذاری و ترتیب می‌فرستد. از 1.1.0 هدر `X-Nonce` (۳۲ hex تصادفی، تازه برای هر درخواست) هم ارسال
  می‌شود؛ جزو متن امضا نیست، ولی سرور آن را یک‌بارمصرف می‌کند (ضد replay) و با روشن‌شدن `SIGNATURE_REQUIRE_NONCE`
  الزامی است.
- **v2 (`IRDG-HMAC-SHA256-V2`):** با `signature_version=2`. چهار هدر `X-Signature-Version: 2`، `X-Timestamp`، `X-Nonce`،
  `X-Signature`؛ رشتهٔ امضاشده ۷ خط است: `IRDG-HMAC-SHA256-V2`، متد، مسیر روی سیم با `/v2`، query کانونی (RFC 3986، مرتب)،
  timestamp، nonce، sha256 هگز بدنه. nonce درون امضاست و درخواست ضبط‌شده قابل تکرار نیست.
- تا وقتی ترمینال شما روی سرور `require_signature` ندارد، هر دو نسخه بی‌اثرند.
- `urllib.parse.quote_plus` معادل `urlencode` پی‌اچ‌پی نیست (`~` را کدگذاری نمی‌کند)؛ برای امضا از `php_urlencode()` / `php_http_build_query()` استفاده کنید.

```python
from irandargah import sign_request_v1, sign_request_v2

# V1-1 → e9445ce26bee08264b1455b4eca83184a5c4cad563ec70302cd2f81664784f68
sign_request_v1("irdg_test_secret_0123456789abcdef", "GET", "transactions", timestamp="1767225600",
                query={"from_date": "1405-07-01", "to_date": "1405-07-21", "status": "completed",
                       "page": 1, "per_page": 10})

# TV1 → 2821a709b57106aeb2a549c5af874045013accbb5ac1603918c533fd32ed7b1e
sign_request_v2("irdg_test_secret_0123456789abcdef", "POST", "/v2/payments", "",
                "1767225600", "a1b2c3d4e5f60718293a4b5c6d7e8f90",
                '{"amount":100000,"callback_url":"https://shop.example/callback","order_id":"ORDER-123"}')
```

### `direct_verify` چیست؟

پیش‌فرض `direct_verify=False` است: بعد از بازگشت کاربر از بانک، **شما باید خودتان verify کنید**. اگر
`direct_verify=True` بفرستید، درگاه خودش تراکنش را verify می‌کند؛ ولی پارامترهای callback همچنان امضا ندارند — وضعیت
را با `get_verification()` (و مقایسهٔ مبلغ با سفارش) بررسی کنید.

## مجوز
MIT — متن کامل در فایل [LICENSE](LICENSE)

</div>
