Metadata-Version: 2.4
Name: django-hesabpay
Version: 0.1.8
Summary: Beginner-friendly Django SDK for the HesabPay payment gateway
Author: Cyber0x3a
License-Expression: MIT
Project-URL: Documentation, https://github.com/Cyber0x3a/django-hesabpay#readme
Project-URL: Homepage, https://github.com/Cyber0x3a/django-hesabpay
Project-URL: Issues, https://github.com/Cyber0x3a/django-hesabpay/issues
Keywords: django,hesabpay,payments,afghanistan,afn
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Requires-Dist: httpx>=0.27
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-django>=4.8; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5; extra == "dev"
Dynamic: license-file

﻿# django-hesabpay

Django SDK for the [HesabPay Payment Gateway](https://docs.hesab.com/).

Hosted checkout in a few lines. Your app owns fulfillment. HesabPay webhooks are the source of truth -redirects are UX only.

```python
import hesabpay
from hesabpay import on_payment_success

checkout = hesabpay.checkout(order, user=request.user)
return redirect(checkout.url)

@on_payment_success
def fulfill(context):
    enroll(user=context.user, course=context.instance)
```

- **PyPI:** [django-hesabpay](https://pypi.org/project/django-hesabpay/)
- **HesabPay docs:** [docs.hesab.com](https://docs.hesab.com/)
- **Requires:** Python 3.10+, Django 4.2+
- **Currency:** AFN (no FX conversion in v1)(will be added later)

---

## Table of contents

1. [Install](#install)
2. [5-minute quickstart](#5-minute-quickstart)
3. [How payments work](#how-payments-work)
4. [Settings reference](#settings-reference)
5. [Checkout](#checkout)
6. [Templates](#templates)
7. [JSON / API responses](#json--api-responses)
8. [Fulfillment handlers](#fulfillment-handlers)
9. [Payment context](#payment-context)
10. [Status polling](#status-polling)
11. [Multi-vendor transfers](#multi-vendor-transfers)
12. [Admin](#admin)
13. [Commands](#commands)
14. [Errors](#errors)
15. [Security notes](#security-notes)
16. [Changelog](#changelog)

---

## Install

```bash
pip install django-hesabpay
```

Editable (from this repo):

```bash
pip install -e .
```

---

## 5-minute quickstart

### 1. Add the app

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "hesabpay",
]
```

### 2. Configure

```python
HESABPAY = {
    "ENVIRONMENT": "sandbox",  # or "production"
    "SANDBOX_API_KEY": "your-sandbox-api-key",
    # "PRODUCTION_API_KEY": "your-live-api-key",
    "PUBLIC_BASE_URL": "https://your-public-https-origin.example",
    "SUCCESS_URL": "/payments/success/",
    "FAILURE_URL": "/payments/failure/",
    "HANDLERS": ["shop.payments"],  # import path for @on_payment_success modules
}
```

`PUBLIC_BASE_URL` turns relative success/failure paths into absolute HTTPS URLs HesabPay can redirect to (required behind tunnels / reverse proxies). `SITE_URL` is accepted as an alias.

### 3. Migrate

```bash
python manage.py migrate
```

Webhook and status routes register automatically:

| Endpoint | Purpose |
|----------|---------|
| `POST /hesabpay/webhook/` | HesabPay → your app (truth) |
| `GET /hesabpay/status/<reference>/` | Poll local payment status |

Copy the webhook URL into the [HesabPay dashboard](https://developers.hesab.com) (or sandbox equivalent).

### 4. Start checkout

```python
# views.py
import hesabpay
from django.shortcuts import redirect

def pay(request, order_id):
    order = Order.objects.get(pk=order_id)
    session = hesabpay.checkout(order, user=request.user)
    return redirect(session.url)
```

### 5. Fulfill after a verified webhook

```python
# shop/payments.py  ← listed in HESABPAY["HANDLERS"]
from hesabpay import on_payment_success

@on_payment_success
def fulfill(context):
    if not context.is_first_success:
        return  # idempotent retries / replays
    order = context.instance
    order.mark_paid()
    send_receipt(context.user, order)
```

Sanity-check config anytime:

```bash
python manage.py hesabpay doctor
```

---

## How payments work

```text
checkout(instance)
  → local HesabPayPayment (reference = HesabPay user_id)
  → POST /api/v1/payment/create-session
  → redirect customer to hosted checkout

customer pays on HesabPay
  → browser redirect to SUCCESS_URL / FAILURE_URL  (UX only)
  → HesabPay POST webhook
       → verify-signature
       → resolve payment by user_id / reference
       → mark payment succeeded|failed
       → on_commit → @on_payment_success / @on_payment_failed
```

**Rules of thumb**

| Do | Don't |
|----|-------|
| Fulfill in `@on_payment_success` | Fulfill only on the success redirect |
| Treat webhooks as truth | Trust query-string redirect payloads alone |
| Keep API keys + PIN server-side | Put secrets in frontend / templates |

If a merchant handler crashes after acknowledgment, the payment stays **succeeded**. Fix the bug and use **Replay fulfillment handlers** in admin -never roll back a verified payment because fulfillment failed.

---

## Settings reference

All keys live under `HESABPAY = { ... }`.

### Required / common

| Key | Default | Description |
|-----|---------|-------------|
| `ENVIRONMENT` | `"sandbox"` | `"sandbox"` or `"production"` |
| `SANDBOX_API_KEY` | `""` | Used when environment is sandbox |
| `PRODUCTION_API_KEY` | `""` | Used when environment is production |
| `PUBLIC_BASE_URL` | `""` | Public HTTPS origin for redirects (`SITE_URL` alias OK) |
| `SUCCESS_URL` | `None` | Absolute URL, path, or Django route name |
| `FAILURE_URL` | `None` | Absolute URL, path, or Django route name |
| `HANDLERS` | `[]` | Modules to import so decorators register |
| `LOG_LEVEL` | `"INFO"` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` for HesabPay logs |

### Webhooks & URLs

| Key | Default |
|-----|---------|
| `WEBHOOK_ENABLED` | `True` |
| `AUTO_REGISTER_URLS` | `True` |
| `WEBHOOK_PATH` | `"/hesabpay/webhook/"` |
| `STATUS_PATH` | `"/hesabpay/status/<str:reference>/"` |

### Items (create-session line items)

| Key | Default |
|-----|---------|
| `ITEM_ID_FIELD` | `"id"` |
| `ITEM_NAME_FIELD` | `"name"` |
| `ITEM_PRICE_FIELD` | `"price"` |
| `ITEM_RELATIONS` | `("items", "lines", "orderitem_set")` |

### Multi-vendor

| Key | Default | Description |
|-----|---------|-------------|
| `MERCHANT_PIN` | `""` | Plain account PIN (encrypted on the wire -see [Encrypt PIN](https://docs.hesab.com/api-reference/encrypt-pin/)) |
| `AUTO_VENDOR_TRANSFER` | `False` | Run `@vendor_resolver` after first success |
| `VENDOR_RESOLVER` | `None` | Optional dotted callable (prefer `@vendor_resolver`) |

### Optional object updates

| Key | Default |
|-----|---------|
| `AUTO_UPDATE_STATUS` | `False` |
| `STATUS_FIELD` | `"status"` |
| `PAID_VALUE` | `"paid"` |
| `FAILED_VALUE` | `"failed"` |

### HTTP

| Key | Default |
|-----|---------|
| `TIMEOUT_SECONDS` | `30.0` |

API bases (fixed by environment):

- Sandbox: `https://api-sandbox.hesab.com`
- Production: `https://api.hesab.com`

---

## Checkout

```python
import hesabpay

session = hesabpay.checkout(
    order,                          # any model instance (GFK stored locally)
    user=request.user,              # optional; stored on the payment
    email="buyer@example.com",      # optional; falls back to user.email
    items=order.lines.all(),        # optional; else ITEM_RELATIONS / instance itself
    success_url="/ok/",             # optional override
    failure_url="/fail/",
    request=request,                # helps build absolute URLs when needed
    metadata={"order_number": "A-100"},
    auto_vendor_transfer=False,     # optional per-payment override (see Multi-vendor)
)

session.url            # hosted checkout URL
session.payment_id     # local reference (sent to HesabPay as user_id)
session.status         # pending | ...
session.as_dict()      # JSON-friendly payload
session.payment        # HesabPayPayment row
```

### What gets sent to HesabPay

Only documented create-session fields: `user_id` (payment reference), `items[{id,name,price}]`, optional `email`, `redirect_success_url`, `redirect_failure_url`.

Redirect URLs get `?hesabpay_payment=<reference>` appended so your success page can poll status.

### Resolving line items

1. Explicit `items=...`, or
2. First matching relation on the instance from `ITEM_RELATIONS`, or
3. The instance itself as a single payable item (`id` / `name` / `price` fields -configurable)

```python
# Single product
session = hesabpay.checkout(product, user=request.user)

# Order with lines
session = hesabpay.checkout(order, user=request.user, items=order.lines.all())
```

### Optional model mixin

```python
from hesabpay.models import HesabPayPayableMixin

class Order(HesabPayPayableMixin, models.Model):
    ...

session = order.create_hesabpay_checkout(user=request.user)
```

---

## Templates

```django
{% load hesabpay %}

{# Creates a session immediately and renders a link #}
{% hesabpay_checkout_button order user=request.user %}

{% hesabpay_checkout_button order user=request.user label="Pay now" css_class="btn btn-primary" %}
```

For more control, call `hesabpay.checkout(...)` in the view and pass `session.url` into the template.

---

## JSON / API responses

```python
from django.http import JsonResponse
import hesabpay

def api_checkout(request, order):
    session = hesabpay.checkout(order, user=request.user)
    return JsonResponse(session.as_dict())
```

Example body:

```json
{
  "payment_id": "hp_…",
  "checkout_url": "https://…",
  "status": "pending",
  "auto_vendor_transfer": null,
  "items": [
    {"id": "12", "name": "Course access", "price": 250.0}
  ]
}
```

`auto_vendor_transfer` is `true` / `false` when you set it on checkout; otherwise `null` (follow global setting).

---

## Fulfillment handlers

Register handler modules in settings so Django imports them at startup:

```python
HESABPAY = {
    # ...
    "HANDLERS": ["shop.payments", "billing.hesabpay_handlers"],
}
```

```python
# shop/payments.py
from hesabpay import on_payment_success, on_payment_failed

@on_payment_success
def on_ok(context):
    if not context.is_first_success:
        return
    # enroll / unlock / email / ship / …

@on_payment_failed
def on_fail(context):
    notify_ops(context.payment_id, context.raw_payload)
```

Handlers run **after** the payment row is updated, inside `transaction.on_commit`. Handler failures are logged; the payment stays acknowledged.

Django signals are also available for advanced wiring:

`payment_succeeded`, `payment_failed`, `webhook_verified`, `payment_unmatched`, `vendor_transfer_succeeded`, `vendor_transfer_failed`.

---

## Payment context

`@on_payment_success` / `@on_payment_failed` receive a `HesabPayPaymentContext`:

| Attribute | Meaning |
|-----------|---------|
| `payment` | Local `HesabPayPayment` |
| `event` | Stored webhook event (if any) |
| `payload` | Parsed HesabPay webhook fields |
| `instance` | Your GFK object (order, product, …) |
| `user` | Django user on the payment (if any) |
| `items` | Local `HesabPayPaymentItem` rows |
| `item_instances` | Resolved real item objects when possible |
| `webhook_items` | `items[]` from the HesabPay webhook payload |
| `raw_payload` | Full webhook JSON |
| `transaction_id` | HesabPay transaction id |
| `amount` | Decimal amount |
| `succeeded` | bool |
| `is_first_success` | `True` only on the first successful processing |

```python
@on_payment_success
def fulfill(context):
    data = context.as_dict()
    # payment_id, status, transaction_id, amount, currency, email,
    # succeeded, is_first_success, user_id, django_user_id,
    # sender_account, memo, transaction_date,
    # instance_type, instance_id, items, webhook_items, item_count
```

Notes:

- HesabPay `user_id` in the webhook is the **payment reference**, not the Django user pk.
- `django_user_id` is the auth user id when one was attached at checkout.

---

## Status polling

Useful right after the browser redirect (webhook may arrive slightly later):

```http
GET /hesabpay/status/hp_your_reference/
```

```json
{
  "payment_id": "hp_…",
  "status": "succeeded",
  "transaction_id": "…",
  "amount": "250.00",
  "currency": "AFN",
  "items": [{"id": "12", "name": "Course access", "price": 250.0}]
}
```

Disable auto URL registration and include manually if you prefer:

```python
# urls.py
path("payments/", include("hesabpay.urls")),
```

```python
HESABPAY = {"AUTO_REGISTER_URLS": False, ...}
```

---

## Multi-vendor transfers

After a customer pays once, you may split funds to vendor accounts via
[`send-money-MultiVendor`](https://docs.hesab.com/) with an
[encrypted PIN](https://docs.hesab.com/api-reference/encrypt-pin/).

Store the **plain** PIN in settings. The SDK encrypts it (AES-CBC, API-key-derived key) on every request -never send a plaintext PIN on the wire.

```python
HESABPAY = {
    # ...
    "MERCHANT_PIN": "1234",
    "AUTO_VENDOR_TRANSFER": False,  # recommended default
}
```

### Manual (recommended)

```python
from hesabpay import on_payment_success, transfer_to_vendors

@on_payment_success
def settle(context):
    if not context.is_first_success:
        return
    transfer_to_vendors(
        payment=context.payment,
        vendors=[
            {"account_number": "700000001", "amount": 50},
            {"account_number": "700000002", "amount": 15},
            # dozens more -auto-batched
        ],
    )
```

`transfer_to_vendors(...)`:

- merges duplicate `account_number`s (sums amounts)
- splits into batches of **15** (HesabPay max is 16; one slot headroom)
- returns `list[HesabPayVendorPayout]` (one row per batch)
- is idempotent per payment + vendor chunk fingerprint

Override batch size if needed: `transfer_to_vendors(..., batch_size=15)`.

Helper: `transfer_to_vendors_from_context(context)` uses `@vendor_resolver` / settings resolver to collect vendors, then transfers.

### Automatic via `@vendor_resolver`

```python
HESABPAY = {
    # ...
    "MERCHANT_PIN": "1234",
    "AUTO_VENDOR_TRANSFER": True,
    "HANDLERS": ["shop.payments"],
}
```

```python
from hesabpay import vendor_resolver

@vendor_resolver
def vendors_for(context):
    order = context.instance
    return [
        {"account_number": line.vendor_account, "amount": float(line.price_afn)}
        for line in order.lines.all()
        if line.vendor_account
    ]
```

Returning `[]` skips auto settlement for that payment.

### Per-checkout opt-out / opt-in

Large apps often auto-settle most orders but handle some manually:

```python
# Skip auto transfer even when AUTO_VENDOR_TRANSFER is True
session = hesabpay.checkout(order, user=request.user, auto_vendor_transfer=False)

# Force auto transfer for this payment even when the global setting is False
session = hesabpay.checkout(order, user=request.user, auto_vendor_transfer=True)
```

The flag is stored on `payment.metadata["auto_vendor_transfer"]` and wins over the global setting.

```python
from hesabpay import payment_allows_auto_vendor_transfer

if not payment_allows_auto_vendor_transfer(payment):
    transfer_to_vendors(payment=payment, vendors=manual_list)
```

---

## Admin

Open Django admin → **HesabPay**:

- **Payments** -status badges, item snapshots, activity timeline, setup checklist (env, API key present, webhook path)
- **Webhook events** -verification / processing state; action **Replay fulfillment handlers**
- **Vendor payouts** -batch records; action **Retry failed vendor transfers**
- **Logs** -human-readable timeline (secrets masked)

---

## Commands

```bash
# Config + webhook path + API base
python manage.py hesabpay doctor

# Locally fire a fake verified webhook for a payment reference
python manage.py hesabpay simulate-webhook <payment_reference>
python manage.py hesabpay simulate-webhook <payment_reference> --failure
```

---

## Errors

Friendly exceptions from `hesabpay.exceptions` (often include a `Hint: …`):

| Exception | When |
|-----------|------|
| `HesabPayConfigurationError` | Missing API key / PIN / bad config |
| `HesabPayAuthenticationError` | API rejected credentials |
| `HesabPayValidationError` | No items, unpaid payout, bad input |
| `HesabPayAPIError` | Upstream HTTP / API failure |
| `HesabPayPayoutError` | Multi-vendor transfer failed |
| `HesabPayError` | Base class |

```python
from hesabpay.exceptions import HesabPayError

try:
    session = hesabpay.checkout(order, user=request.user)
except HesabPayError as exc:
    logger.exception("checkout failed: %s", exc)
```

System checks: `hesabpay.W001` (missing API key), `W002` (production without production key), `W003` (bad `HANDLERS` import).

---

## Security notes

- Keep `SANDBOX_API_KEY` / `PRODUCTION_API_KEY` and `MERCHANT_PIN` in environment / secret store -never in git or the browser.
- PIN is stored plain in settings and **encrypted per request** with your API key ([docs](https://docs.hesab.com/api-reference/encrypt-pin/)). Pre-encrypting the PIN with the same API key does not meaningfully harden a settings leak.
- Webhook signature verification is mandatory (`POST /api/v1/hesab/webhooks/verify-signature`).
- Set `WEBHOOK_ENABLED=False` only for local experiments -the webhook view returns 503 when disabled.

---

## Public API surface

```python
import hesabpay
from hesabpay import (
    checkout,                      # also hesabpay.checkout
    on_payment_success,
    on_payment_failed,
    vendor_resolver,
    transfer_to_vendors,
    transfer_to_vendors_from_context,
    payment_allows_auto_vendor_transfer,
    HesabPayPaymentContext,
    DEFAULT_VENDOR_BATCH_SIZE,     # 15
)
```

---

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## License

MIT
