# سیستم API هامرزپی

راهنمای کامل لایه‌ی API — معماری، مدل‌های احراز هویت، قراردادهای مشترک، جریان‌های کاری، و چک‌لیست توسعه.

| منبع | آدرس | برای چه کسی |
|---|---|---|
| همین سند | `docs/api/README.md` | تیم داخلی — «چرا» و «چطور» |
| مرجع اندپوینت‌ها | <https://hmzptest.ir/docs/api> | توسعه‌دهنده‌ی یکپارچه‌ساز — «چه چیزی» |
| spec ماشین‌خوان | <https://hmzptest.ir/docs/api/openapi.yaml> | Postman / تولید کلاینت |
| تولید مجدد مرجع | `python3 docs/api/build-reference.py` | بعد از هر تغییر spec |

> `openapi.yaml` منبع حقیقت قرارداد است. صفحه‌ی HTML صرفاً رندر آن است و با اسکریپت بالا از رویش ساخته می‌شود.

---

## ۱. نقشه‌ی کلی

سیستم ۹ خانواده‌ی API دارد که در **مدل احراز هویت** با هم فرق می‌کنند. این تفاوت مهم‌ترین چیزی است که باید بدانید — بقیه‌ی طراحی از آن می‌آید.

| خانواده | پیشوند | احراز هویت | فایل روت |
|---|---|---|---|
| مشتری | `/api/customer/*` | session (OTP) | `routes/api_customer.php` |
| پذیرنده | `/api/merchant/*` | session (OTP) | `routes/api_merchant.php` |
| سازمان | `/api/org/*` | session (OTP) | `routes/api_organization.php` |
| مدیریت | `/api/admin/*` | session (رمز عبور) | `routes/api_admin.php` |
| پوز و چک‌اوت | `/api/v1/pos/*` | API key + secret | `routes/api_pos.php` |
| پارتنر بانکی | `/api/v1/bank/*` | API key + HMAC | `routes/api_bank_partner.php` |
| رضایت مشتری (پارتنر بانکی) | `/api/v1/bank/consent-*` | API key + HMAC + توکن رضایت | `routes/api_bank_partner.php` |
| صفحه‌ی رضایت | `/consent/bank/*` | عمومی + OTP مشتری | `routes/web.php` |
| شرکتی B2B | `/api/v1/corporate/*` | API key + HMAC | `routes/api_corporate.php` |
| صفحه‌ی چک‌اوت | `/checkout/*` | عمومی + OTP مشتری | `routes/web.php` |
| وب‌هوک تأمین‌کننده | `/api/providers/*` | HMAC با secret مشترک | `routes/api.php` |

همه‌ی این‌ها از `routes/api.php` بارگذاری می‌شوند، به‌جز صفحه‌ی چک‌اوت که چون HTML برمی‌گرداند در `routes/web.php` است.

### دو دنیای متفاوت

**APIهای پنل** (چهار تای اول) برای فرانت‌اند خود پنل‌ها هستند: با کوکی session کار می‌کنند، CSRF دارند، و به هاست پنل قفل شده‌اند. **برای یکپارچه‌سازی سرور-به-سرور نیستند.**

**APIهای ماشین‌به‌ماشین** (`/api/v1/*`) بدون session کار می‌کنند، به هاست قفل نیستند و روی هر هاستی که به اپ می‌رسد جواب می‌دهند. این‌ها را به شرکت‌های بیرونی می‌دهید.

---

## ۲. احراز هویت

### ۲-۱. Session — APIهای پنل

دو میدل‌ور پشت سر هم روی هر روت پنل:

```
panel:{name}        → EnsurePanelHost              هدر Host باید با دامنه‌ی پنل بخواند
panel.access:{name} → EnsureAuthenticatedPanelActor  session معتبر + resolve کردن context
```

`EnsurePanelHost` جلوی این را می‌گیرد که مثلاً پنل مشتری از روی دامنه‌ی پذیرنده باز شود. روی `localhost` و محیط local با `hamerzpay.panels.allow_local_bypass` قابل دور زدن است (روی staging خاموش است).

`EnsureAuthenticatedPanelActor` کاربر لاگین‌شده را به **context پنل** تبدیل می‌کند و در `panelContext` می‌گذارد:

| پنل | از کجا resolve می‌شود | کلیدهای context |
|---|---|---|
| customer | `customer_profiles` + `credit_accounts` | `customer`, `creditAccount` |
| merchant | `merchant_operators` (با پشتیبانی سوییچ) | `merchant`, `merchantUser`, `merchantOperator` |
| organization | `organization_members` | `organization`, `organizationUser`, `organizationMember` |
| admin | هر نقش فعال در `admin_user_roles` | `adminUser` |
| corporate | `corporate_companies.panel_owner_mobile` | `corporateUser`, `corporateCompany` |

> **قانون:** کنترلر باید `$request->attributes->get('panelContext')` را بخواند، نه اینکه خودش دوباره از دیتابیس actor را دربیاورد. برای کاربری که عضو چند سازمان است، resolve دوباره ممکن است سازمان اشتباهی را انتخاب کند.

نکته‌ی مهم: روت‌های API پنل در گروه middleware خود **هم `web` و هم `api`** دارند. گروه `api` به‌تنهایی `StartSession` و CSRF ندارد، پس بدون `web` کوکی session اصلاً خوانده نمی‌شود. `web` را از این گروه‌ها حذف نکنید.

### ۲-۲. API key + secret — پوز

ساده‌ترین مدل. دو هدر روی هر درخواست:

```http
X-API-KEY:    hp_live_xxxxxxxxxxxxxxxxxxxxxxxx
X-API-SECRET: <۴۸ کاراکتر>
```

secret در دیتابیس **هش** می‌شود (`merchant_api_keys.secret_hash`، bcrypt)، پس قابل بازیابی نیست. پذیرنده آن را یا موقع پروویژن شدن از پارتنر بانکی می‌گیرد یا از پنل خودش rotate می‌کند.

