Metadata-Version: 2.4
Name: pay-pak
Version: 1.0.0
Summary: Unified Python SDK for Pakistani payment gateways: JazzCash, EasyPaisa, Safepay, NayaPay. One API, signature-verified callbacks, FastAPI & Django plugins.
Project-URL: Homepage, https://pypi.org/project/pay-pak/
Project-URL: Documentation, https://github.com/mkdeveloper/pay-pak#readme
Project-URL: Repository, https://github.com/mkdeveloper/pay-pak
Project-URL: Issues, https://github.com/mkdeveloper/pay-pak/issues
Project-URL: Changelog, https://github.com/mkdeveloper/pay-pak/blob/main/CHANGELOG.md
Author-email: MK Developer <devtesting123mk@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: easypaisa,fintech,jazzcash,nayapay,pakistan,payfast,payment-gateway,payments,pkr,raast,safepay
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
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.0
Requires-Dist: pycryptodome>=3.20
Requires-Dist: pydantic-settings>=2.0
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: django>=4.2; extra == 'dev'
Requires-Dist: fastapi>=0.100.0; extra == 'dev'
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
Provides-Extra: redis
Requires-Dist: redis>=5.0; extra == 'redis'
Description-Content-Type: text/markdown

# pay-pak

[![Python](https://img.shields.io/badge/Python-3.10%2B-blue?logo=python&logoColor=white)](https://pypi.org/project/pay-pak/)
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-55%20passed-success)](#testing)

**pay-pak** is a unified Python SDK for accepting payments through Pakistani payment gateways:
**JazzCash**, **EasyPaisa**, **Safepay**, **NayaPay** (plus **PayFast** and **Raast** scaffolds).

One API for every gateway. Every driver verifies callback signatures with constant-time comparison and **never fakes** flows it does not support.

Python port of the Laravel package [`pak-pay/pak-pay`](https://github.com/pak-pay/pak-pay).

```bash
pip install pay-pak
```

```python
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest

paypak = PayPak()
request = PaymentRequest(amount=150_00, order_id="ORDER-1001")

paypak.gateway("jazzcash").checkout(request)        # hosted redirect
paypak.gateway("jazzcash").charge(request)        # direct wallet
paypak.gateway("jazzcash").verify_callback(inbound) # signed callback
```

---

## Table of contents

1. [What is pay-pak?](#what-is-pay-pak)
2. [How it works](#how-it-works)
3. [Requirements](#requirements)
4. [Installation](#installation)
5. [Quick start](#quick-start)
6. [Configuration](#configuration)
7. [Usage](#usage)
8. [Framework integration](#framework-integration)
9. [API reference](#api-reference)
10. [Gateway capability matrix](#gateway-capability-matrix)
11. [Payment states](#payment-states)
12. [Security model](#security-model)
13. [Troubleshooting](#troubleshooting)
14. [Testing](#testing)
15. [Publishing to PyPI](#publishing-to-pypi)
16. [License](#license)

---

## What is pay-pak?

If you run an online shop in Pakistan, customers pay with **JazzCash**, **EasyPaisa**, **Safepay**, **NayaPay**, and others. Each gateway has different APIs, signing rules, and callback formats.

**pay-pak** is a single Python library that handles all of that for you:

1. Customer clicks **Pay** on your site.
2. Your app calls `checkout()` with amount and order id.
3. Customer is sent to the gateway hosted page.
4. Gateway returns a signed callback to your app.
5. You call `verify_callback()` — pay-pak checks the signature and tells you **paid** or **not paid**.

You write a few lines; pay-pak handles signing, security, and gateway-specific details.

> **Status:** JazzCash, EasyPaisa, Safepay, NayaPay are fully implemented. PayFast and Raast are scaffolds (they raise `UnsupportedFlowException` until implemented).

---

## How it works

```
Your app  →  PayPak.gateway("jazzcash")  →  GatewayDriver  →  Payment gateway
Gateway   →  signed callback/webhook     →  verify_callback()  →  PaymentResult
```

**Hosted checkout flow:**

1. `checkout()` builds a signed payload and stores it behind a one-time token.
2. Your app redirects the customer to `/{prefix}/redirect/{token}`.
3. The redirect handler renders an auto-submitting HTML form POST to the gateway.
4. Customer pays on the gateway page.
5. Gateway POSTs/redirects back to your `return_url`.
6. `verify_callback()` verifies `pp_SecureHash` (or gateway equivalent) before trusting any field.

---

## Requirements

- Python **3.10+**
- Core dependencies (installed automatically): `httpx`, `pydantic`, `pydantic-settings`, `pycryptodome`

---

## Installation

### Core package

```bash
pip install pay-pak
```

### Optional extras

```bash
pip install "pay-pak[fastapi]"   # FastAPI redirect router + helpers
pip install "pay-pak[django]"    # Django redirect view + helpers
pip install "pay-pak[redis]"     # Redis cache for multi-worker deployments
pip install "pay-pak[dev]"       # pytest, ruff, mypy (contributors)
```

### From source (development)

```bash
git clone https://github.com/your-org/pay-pak.git
cd pay-pak
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
```

---

## Quick start

### 1. Set environment variables

Create a `.env` file (or export variables):

```env
PAKPAY_MODE=sandbox
PAKPAY_GATEWAY=jazzcash

JAZZCASH_MERCHANT_ID=your_merchant_id
JAZZCASH_PASSWORD=your_password
JAZZCASH_INTEGRITY_SALT=your_salt
JAZZCASH_RETURN_URL=https://your-app.com/payments/jazzcash/return
```

### 2. Start a payment

```python
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest

paypak = PayPak()
request = PaymentRequest(
    amount=150_00,              # PKR 150.00 — always integer paisa
    order_id="ORDER-1001",
    description="Order #1001",
)

checkout = paypak.gateway("jazzcash").checkout(request)
# checkout.redirect_url → e.g. /pakpay/redirect/{uuid}
# checkout.token        → one-time token for the redirect handler
```

Redirect the customer with HTTP 302 to `checkout.redirect_url`.

### 3. Verify the callback

```python
from pay_pak.dto import InboundRequest
from pay_pak.exceptions import PakPayException

# Build InboundRequest from your web framework (see Framework integration)
result = paypak.gateway("jazzcash").verify_callback(inbound)

if result.is_paid():
    # Fulfill order — store result.transaction_id for refunds/status
    print(f"Paid! txn={result.transaction_id}")
else:
    print(f"Not paid: {result.message}")
```

### Standalone demo (no web server)

```bash
python examples/demo.py
```

Signs a JazzCash payload, verifies a genuine callback, and rejects a tampered one.

---

## Configuration

### Global settings

| Variable | Default | Description |
|----------|---------|-------------|
| `PAKPAY_MODE` | `sandbox` | `sandbox` or `production` — flips **all** gateway endpoints |
| `PAKPAY_GATEWAY` | `jazzcash` | Default gateway when calling `paypak.checkout()` without `gateway()` |
| `PAKPAY_ROUTE_PREFIX` | `pakpay` | URL prefix for hosted redirect (`/{prefix}/redirect/{token}`) |
| `PAKPAY_REDIRECT_BASE_URL` | `""` | Optional absolute base URL prepended to redirect paths |

### Programmatic configuration

```python
from pay_pak import PayPak
from pay_pak.config import PayPakSettings, default_gateways_config

settings = PayPakSettings(
    mode="sandbox",
    default_gateway="jazzcash",
    route_prefix="pakpay",
    redirect_base_url="https://shop.example.com",
)

# Override gateway config dict (e.g. in tests)
gateways = default_gateways_config()
gateways["jazzcash"]["merchant_id"] = "MC1234"

paypak = PayPak(settings=settings, gateways=gateways)
```

### JazzCash

Credentials from your JazzCash merchant portal (Integration → API keys).

| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `JAZZCASH_MERCHANT_ID` | ✅ | — | Merchant ID (`pp_MerchantID`) |
| `JAZZCASH_PASSWORD` | ✅ | — | Merchant password (`pp_Password`) |
| `JAZZCASH_INTEGRITY_SALT` | ✅ | — | HMAC-SHA256 signing salt |
| `JAZZCASH_RETURN_URL` | ✅ | — | Callback URL where JazzCash posts the result |
| `JAZZCASH_CURRENCY` | ❌ | `PKR` | Transaction currency |
| `JAZZCASH_LANGUAGE` | ❌ | `EN` | Hosted form language |
| `JAZZCASH_HOSTED_SEND_PASSWORD` | ❌ | `true` | Set `false` to omit password from browser HTML form |

```env
JAZZCASH_MERCHANT_ID=
JAZZCASH_PASSWORD=
JAZZCASH_INTEGRITY_SALT=
JAZZCASH_RETURN_URL=https://your-app.com/payments/jazzcash/return
```

### EasyPaisa

| Variable | Required | Description |
|----------|:--------:|-------------|
| `EASYPAISA_STORE_ID` | ✅ | Store ID from onboarding pack |
| `EASYPAISA_HASH_KEY` | ✅ | 16-byte AES key for `merchantHashedReq` |
| `EASYPAISA_ACCOUNT_NUM` | ✅ | Merchant account number |
| `EASYPAISA_RETURN_URL` | ✅ | Signed return URL |

### Safepay

| Variable | Required | Description |
|----------|:--------:|-------------|
| `SAFEPAY_CLIENT_KEY` | ✅ | Public key (safe for browser / Smart Button) |
| `SAFEPAY_SECRET_KEY` | ✅ | Secret API key (server-side only) |
| `SAFEPAY_WEBHOOK_SECRET` | ✅ | Webhook signing secret |
| `SAFEPAY_RETURN_URL` | ✅ | Success return URL |
| `SAFEPAY_CANCEL_URL` | ❌ | Cancel return URL |
| `SAFEPAY_CURRENCY` | ❌ | Default `PKR` |

### NayaPay

| Variable | Required | Description |
|----------|:--------:|-------------|
| `NAYAPAY_MERCHANT_ID` | ✅ | Merchant ID |
| `NAYAPAY_API_KEY` | ✅ | API key (Bearer token for status API) |
| `NAYAPAY_HASH_KEY` | ✅ | HMAC signing key |
| `NAYAPAY_RETURN_URL` | ✅ | Signed return URL |
| `NAYAPAY_CURRENCY` | ❌ | Default `PKR` |

### PayFast / Raast (scaffold only)

Env keys `PAYFAST_*` and `RAAST_*` are reserved. Drivers resolve but every flow raises `UnsupportedFlowException`.

---

## Usage

> **Golden rule:** amounts are always **integers in paisa**. `150_00` = PKR 150.00. Never use floats.

### Hosted checkout (redirect)

```python
checkout = paypak.gateway("jazzcash").checkout(request)
# RedirectResponse: checkout.redirect_url, checkout.token
```

Works for JazzCash, EasyPaisa, NayaPay (token + auto-submit form). Safepay returns a direct URL to hosted checkout (no token).

### Direct wallet charge (JazzCash)

```python
from pay_pak.dto import Customer

request = PaymentRequest(
    amount=150_00,
    order_id="ORDER-1002",
    customer=Customer(mobile="03019680302", cnic_last6="123456"),
)
result = paypak.gateway("jazzcash").charge(request)
```

### EasyPaisa mobile account (OTP)

```python
request = PaymentRequest(
    amount=150_00,
    order_id="ORDER-1003",
    customer=Customer(mobile="03019680302", email="ali@shop.test"),
)
result = paypak.gateway("easypaisa").charge(request)
```

### Safepay Smart Button (frontend)

```python
data = paypak.gateway("safepay").smart_button_data(request)
# data: environment, clientKey, tracker, orderId, amount, currency
```

Pass `data` to the Safepay JavaScript Smart Payment Button on your frontend.

### Safepay webhook

```python
inbound = InboundRequest(
    method="POST",
    query={},
    form={},
    body=raw_body_bytes,
    headers={"x-sfpy-signature": signature_header},
)
result = paypak.gateway("safepay").verify_callback(inbound)
```

### Status inquiry

```python
status = paypak.gateway("jazzcash").status("T20260101120000ABCDEF")
if status.is_paid():
    ...
```

### Refund

```python
refund = paypak.gateway("jazzcash").refund("T20260101120000ABCDEF", 50_00)
if refund.success:
    print(refund.refund_id)
```

### Switch gateways

```python
paypak.gateway("jazzcash").checkout(request)
paypak.gateway("easypaisa").checkout(request)
paypak.gateway("safepay").checkout(request)
paypak.gateway("nayapay").checkout(request)
```

---

## Framework integration

### Flask

```python
from flask import Flask, redirect, request
from pay_pak import PayPak
from pay_pak.dto import InboundRequest, PaymentRequest
from pay_pak.redirect import consume_redirect_payload, render_auto_submit_form

app = Flask(__name__)
paypak = PayPak()

@app.get("/pay")
def pay():
    req = PaymentRequest(amount=150_00, order_id="ORDER-1")
    checkout = paypak.gateway("jazzcash").checkout(req)
    return redirect(checkout.redirect_url)

@app.get("/pakpay/redirect/<token>")
def pakpay_redirect(token: str):
    result = consume_redirect_payload(paypak.cache, token)
    if result is None:
        return "Redirect expired. Please restart checkout.", 410
    action, fields, _ = result
    return render_auto_submit_form(action, fields)

@app.route("/payments/jazzcash/return", methods=["GET", "POST"])
def jazzcash_return():
    inbound = InboundRequest(
        method=request.method,
        query={k: str(v) for k, v in request.args.items()},
        form={k: str(v) for k, v in request.form.items()},
        body=request.get_data(),
        headers={k.lower(): v for k, v in request.headers.items()},
    )
    result = paypak.gateway("jazzcash").verify_callback(inbound)
    return "Thank you!" if result.is_paid() else "Payment failed"
```

### FastAPI

```bash
pip install "pay-pak[fastapi]"
```

```python
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
from pay_pak_fastapi import create_pakpay_router, to_inbound_request_async

app = FastAPI()
paypak = PayPak()

app.include_router(create_pakpay_router(prefix="pakpay", cache=paypak.cache))

@app.get("/pay")
def pay():
    req = PaymentRequest(amount=150_00, order_id="ORDER-1")
    checkout = paypak.gateway("jazzcash").checkout(req)
    return RedirectResponse(checkout.redirect_url, status_code=302)

@app.api_route("/payments/jazzcash/return", methods=["GET", "POST"])
async def jazzcash_return(request: Request):
    inbound = await to_inbound_request_async(request)
    result = paypak.gateway("jazzcash").verify_callback(inbound)
    return {"paid": result.is_paid(), "txn": result.transaction_id}
```

### Django

```bash
pip install "pay-pak[django]"
```

```python
# urls.py
from django.urls import path
from pay_pak_django import redirect_view, set_cache
from pay_pak import PayPak

paypak = PayPak()
set_cache(paypak.cache)

urlpatterns = [
    path("pakpay/redirect/<uuid:token>/", redirect_view, name="paypak_redirect"),
]
```

```python
# views.py
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from pay_pak import PayPak
from pay_pak.dto import PaymentRequest
from pay_pak_django import to_inbound_request

paypak = PayPak()

def start_payment(request):
    req = PaymentRequest(amount=150_00, order_id="ORDER-1")
    checkout = paypak.gateway("jazzcash").checkout(req)
    from django.shortcuts import redirect
    return redirect(checkout.redirect_url)

@csrf_exempt
def jazzcash_return(request):
    result = paypak.gateway("jazzcash").verify_callback(to_inbound_request(request))
    return JsonResponse({"paid": result.is_paid()})
```

### Redis cache (production, multi-worker)

```bash
pip install "pay-pak[redis]"
```

```python
from pay_pak.cache import RedisCache
from pay_pak import PayPak

paypak = PayPak(cache=RedisCache("redis://localhost:6379/0"))
```

Redirect tokens and Safepay passport tokens are shared across workers.

---

## API reference

### `PayPak` (alias for `PayPakManager`)

| Method | Description |
|--------|-------------|
| `gateway(name=None)` | Resolve a gateway driver (`jazzcash`, `easypaisa`, `safepay`, `nayapay`, `payfast`, `raast`) |
| `checkout(request)` | Proxy to default gateway `checkout()` |
| `charge(request)` | Proxy to default gateway `charge()` |
| `verify_callback(inbound)` | Proxy to default gateway `verify_callback()` |
| `refund(txn_id, amount)` | Proxy to default gateway `refund()` |
| `status(txn_id)` | Proxy to default gateway `status()` |

### `GatewayDriver` methods

| Method | Returns | Description |
|--------|---------|-------------|
| `checkout(request)` | `CheckoutResponse` | Start hosted payment |
| `charge(request)` | `PaymentResult` | Direct wallet / OTP charge |
| `verify_callback(inbound)` | `PaymentResult` | Verify signed callback/webhook |
| `refund(txn_id, amount)` | `RefundResult` | Refund a transaction |
| `status(txn_id)` | `PaymentStatus` | Query transaction status |

### `PaymentRequest`

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `amount` | `int` | ✅ | Amount in paisa (> 0) |
| `order_id` | `str` | ✅ | Your unique order reference |
| `currency` | `str` | ❌ | Default `PKR` |
| `description` | `str \| None` | ❌ | Shown on hosted page |
| `customer` | `Customer \| None` | ❌ | Required for wallet `charge()` |
| `return_url` | `str \| None` | ❌ | Override gateway return URL |
| `meta` | `dict` | ❌ | Arbitrary metadata |

```python
request.amount_as_decimal()  # "150.00"
PaymentRequest.from_dict({"amount": 15000, "order_id": "ORDER-1"})
```

### `Customer`

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str \| None` | Full name |
| `email` | `str \| None` | Email address |
| `mobile` | `str \| None` | MSISDN — required for wallet charge |
| `cnic_last6` | `str \| None` | Last 6 CNIC digits (JazzCash `pp_CNIC`) |

### `PaymentResult` / `PaymentStatus`

| Field | Type | Description |
|-------|------|-------------|
| `success` | `bool` | Whether operation succeeded |
| `state` | `str` | Normalised `PaymentState` value |
| `transaction_id` | `str \| None` | Gateway transaction id |
| `order_id` | `str \| None` | Your order id |
| `amount` | `int \| None` | Amount in paisa |
| `gateway_code` | `str \| None` | Raw gateway response code |
| `message` | `str \| None` | Human-readable message |
| `raw` | `dict` | Full gateway payload for auditing |

```python
result.is_paid()  # success and state == "paid"
```

### `CheckoutResponse`

| Field | Type | Description |
|-------|------|-------------|
| `redirect_url` | `str` | URL to redirect the customer |
| `token` | `str \| None` | One-time redirect token (null for Safepay direct URL) |

### `InboundRequest`

Framework-agnostic HTTP request for `verify_callback()`:

| Field | Type | Description |
|-------|------|-------------|
| `method` | `str` | HTTP method |
| `query` | `dict[str, str]` | Query parameters |
| `form` | `dict[str, str]` | Form fields |
| `body` | `bytes` | Raw body (webhooks) |
| `headers` | `dict[str, str]` | Lowercase header names |

### Exceptions

| Exception | When raised |
|-----------|-------------|
| `PakPayException` | Base class — catch for any pay-pak error |
| `GatewayException` | Misconfiguration or gateway error response |
| `SignatureVerificationException` | Invalid, missing, or replayed signature |
| `UnsupportedFlowException` | Flow not supported by gateway (never faked) |

---

## Gateway capability matrix

| Gateway | checkout | charge | refund | status | verify |
|---------|:--------:|:------:|:------:|:------:|:------:|
| `jazzcash` | ✅ | ✅ Mobile Wallet | ✅ | ✅ | ✅ HMAC `pp_SecureHash` |
| `easypaisa` | ✅ | ✅ Mobile Account | ❌ | ✅ | ✅ HMAC return signature |
| `safepay` | ✅ | ❌ PCI | ✅ | ✅ | ✅ `X-SFPY-Signature` webhook |
| `nayapay` | ✅ | ❌ PCI | ❌ | ✅ | ✅ HMAC `signature` |
| `payfast` | 🚧 scaffold | 🚧 | 🚧 | 🚧 | 🚧 |
| `raast` | 🚧 scaffold | 🚧 | 🚧 | 🚧 | 🚧 |

---

## Payment states

All gateways normalise to one vocabulary (`pay_pak.enums.PaymentState`):

| State | Value | Meaning |
|-------|-------|---------|
| `PENDING` | `pending` | Created, no money moved |
| `PROCESSING` | `processing` | Redirected / OTP issued |
| `PAID` | `paid` | Funds captured |
| `FAILED` | `failed` | Declined or abandoned |
| `CANCELLED` | `cancelled` | Cancelled before completion |
| `REFUNDED` | `refunded` | Funds returned |
| `UNKNOWN` | `unknown` | Unmapped gateway state |

```python
from pay_pak.enums.payment_state import PaymentState

if result.state == PaymentState.PAID:
    ...
PaymentState.is_successful(result.state)  # True only for PAID
PaymentState.is_final(result.state)       # PAID, FAILED, CANCELLED, REFUNDED
```

---

## Security model

- **Signature first:** every callback is verified with `hmac.compare_digest` before any field is trusted.
- **One-time redirect tokens:** 10-minute TTL; consumed on first use (replay → HTTP 410).
- **Safepay replay protection:** optional `X-SFPY-Timestamp` header; rejects payloads older than 300 seconds.
- **No PCI card data:** Safepay and NayaPay `charge()` intentionally raise `UnsupportedFlowException`.
- **No fake flows:** unsupported operations raise `UnsupportedFlowException`, never return fake success.
- **Secrets in env only:** never hardcode merchant passwords or salts in source code.
- **JazzCash password in browser:** `JAZZCASH_HOSTED_SEND_PASSWORD=false` keeps `pp_Password` out of the hosted HTML form when your integration allows it.

---

## Troubleshooting

| Error | Cause | Fix |
|-------|-------|-----|
| `Missing required configuration [merchant_id]` | Env var not set | Add credentials to `.env` |
| `Signature verification failed` | Wrong salt/secret or tampered payload | Check `INTEGRITY_SALT` / `HASH_KEY`; never trust payload before verify |
| `Gateway [easypaisa] does not support the [refund] flow` | Unsupported flow | Use a gateway that supports refund, or handle manually |
| `Cache backend is required for checkout` | No cache passed | Use `PayPak(cache=MemoryCache())` or Redis |
| `Redirect expired` (410) | Token reused or expired | Customer must restart checkout |
| `ValueError: amount must be positive` | Amount ≤ 0 or float used | Use int paisa: `150_00` not `150.0` |

---

## Testing

```bash
pip install -e ".[dev]"
pytest -v
```

55 tests cover signature vectors (matching PHP test suite), driver flows, redirect replay, and framework plugins.

---

## Publishing to PyPI

See [docs/PUBLISHING.md](docs/PUBLISHING.md) for step-by-step instructions.

Quick summary:

```bash
pip install build twine
python -m build
twine upload dist/*
```

---

## Author

**MK Developer** — devtesting123mk@gmail.com

---

## License

MIT — Copyright (c) 2026 MK Developer. See [LICENSE](LICENSE).