### ۲-۳. API key + HMAC — شرکتی و پارتنر بانکی

مدل امن‌تر: secret روی سیم فرستاده نمی‌شود، بلکه با آن بدنه امضا می‌شود.

```http
X-API-KEY:    <کلید>
X-TIMESTAMP:  <ثانیه‌ی یونیکس>
X-SIGNATURE:  hmac_sha256("{X-TIMESTAMP}" + "." + <raw body>, <secret>)
```

```bash
ts=$(date +%s)
body='{"account_id":42,"amount":5000000,"reference_id":"INV-1","idempotency_key":"a1b2c3"}'
sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

curl -X POST https://hmzptest.ir/api/v1/corporate/credit/reserve \
  -H "X-API-KEY: $API_KEY" \
  -H "X-TIMESTAMP: $ts" \
  -H "X-SIGNATURE: $sig" \
  -H "Idempotency-Key: a1b2c3" \
  -H "Content-Type: application/json" \
  -d "$body"
```

سه نکته که بیشترین خطا را می‌سازند:

1. **همان بایت‌هایی را امضا کنید که می‌فرستید.** JSON را یک‌بار serialize کنید، همان رشته را امضا کنید و همان را بفرستید. اگر بعد از امضا دوباره serialize کنید (ترتیب کلیدها یا فاصله عوض شود) امضا رد می‌شود.
2. برای `GET` بدنه رشته‌ی خالی است — یعنی امضا روی `"{ts}."` محاسبه می‌شود.
3. ساعت سرورتان باید حداکثر **۳۰۰ ثانیه** با ما اختلاف داشته باشد، وگرنه `expired_timestamp` می‌گیرید.

secret این‌ها **رمزنگاری‌شده** ذخیره می‌شود نه هش‌شده (چون برای تأیید HMAC به متن اصلی نیاز است). ستون `api_secret_hash` نامش گمراه‌کننده است ولی cast آن `encrypted` است.

#### امضای v2 (ضد replay) — `X-SIGNATURE-VERSION: 2`

امضای v1 بالا method و مسیر را پوشش نمی‌دهد و یک درخواست ضبط‌شده تا ۳۰۰ ثانیه قابل ارسال مجدد است. نسخه‌ی ۲ هر دو را می‌بندد و **هم‌زمان با v1 پذیرفته می‌شود** (انتخاب با هدر):

```http
X-API-KEY:            <کلید>
X-TIMESTAMP:          <ثانیه‌ی یونیکس>
X-NONCE:              <۱۶ تا ۱۲۸ کاراکتر [A-Za-z0-9_-]، یکتا برای هر درخواست>
X-SIGNATURE-VERSION:  2
X-SIGNATURE:          hmac_sha256("{METHOD}\n{PATH}\n{TIMESTAMP}\n{NONCE}\n{sha256_hex(raw body)}", <secret>)
```

- `METHOD` با حروف بزرگ (`POST`)، `PATH` همان URI ارسالی با query (مثلاً `/api/v1/corporate/customers?page=2`)، و برای بدنه‌ی خالی `sha256("")`.
- هر `X-NONCE` به ازای هر کلید برای ۲× پنجره‌ی timestamp به خاطر سپرده می‌شود؛ استفاده‌ی دوباره → `401 replayed_nonce`.
- خطاهای جدید: `signature_version_required`، `invalid_signature_version`، `invalid_nonce`، `replayed_nonce` (در شرکتی `legacy_code` همان `INVALID_SIGNATURE` می‌ماند).

```bash
ts=$(date +%s); nonce=$(openssl rand -hex 16); path=/api/v1/corporate/credit/reserve
bh=$(printf '%s' "$body" | openssl dgst -sha256 -r | cut -d' ' -f1)
sig=$(printf 'POST\n%s\n%s\n%s\n%s' "$path" "$ts" "$nonce" "$bh" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
```

| پرچم (`.env`) | پیش‌فرض | اثر وقتی `true` |
|---|---|---|
| `HAMERZPAY_CORPORATE_REQUIRE_SIGNATURE_V2` | `false` | امضای v1 روی `/api/v1/corporate/*` با `signature_version_required` رد می‌شود |
| `HAMERZPAY_BANK_PARTNER_REQUIRE_SIGNATURE_V2` | `false` | همین برای `/api/v1/bank/*` (مستقل از پرچم شرکتی) |

پیش‌فرض‌ها سازگارند؛ پرچم را فقط بعد از مهاجرت همه‌ی کلاینت‌های آن خانواده به v2 روشن کنید.

**چرخش کلید:** برای شرکتی، هم `webhook_secret` و هم `previous_webhook_secret` پذیرفته می‌شوند تا بدون قطعی جابه‌جا شوید. برای پارتنر بانکی، «تعویض رمز» در پنل رمز قبلی را بلافاصله باطل می‌کند.

### ۲-۴. HMAC وب‌هوک — تأمین‌کننده‌ها

همان الگو با هدرهای خودش:

```http
X-Provider-Event-Id:   <یکتا به ازای هر رویداد — مبنای dedupe>
X-Provider-Timestamp:  <ثانیه‌ی یونیکس>
X-Provider-Signature:  hmac_sha256("{timestamp}.{raw body}", <webhook secret>)
```

secret از `hamerzpay.finance.providers.{provider}.webhook_secret` می‌آید. بدنه سقف ۶۴KB دارد. ارسال مجدد همان `event_id` با همان payload بی‌خطر است و نتیجه‌ی اولیه را برمی‌گرداند؛ ارسال مجدد با payload **متفاوت** به‌عنوان تعارض idempotency رد می‌شود.

---

## ۳. قراردادهای مشترک

### پول

همه‌ی مبالغ **عدد صحیح به ریال**‌اند. نه اعشار، نه رشته. `amount`، `credit_amount`، `cash_amount`، `credit_limit` — همه.

### شکل خطا

APIهای ماشین‌به‌ماشین:

```json
{ "success": false, "error": { "code": "credit_unavailable", "message": "..." } }
```

API شرکتی یک لایه بیشتر دارد (`CorporateApiResponse`):

```json
{
  "success": false,
  "error": { "code": "insufficient_credit", "message": "...", "details": {}, "legacy_code": "INSUFFICIENT_CREDIT" },
  "request_id": "..."
}
```

`legacy_code` برای سازگاری با کلاینت‌های قدیمی نگه داشته شده. **همیشه روی `error.code` شرط بگذارید، نه روی متن پیام** — متن‌ها فارسی‌اند و تغییر می‌کنند.

### Idempotency

هر عملیات mutating که پول جابه‌جا می‌کند کلید idempotency می‌گیرد. اگر timeout خوردید، **بی‌خطر دوباره بفرستید** — همان کلید نتیجه‌ی اولیه را برمی‌گرداند، نه یک تراکنش دوم.

سه سطح پیاده‌سازی:

| سطح | کجا | مکانیزم |
|---|---|---|
| هدر | `EnforceIdempotency` (شرکتی) | هدر `Idempotency-Key` الزامی؛ اگر بدنه هم `idempotency_key` دارد باید یکی باشند |
| موتور اعتبار | `BankCreditEngine` | ستون‌های `reserve_idempotency_key` / `capture_idempotency_key` / `cancel_idempotency_key` روی `credit_orders` |
| دفتر کل | `FinancialPostingService` | کلید + هش payload؛ همان کلید با payload متفاوت ← `IdempotencyConflictException` |

### زمان و محدودیت نرخ

| مسیر | سقف IP (قبل از احراز) | سقف هر کلید |
|---|---|---|
| `/api/v1/pos/*` | ۶۰۰ در دقیقه | ۱۲۰ در دقیقه |
| `/api/v1/bank/*` | ۳۰۰ در دقیقه | ۶۰ در دقیقه |
| `/api/v1/bank/consent-*` و `customers/credit` | ۳۰۰ در دقیقه | **۲۰ در دقیقه** |
| `/api/v1/corporate/*` | ۳۰۰ در دقیقه | ۶۰ در دقیقه |
| `/api/{panel}/*` | ۱۲۰ در دقیقه | — |
| `/api/providers/*` | ۱۲۰ در دقیقه | — |
| `/checkout/*` | ۲۰ در دقیقه | — |

> **چرا دو لایه:** throttleهای اختصاصی هر کلید روی credentialِ resolve‌شده کلید می‌خورند، پس هرگز درخواستی را که احراز هویتش شکست خورده نمی‌بینند. گروه `api` لاراول هم هیچ محدودیت نرخی ندارد. بدون لایه‌ی IP که **قبل از** میدل‌ور اعتبارسنجی می‌نشیند، حدس‌زدن کلید کاملاً بدون محدودیت بود. سقف IP عمداً خیلی بالاتر از سقف هر کلید است تا ترافیک احرازشده همچنان با قرارداد خطای limiter اختصاصی خودش مدیریت شود.
>
> این ترتیب با `tests/Feature/MachineApiThrottlingTest.php` قفل شده — **موقع افزودن گروه `/api/v1/*` جدید آن تست را هم به‌روز کنید.**

---

## ۴. خانواده‌ها به تفصیل

### ۴-۱. پوز و چک‌اوت پذیرنده — `/api/v1/pos`

برای یکپارچه‌سازی صندوق فروشگاهی و فروشگاه اینترنتی پذیرنده.

**خرید حضوری (پوز)** دو مرحله‌ای است و هر تراکنش نیازمند تأیید پیامکی مشتری است:

```
POST /orders/initiate   → اعتبارسنجی + ارسال OTP به موبایل مشتری → pending_order_ref
POST /orders/confirm    → با کد OTP: رزرو + تسخیر اتمیک اعتبار → سفارش نهایی
```

در `initiate` **هیچ اعتباری رزرو نمی‌شود** — فقط بررسی و ارسال کد. رزرو و تسخیر با هم و به‌صورت اتمیک در `confirm` انجام می‌شود.

نکات طراحی که عمدی‌اند:

- **فقط اعتبار شخصی.** `findActiveAccountByMobile()` صراحتاً `organization_id IS NULL` می‌گذارد؛ اعتبار سازمانی محصول و گردش‌کار جدایی دارد و از این مسیر خرج نمی‌شود.
- **کد OTP به سفارش گره خورده.** انبار OTP فقط با `(panel, mobile)` کلید می‌خورد، پس شناسه‌ی چالش در cache سفارش ذخیره می‌شود. اگر `initiate` جدیدتری چالش را جایگزین کند، `confirm` قدیمی با `409 pending_order_superseded` رد می‌شود.
- **خطاهای مشتری مبهم‌اند.** «حسابی وجود ندارد»، «حساب فعال نیست» و «اعتبار کافی نیست» همه به `credit_unavailable` تبدیل می‌شوند. تفکیک آن‌ها این اندپوینت را به یک اوراکل تبدیل می‌کرد که هر پذیرنده‌ای می‌توانست شماره‌های دلخواه را بررسی کند و با جست‌وجوی دودویی سقف اعتبار مشتری را دربیاورد. خطاهای مربوط به **خود پذیرنده** (`merchant_not_active`، `merchant_sales_cap_exceeded`) دست‌نخورده برمی‌گردند چون نشتی ندارند.
- **OTP در لاگ نوشته نمی‌شود.** `merchant_api_logs` بدنه را ذخیره می‌کند، پس `otp_code` قبل از لاگ به `[redacted]` تبدیل می‌شود.

**خرید اینترنتی** سرور-به-سرور جلسه می‌سازد و مشتری خودش روی صفحه‌ی میزبانی‌شده تکمیل می‌کند:

```
POST /checkout/sessions        → code + checkout_url
GET  /checkout/sessions/{code} → استعلام وضعیت
```

جلسه با `(merchant_id, idempotency_key)` یکتاست. اگر پذیرنده موقع ساخت جلسه `customer_mobile` را تعیین کند، فقط همان شماره می‌تواند پرداخت را تکمیل کند.

### ۴-۲. پارتنر بانکی — `/api/v1/bank`

برای سیستم بانکی خارجی (مثل «هامرز بانک») که پذیرنده‌هایش را به ما پروویژن می‌کند.

```
POST /merchants/upsert           ثبت یا به‌روزرسانی پذیرنده
GET  /merchants/{external_ref}   استعلام
```

**شناسه‌ی اصلی `external_ref` است، نه موبایل.** یک نفر می‌تواند چند کسب‌وکار مجزا داشته باشد و اطلاعاتشان نباید روی هم بیفتد. موبایل فقط برای ساخت/اتصال حساب لاگین operator استفاده می‌شود.

اولین `upsert` یک `api_credential` (کلید و رمز پوز) برمی‌گرداند — **فقط همان یک‌بار**. فراخوانی‌های بعدی روی همان `external_ref` فقط فیلدهای توصیفی را به‌روز می‌کنند و رمز را دوباره برنمی‌گردانند.

مدیریت کلیدهای پارتنر در پنل مدیریت است: **اعتبار بانکی و تامین مالی ← پارتنرهای بانکی (API)**. صدور، تغییر نام، فعال/غیرفعال، تعویض رمز و حذف.

> کلیدی که پذیرنده‌ی پروویژن‌شده دارد قابل حذف نیست. `bank_partner_merchants` روی cascade است و حذف کلید نگاشت `external_ref` را از بین می‌برد؛ آن‌وقت `upsert` بعدیِ پارتنر به‌جای به‌روزرسانی، پذیرنده‌ی **تکراری** می‌سازد. برای قطع دسترسی، کلید را غیرفعال کنید.

### ۴-۲-۱. رضایت مشتری برای پارتنر بانکی

پارتنر بانکی نمی‌تواند اعتبار یک مشتری را با شماره‌ی موبایل استعلام کند. اگر می‌توانست، هر پارتنری با یک کلید معتبر می‌توانست شماره‌های دلخواه را بررسی کند که چه کسی اعتبار دارد و با جست‌وجوی دودویی سقفش را دربیاورد — همان دفاعی که در `pos/orders/initiate` ساختیم. به‌جای آن، **مشتری خودش اجازه می‌دهد**:

```
POST /api/v1/bank/consent-requests   (HMAC)     → consent_url  ← ۱۰ دقیقه اعتبار
     مشتری به consent_url می‌رود، با OTP خودش تأیید می‌کند
     → redirect به redirect_uri?code=…&state=…             ← کد یک‌بارمصرف، ۶۰ ثانیه
POST /api/v1/bank/consent-tokens     (HMAC)     → access_token + refresh_token
GET  /api/v1/bank/customers/credit   (HMAC + Bearer) → اعتبار همان مشتری
POST /api/v1/bank/consent-tokens/refresh        → چرخش توکن
DELETE /api/v1/bank/consent-tokens              → ابطال از سمت پارتنر
```

تصمیم‌های طراحی که عمدی‌اند:

- **اندپوینت خواندن اعتبار هیچ انتخاب‌گر مشتری ندارد** — نه پارامتر مسیر، نه query، نه فیلد بدنه. مشتری از روی توکن مشخص می‌شود. تستی وجود دارد که این را قفل می‌کند.
- **`redirect_uri` باید از قبل روی credential ثبت شده باشد** و تطبیق **دقیق** است. تطبیق پیشوندی روش شناخته‌شده‌ای برای دزدیدن کد است.
- **کد یک‌بارمصرف** است و قبل از صدور توکن سوزانده می‌شود.
- **refresh token می‌چرخد**: توکن ارائه‌شده هم‌زمان با صدور جفت جدید باطل می‌شود، پس replay یک refresh قدیمی شکست می‌خورد.
- **توکن‌ها فقط به‌صورت SHA-256 ذخیره می‌شوند** — نشت جدول توکن قابل استفاده نمی‌دهد.
- **سقف اختصاصی و سخت‌گیرانه** (`۲۰ در دقیقه` به ازای هر کلید) روی این مسیرها، جدا از سقف عمومی ۶۰ تایی.
- توکن یک پارتنر برای پارتنر دیگر بی‌فایده است.

**اعطای مستقیم (`POST /consent-tokens/direct`)** — از ۲۰۲۶-۱۰-۰۳:

- برای هر جفت «پارتنر + مشتری» فقط **یک** رضایت فعال direct-grant نگه داشته می‌شود؛ فراخوانی دوباره همان `consent_id` را برمی‌گرداند (scopeها ادغام می‌شوند) و یک جفت توکن تازه صادر می‌کند. توکن‌های قبلی تا انقضای خودشان معتبر می‌مانند (برای سازگاری با ترمینال‌های هم‌زمان). رضایتِ لغوشده دوباره استفاده نمی‌شود.
- هنگام ساخت رضایت تازه به مشتری **پیامک اطلاع‌رسانی + اعلان درون‌برنامه‌ای** می‌رود («… اجازه‌ی مشاهده‌ی وضعیت اعتبار شما را دریافت کرد»). پیامک تابع `HAMERZPAY_NOTIFICATIONS_SMS_ENABLED` است.

**`POST /pos/credit-eligibility` و رضایت:** این مسیر بدون توکن رضایت، جزئیات اعتبار هر شماره‌ای (حساب‌ها، مبلغ قابل خرج، اقساط) را به دارنده‌ی کلید پارتنر برمی‌گرداند. با `HAMERZPAY_BANK_ELIGIBILITY_REQUIRE_CONSENT=true` (پیش‌فرض `false` برای سازگاری) هدر `Authorization: Bearer <access_token>` رضایتِ `credit.read` **همان مشتری** الزامی می‌شود؛ خطاها: `401 consent_token_required` / `401 consent_token_invalid` / `403 consent_scope_insufficient` / `403 consent_customer_mismatch`. شکل پاسخ در هر دو حالت تغییر نکرده است.

ادمین می‌تواند از پنل هر دسترسی فعالی را لغو کند؛ لغو بلافاصله همه‌ی توکن‌های آن رضایت را باطل می‌کند.

پاسخ `GET /customers/credit` **همه‌ی خطوط اعتباری** مشتری را برمی‌گرداند (اعتبار شخصی + هر اعتبار سازمانی)، هرکدام با `ref` پایدار، `scope` (`global` یا `merchant`)، سقف، مصرف، اقساط، انقضا و وضعیت مشتق‌شده.

### ۴-۲-۲. وب‌هوک تغییر وضعیت پذیرنده

پذیرنده‌ای که پارتنر upsert می‌کند `pending` می‌ماند تا ادمین فعالش کند. به‌جای اینکه پارتنر poll کند، اگر مقصد وب‌هوکی در پنل ثبت شده باشد رویداد ارسال می‌شود:

```http
POST <partner endpoint>
X-HAMERZ-EVENT: merchant.status_changed
X-HAMERZ-TIMESTAMP: <unix seconds>
X-HAMERZ-SIGNATURE: hmac_sha256("{timestamp}.{raw body}", <webhook secret>)
```

> امضا روی `timestamp.body` است، نه فقط بدنه. وب‌هوک خروجی **شرکتی** فعلاً فقط بدنه را امضا می‌کند و محافظت replay ندارد — این یک بدهی فنی شناخته‌شده در آن مسیر است، نه الگویی که باید تکرار شود.

تحویل تا ۵ بار تلاش می‌کند و در `bank_partner_webhook_deliveries` ثبت می‌شود. رفتار مشترک وب‌هوک‌های خروجی (شرکتی + بانکی):

- **SSRF:** میزبان هم هنگام ذخیره و هم هنگام هر ارسال resolve می‌شود؛ اگر **هر** آدرسش خصوصی/loopback/link-local/metadata/رزرو (IPv4 و IPv6، شامل IPv4-mapped، NAT64 و 6to4) باشد رد می‌شود، اتصال با `CURLOPT_RESOLVE` به همان IP بررسی‌شده pin می‌شود (DNS rebinding بی‌اثر) و redirect دنبال نمی‌شود. مقصد ناامن بلافاصله `failed` با تلاش‌های تمام‌شده می‌شود.
- **retry:** job صف با back-off (شرکتی ۱۰/۳۰/۱۲۰/۳۰۰ ثانیه) دوباره تلاش می‌کند؛ تحویلِ `success` هرگز دوباره فرستاده نمی‌شود. جاروکش‌های `corporate:webhooks:retry` و `bank-partner:webhooks:retry` هر ۵ دقیقه تحویل‌های failed/گیرکرده‌ی قدیمی‌تر از ۱۰ دقیقه را می‌فرستند.
- **circuit breaker:** ۵ شکست پیاپی به یک میزبان → ۵ دقیقه ارسال به آن میزبان متوقف می‌شود (بدون مصرف تلاش؛ جاروکش بعداً می‌فرستد).

### ۴-۲-۳. پرداخت اعتباری فروشگاه آنلاین (BizShop)

فروشگاه‌های آنلاین پذیرنده‌ها روی سامانه‌ی پارتنر (مثل `bizshop.hamerzbank.com/<shop>`) روش پرداخت «پرداخت اعتباری» دارند. مسئولیت‌ها جداست: فروشگاه سفارش و مبلغ را تعیین می‌کند، و احراز موبایل، انتخاب اعتبار، تفکیک نقد و اعتبار، و پذیرش شرایط روی صفحه‌ی هامرزپی انجام می‌شود.

```
POST /api/v1/bank/checkout/sessions                 (HMAC) ساخت جلسه → checkout_url
GET  /api/v1/bank/checkout/sessions/{code}          (HMAC) استعلام وضعیت (مرجع قطعی)
POST /api/v1/bank/checkout/sessions/{code}/cancel   (HMAC) لغو جلسه‌ی پرداخت‌نشده / آزادسازی رزرو
```

بدنه‌ی ساخت جلسه:

| فیلد | توضیح |
|---|---|
| `external_ref` | پذیرنده‌ی پروویژن‌شده‌ی همین پارتنر. پذیرنده باید `active` باشد. |
| `amount` | ریال. حداقل ۱۰۰٬۰۰۰. باید برابر جمع `items` باشد. |
| `items[]` | الزامی. `title`، `quantity` و `unit_price` هر قلم روی صفحه و فاکتور نمایش داده می‌شود. |
| `reference_id` | شماره‌ی سفارش در فروشگاه. |
| `idempotency_key` | تکرار با همان بدنه همان جلسه را برمی‌گرداند (۲۰۰). با بدنه‌ی متفاوت (از جمله `customer_mobile` متفاوت، وقتی ارسال شود)، ۴۰۹ `idempotency_conflict` می‌دهد. |
| `return_url` | فقط https، و میزبانش باید در پنل ادمین ثبت شده باشد (**میزبان‌های بازگشت فروشگاه آنلاین**). |
| `customer_mobile` | اختیاری، ولی توصیه می‌شود. اگر ارسال شود فقط همین شماره می‌تواند بپردازد. |

جریان کار:

1. مشتری روی `checkout_url` با OTP هامرزپی تأیید می‌کند.
2. هر حساب اعتباری‌اش را با این اطلاعات می‌بیند: سقف، اعتبار در دسترس، **درصد مجاز پرداخت از اعتبار** (`credit_share_percent`)، سهم اعتباری و نقدی، کارمزد، و تعداد و مبلغ اقساط.
3. شرایط را صریحاً می‌پذیرد و تأیید می‌کند.
4. اگر سهم نقدی صفر باشد، اعتبار همان لحظه تسخیر می‌شود (`reserved_captured`).
5. اگر سهم نقدی داشته باشد:
   - سهم اعتباری **رزرو** می‌ماند (`awaiting_cash`) تا دریافت نقدی تأیید شود.
   - سهم نقدی یا از **درگاه خودِ پذیرنده** پرداخت می‌شود (پول هرگز به حساب هامرزپی نمی‌آید)، یا پذیرنده آن را مستقیم دریافت و در پنلش (تب «فاکتورها»، فقط مالک، مدیر یا حسابدار) با شماره‌ی پیگیری تأیید می‌کند.
   - اگر ظرف `HAMERZPAY_CHECKOUT_CASH_HOLD_HOURS` (پیش‌فرض **۲۴ ساعت**) تأیید نشود، رزرو آزاد و وضعیت `expired` می‌شود.

نتیجه از دو مسیر به فروشگاه برمی‌گردد:

- **ریدایرکت امضاشده**:
  - شکل آدرس: `return_url?hp_code&hp_status&hp_reference&hp_ts&hp_sig`.
  - امضا برابر است با `hmac_sha256("{code}|{status}|{reference_id}|{ts}", <api secret>)`.
  - `hp_status` یکی از این‌هاست: `success`، `awaiting_cash`، `pending` (انصراف مشتری).
  - این مسیر فقط برای تجربه‌ی کاربر است. **مرجع قطعی پرداخت، استعلام یا وب‌هوک است.**
- **وب‌هوک** `checkout.status_changed`:
  - امضای آن مثل ۴-۲-۲ است و بدنه‌اش `session` با همان ساختار استعلام است.
  - وضعیت‌ها: `awaiting_cash`، `reserved_captured` (`paid=true`)، `cancelled` و `expired`.
  - جلسه‌ی ثبت‌نشده‌ای که ۳۰ دقیقه‌اش گذشته را جاروی `checkout:sessions:expire` (هر ۵ دقیقه) واقعاً `expired` می‌کند و همین وب‌هوک برایش ارسال می‌شود.
  - هر بدنه (و `session` در استعلام) فیلد `version` دارد: عدد صحیحی که با هر تغییر وضعیت جلسه یکی بالا می‌رود. ترتیب تحویل تضمین‌شده نیست؛ رویدادی که `version`ش کوچک‌تر یا مساوی آخرین نسخه‌ی پردازش‌شده است را نادیده بگیرید.

خرید نهایی‌شده با `cancel` لغو نمی‌شود (۴۰۹ `checkout_not_cancellable`). مسیر آن مرجوعی است. اگر مشتری همین حالا سهم نقدی را در درگاه آنلاین می‌پردازد، لغو (و تأیید دستی نقدی پذیرنده) با ۴۰۹ `online_payment_in_progress` رد می‌شود؛ پس از قطعی شدن نتیجه‌ی درگاه (حداکثر حدود ۳۰ دقیقه) دوباره تلاش کنید. تا وقتی پرداخت آنلاین باز است رزرو هم منقضی نمی‌شود.

درخواست‌های رد‌شده (4xx) در لاگ API پارتنر با کد خطا (`error.code`، یا `validation_failed`) و **فقط نام** فیلدهای نامعتبر ثبت می‌شوند — بدون مقدار فیلدها.

`GET /customers/credit` برای هر خط اعتباری این فیلدها را هم برمی‌گرداند تا فروشگاه پیش از پرداخت توضیح دهد چه سهمی از خرید با اعتبار پرداخت می‌شود: `credit_share_percent`، `max_credit_per_purchase` و `usage_note`.

### ۴-۳. شرکتی B2B — `/api/v1/corporate`

کامل‌ترین و سخت‌گیرانه‌ترین خانواده. زنجیره‌ی میدل‌ور همیشه به همین ترتیب اجرا می‌شود:

```
throttle:300,1                 سقف IP قبل از احراز هویت
ApplyCorporateSecurityHeaders  ساخت/انتشار X-Request-ID
ValidateCorporateApiKey        X-API-KEY → CorporateApiCredential
ThrottleCorporateApi           سقف هر کلید
VerifyCorporateSignature       بررسی HMAC + پنجره‌ی ۳۰۰ ثانیه
LogCorporateApiRequest         ثبت در کانال corporate_api
```

روت‌های mutating دو میدل‌ور اضافه دارند:

- `EnforceIdempotency` — هدر `Idempotency-Key` الزامی
- `EnsureCorporateTenantScope` — اگر `account_id` یا `usage_id` متعلق به `company_id` فراخوان نباشد، **۴۰۴ برمی‌گرداند نه ۴۰۳**، تا وجود آن منبع تأیید نشود
  (`POST /settlement-requests` هم از ۲۰۲۶-۱۰-۰۳ پشت این میدل‌ور است: `usage_id` شرکت دیگر یا ناموجود → `404 corporate_resource_not_found` و ذخیره نمی‌شود.)

چرخه‌ی عمر اعتبار: `check` → `reserve` → `capture` یا `cancel` → `settle`.

> **هیچ اندپوینت mutating شرکتی بدون `EnforceIdempotency` اضافه نکنید.**

### ۴-۴. صفحه‌ی چک‌اوت — `/checkout/{code}`

صفحه‌ی عمومی و میزبانی‌شده. بدون میدل‌ور `panel:*` — مشتری خودش با موبایل و OTP هویتش را تأیید می‌کند، سپس اعتبار به‌صورت اتمیک رزرو و تسخیر می‌شود و به `callback_url` پذیرنده برمی‌گردد.

### ۴-۵. وب‌هوک تأمین‌کننده — `/api/providers/{provider}/financial-events`

`provider` یکی از `payment`، `settlement`، `funding`، `payroll`. رویداد از الگوی Inbox عبور می‌کند: `InboxService` با `(provider, external_event_id)` تکراری‌زدایی می‌کند و هش payload را نگه می‌دارد، سپس `ProviderInboxProcessor` پردازشش می‌کند.

رویدادهایی که امضایشان نامعتبر است هم **در inbox ثبت می‌شوند** (با علامت invalid) — این عمدی است و ردّ حسابرسی تلاش‌های نامعتبر را نگه می‌دارد.

### ۴-۶. APIهای پنل

جدول کامل اندپوینت‌ها در [مرجع](https://hmzptest.ir/docs/api) است. نکات معماری:

- **مشتری:** میدل‌ور اضافه‌ی `customer.identity` دارد که تا تکمیل احراز هویت جلوی دسترسی را می‌گیرد.
- **سازمان:** سازمان از `panelContext` گرفته می‌شود و `member_id` و `policy_id` هر دو باید به همان سازمان تعلق داشته باشند. `credit/allocate` کلید idempotency الزامی دارد.
- **مدیریت:** اندپوینت‌ها با `->can('admin.*')` گیت شده‌اند.
- **پذیرنده — چک‌اوت کیف شرکتی (تغییر قرارداد ۲۰۲۶-۱۰-۰۳):** قبلاً هر پذیرنده‌ای با `corporate_account_id` دلخواه کیف شرکتی هر کسی را رزرو و تسخیر می‌کرد و با `customerId` دلخواه موجودی کیف‌ها را می‌دید. حالا جریان سه‌مرحله‌ای است:
  1. `POST /api/merchant/checkout/customers/{id}/otp` — کد به موبایل خودِ مشتری شرکتی پیامک می‌شود.
  2. `POST /api/merchant/checkout/customers/{id}/otp/verify` با `otp_code` → `checkout_token` (۱۰ دقیقه، گره‌خورده به همین پذیرنده و همین مشتری).
  3. `GET .../wallets` (با `checkout_token` در query یا هدر `X-Checkout-Token`) و `POST /checkout/session` (با `customer_id` و `checkout_token` الزامی). حساب انتخاب‌شده باید مال همان مشتری باشد. هر توکن فقط یک checkout (یک `idempotency_key`) را مجاز می‌کند؛ retry با همان کلید مجاز است، کلید دیگر `409 CHECKOUT_TOKEN_USED`.

  هر سه با ability ‏`merchant.corporate-checkout.create` (owner/manager/branch_manager/cashier) گیت شده‌اند. شرکتی که هیچ ردیفی در `corporate_allowed_merchants` ندارد دیگر «همه‌ی پذیرنده‌ها مجاز» نیست؛ برای رفتار قدیمی `CORPORATE_ALLOW_ALL_MERCHANTS_WHEN_UNRESTRICTED=true` (`config/corporate.php`).

---

## ۵. Authorization

`config('hamerzpay.authorization.{panel})` فهرست abilityهاست. `AppServiceProvider::registerPanelAuthorizationGates()` برای هر کلید یک `Gate::define` می‌سازد.

> **تعریف ability در config چیزی را اعمال نمی‌کند.** روت باید صراحتاً با `->can('ability.name')` به آن opt-in کند.

abilityهای admin به permission slug نگاشت می‌شوند (مثل `'admin.ledger.manual-post' => ['manage_finance']`) و `PanelAuthorizer::allowsAdmin` فقط نقش‌ها و permissionهای کاربر را در `admin_user_roles` / `admin_role_permissions` نگاه می‌کند — پس `->can()` بدون آرگومان اضافه کار می‌کند.

برای merchant/organization/customer این‌طور نیست: آن‌ها به context (`merchant_id`، `organization_id`، `user_id`) نیاز دارند که `->can()` از پارامترهای روت نمی‌سازد. آنجا باید دستی wire کنید.

دو رفتار که باید بدانید:
- پروژه‌ای با **صفر ردیف** در `admin_permissions` کاملاً باز در نظر گرفته می‌شود (fallback راه‌اندازی).
- نقش‌های با slug ‏`super_admin` / `super-admin` / `superadmin` همیشه مجازند.

---

## ۶. هسته‌ی مالی

نوشتن‌های مالی مستقیماً روی ستون‌های موجودی انجام **نمی‌شود**. مسیر همیشه از `FinancialOperationOrchestrator` می‌گذرد:

```
execute(posting, domainMutation, projectionMutation)
  ├─ resolve حالت cutover
  ├─ اجرای domainMutation
  ├─ ثبت سند دوطرفه در دفتر کل (JournalEntry + JournalLine)
  └─ اجرای projection که ستون‌های خوانشی را به‌روز می‌کند
```

`credit_accounts.consumed_credit` و `used_credit` و `reserved_credit` **projection** هستند — با `FinancialProjectionService::bankOperation()` از روی سند دفتر کل نوشته می‌شوند. `available_credit` اصلاً ستون نیست؛ accessor است: `credit_limit - consumed_credit`.

### حالت‌های cutover

`hamerzpay.finance.operations.{bank,organization,corporate}.*` تعیین می‌کند هر عملیات در چه حالتی است:

| حالت | معنی |
|---|---|
| `legacy_only` | فقط مسیر قدیمی؛ سندی ثبت نمی‌شود |
| `dual_write_verify` | هر دو نوشته می‌شوند و مقایسه می‌شوند |
| `shadow_posting` | دفتر کل نوشته می‌شود ولی مرجع نیست |
| `ledger_projection` | دفتر کل مرجع است |

> پرچم `requires_ledger => true` حالت `legacy_only` را به `dual_write_verify` ارتقا می‌دهد، پس projection هیچ‌وقت از قلم نمی‌افتد. قبل از اینکه فرض کنید یک مسیر مالی ledger-authoritative است، حالتش را چک کنید.

---

## ۷. افزودن اندپوینت جدید — چک‌لیست

1. **روت را در فایل درست بگذارید.** پنل → `routes/api_{panel}.php` (با `web` و `api` هر دو). ماشین‌به‌ماشین → گروه `/api/v1/*` مربوطه.
2. **اگر گروه `/api/v1/*` جدید می‌سازید:** یک `throttle:` مبتنی بر IP **قبل از** میدل‌ور اعتبارسنجی کلید بگذارید و `MachineApiThrottlingTest` را به‌روز کنید.
3. **Authorization را صریح wire کنید.** برای admin با `->can('...')`؛ برای بقیه‌ی پنل‌ها دستی.
4. **اگر mutating است، کلید idempotency بگیرید.** برای شرکتی حتماً `EnforceIdempotency`.
5. **context را از `panelContext` بخوانید**، نه با کوئری دوباره.
6. **هر منبعی را که از ورودی می‌آید به tenant محدود کنید** — `member_id`، `policy_id`، `account_id`. عدم محدودسازی یعنی نشتی بین تنانت‌ها.
7. **نوشتن مالی را از orchestrator رد کنید**، نه مستقیم روی ستون موجودی.
8. **`openapi.yaml` را به‌روز کنید و `build-reference.py` را اجرا کنید.**
9. **تست فیچر بنویسید که واقعاً اندپوینت را صدا بزند.**

### تله‌های تست

- `actingAs()` **زنجیره‌ی میدل‌ور واقعی روت را دور می‌زند** — نمی‌تواند نبود session/CSRF را بگیرد. برای بررسی پیکربندی میدل‌ور، مستقیم `Route::getRoutes()->getByName(...)->gatherMiddleware()` را وارسی کنید.
- داخل یک متد تست، لاراول همان container را بین `$this->get()/post()` پشت‌سرهم نگه می‌دارد، پس state سشن نشت می‌کند. به تستی که «رفت‌وبرگشت واقعی کوکی» را در یک متد چک می‌کند مشکوک باشید.
- **تست‌ها روی SQLite اجرا می‌شوند ولی staging روی MySQL است.** دو خطا فقط روی MySQL بروز می‌کنند و CI سبز می‌ماند:
  - نام ایندکس بیش از ۶۴ کاراکتر → برای unique ترکیبی روی جدول‌های با نام بلند، نام صریح کوتاه بدهید.
  - ستون با cast `encrypted` که `string()` تعریف شده → payload رمزنگاری‌شده ~۳۸۰ کاراکتر است؛ همیشه `text()`.
  - هر مهاجرت جدید را قبل از merge روی staging اجرا کنید.
- کدهای OTP در تست با `app(OtpTransport::class)->latestCode($panel, $mobile)` قابل خواندن‌اند.

---

## ۸. عیب‌یابی

| نشانه | علت محتمل |
|---|---|
| `invalid_signature` روی همه‌ی درخواست‌ها | JSON بعد از امضا دوباره serialize شده؛ باید همان بایت‌ها فرستاده شود |
| `expired_timestamp` | ساعت سرور بیش از ۳۰۰ ثانیه اختلاف دارد |
| `replay_detected` روی درخواست جدید | هدر `Idempotency-Key` با `idempotency_key` بدنه یکی نیست |
| ۴۰۴ روی منبعی که مطمئنید وجود دارد | tenant scope؛ منبع متعلق به شرکت شما نیست |
| ۴۰۳ روی صفحه‌ی پنل | هدر `Host` با دامنه‌ی آن پنل نمی‌خواند |
| ۵۰۰ روی همه‌ی لاگین‌ها | مالکیت فایل لاگ؛ ← بخش زیر |

### ۵۰۰ روی لاگین پنل‌ها

`php-fpm` با کاربر `www-data` اجرا می‌شود. اگر `php artisan` را با **root** اجرا کنید، فایل لاگ روزانه با مالکیت `root` ساخته می‌شود و از آن لحظه www-data نمی‌تواند در آن بنویسد. چون اولین کار `sendLoginCode()` نوشتن یک خط ممیزی است، **هر تلاش ورود ۵۰۰ می‌شود**.

پیشگیری فعال است: بیت `setgid` روی `storage` و `bootstrap/cache` به‌علاوه‌ی `'permission' => 0664` روی کانال‌های `daily` در `config/logging.php`. با این حال روی staging همیشه با `sudo -u www-data php artisan ...` کار کنید.

> برای بازتولید این خطا، درخواست را حتماً به‌عنوان `www-data` بفرستید — probe با root سبز جواب می‌دهد و مشکل را پنهان می‌کند.

---

## ۹. موارد باز

| مورد | وضعیت |
|---|---|
| `CORPORATE_PANEL_HOST` روی staging تنظیم نشده (هنوز `corporate.hamerzpay.test`) و در `server_name` نginx هم نیست → **پنل شرکتی روی staging در دسترس نیست**. API شرکتی سالم است چون قید هاست ندارد. | باز |
| متن پیامک پوز هنوز «کد ورود» است؛ نمایش مبلغ و نام پذیرنده نیازمند تعریف template کاوه‌نگار است که در `.env` خالی است. | باز |
| همه‌ی abilityهای `admin.corporate.*` به یک permission واحد (`manage_settlement`) نگاشت شده‌اند — نقش «فقط‌خواندنی» قابل تعریف نیست. | باز، تصمیم RBAC |
| `/api/admin/kpi/summary` هنوز stub است و `null` برمی‌گرداند. | باز |
| `/docs/*` بدون احراز هویت سرو می‌شود — هر فایلی در `docs/` عمومی است. | عمدی |

گزارش کامل ممیزی: `docs/api/api-audit-2026-08-28.html`
