# Subscription API

مدیریت پنل‌های اشتراکی (Free / Basic / Professional).

---

## GET /api/v1/subscription/plans

لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth).

> پلن `free`: همه امکانات (`patient_records`, `services`, `sms_panel`, `insurance`) فعال‌اند؛ محدودیت‌هایش عددی‌اند — تعداد منشی (`max_secretaries`) و تعداد منبع (`max_resources`).

> `max_resources` سقف منابع محیط است. مقدار `-1` یعنی نامحدود. مقادیر شیپ‌شده: `free` = ۱، `basic` = ۳، `professional` = `-1`. اجرای این سقف در `POST /api/v1/resource` است — [resource.md](resource.md).

### مالیات دوره‌ها

`price_rials` هر دوره **خالص** است و مالیات رویش **اضافه** می‌شود. این برعکسِ نوبت است؛ آنجا
مبلغ شامل مالیات است و `CommissionService` مالیات را از دلش استخراج می‌کند.

نرخ از همان کلیدهای سراسری `tax_enabled` و `tax_percent` در SiteConfig می‌آید — کلید جداگانه‌ای
برای اشتراک وجود ندارد. محاسبه در `App\Subscription\Service\SubscriptionTaxCalculator`.

هر دوره سه فیلد محاسبه‌شدهٔ اضافه دارد. `price_rials` دست‌نخورده می‌ماند تا کلاینت قدیمی نشکند:

| فیلد | معنی |
|------|------|
| `price_rials` | قیمت خالص، بدون مالیات — همان چیزی که ادمین وارد می‌کند |
| `tax_percent` | درصد مؤثر؛ با `tax_enabled=0` برابر `0` |
| `tax_rials` | `round(price_rials × tax_percent / 100)` |
| `payable_rials` | `price_rials + tax_rials` — مبلغی که واقعاً پرداخت می‌شود |

دورهٔ رایگان یا تریال (`price_rials = 0`) مالیات نمی‌گیرد.

**Response 200:**
```json
{
  "success": true,
  "data": [
    {
      "uuid": "...",
      "name": "free",
      "level": 0,
      "max_secretaries": 1,
      "max_resources": 1,
      "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true },
      "active": true,
      "periods": []
    },
    {
      "uuid": "...",
      "name": "basic",
      "level": 1,
      "max_secretaries": 3,
      "max_resources": 3,
      "features": { "patient_records": true, "services": true, "sms_panel": false },
      "active": true,
      "periods": [
        {
          "uuid": "...",
          "plan_uuid": "...",
          "label": "یک ماهه",
          "duration_months": 1,
          "price_rials": 290000,
          "tax_percent": 10,
          "tax_rials": 29000,
          "payable_rials": 319000,
          "is_trial": false,
          "active": true,
          "sort_order": 1
        }
      ]
    }
  ]
}
```

---

## GET /api/v1/subscription/my

اشتراک فعال کاربر جاری.

**Permission:** `IS_AUTHENTICATED_FULLY`

**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده می‌کند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.

کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحب‌شده دارد. آنجا محیط فعال
(`UserActiveContext`) تعیین می‌کند اشتراک کدام‌یک خوانده شود. بدون محیط فعال، مطب
شخصی پیش‌فرض است.

**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار می‌کند. منشی محیط صاحب‌شده ندارد، پس محیط فعالش خوانده می‌شود و اشتراک همان entity برمی‌گردد.

**Response 200:**
```json
{
  "success": true,
  "data": {
    "subscription": {
      "uuid": "...",
      "plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} },
      "period": { "label": "یک ماهه", "duration_months": 1, "price_rials": 290000 },
      "is_trial": false,
      "is_granted": false,
      "starts_at": 1718000000,
      "expires_at": 1720678400,
      "days_remaining": 30,
      "is_active": true
    },
    "used_trial": false,
    "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} }
  }
}
```

`is_granted` یعنی این اشتراک را ادمین بدون پرداخت اعطا کرده است.

اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است.

### پاسخ کاهش‌یافته برای کاربرِ بدون مجوزِ `subscription.view` (2026-08)

پیش از این، منشیِ بدون این مجوز `403` می‌گرفت. نتیجه‌اش یک **قفلِ دروغین در پنل** بود:
سایدبار هر آیتم feature-دار (پروندهٔ بیماران، بیمه) را با `hasFeature()` گیت می‌کند و
بدون این پاسخ، `features` خالی می‌ماند و آیتم قفل و به صفحهٔ اشتراک هدایت می‌شد — حتی
وقتی خودِ API آن قابلیت را به همان منشی می‌داد.

حالا پاسخ `200` است ولی فقط توانمندی‌های پلن را دارد:

```json
{"success":true,"data":{
  "subscription": null,
  "used_trial": false,
  "effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 }
}}
```

- `subscription`، `used_trial` و فیلدهای هویتی/سطحِ پلن (`name`, `level`, `uuid`, `active`,
  `periods`) در این حالت **نمی‌آیند**.
- افشای تازه‌ای نیست: `GET /subscription/plans` عمومی است و همین `features` را (به‌همراه
  قیمت‌ها) برای همهٔ پلن‌ها می‌دهد.
- سایر نقش‌ها و منشیِ دارای `subscription.view` همان پاسخ کامل بالا را می‌گیرند.

---

## POST /api/v1/subscription/trial

فعال‌سازی تریال رایگان (یکبار برای هر entity).

**Permission:** `IS_AUTHENTICATED_FULLY`

**Response 201:** همان ساختار ClinicSubscription

**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است — یا `SiteConfig: trial_enabled=0`، یا پلن `basic` هیچ دورهٔ تریالِ `active` ندارد |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
| ERR_NOT_FOUND_001 | 500 | پلن `basic` وجود ندارد یا غیرفعال است (نصب ناقص) |

---

## POST /api/v1/subscription-payment

شروع پرداخت اشتراک.

**Permission:** `IS_AUTHENTICATED_FULLY`

**Request Body:**
```json
{
  "gateway": "mellat",
  "period_uuid": "uuid-of-subscription-period",
  "frontend_address": "https://example.com/payment-result"
}
```

| فیلد | نوع | الزامی |
|------|-----|--------|
| gateway | string (mellat\|sep) | ✅ |
| period_uuid | string (UUID) | ✅ |
| frontend_address | string (URL) | ❌ |

> **`amount_rials` دیگر پذیرفته نمی‌شود.** مبلغ سمت سرور از دورهٔ اشتراک محاسبه می‌شود:
> `price_rials + tax_rials`. اگر کلاینت آن را بفرستد نادیده گرفته می‌شود. دلیلش بستنِ راهِ
> دستکاری قیمت است. دورهٔ ناموجود یا غیرفعال → `422 ERR_VALIDATION_001` روی فیلد `period_uuid`.

**Response 200:**
```json
{
  "success": true,
  "data": {
    "payment_uuid": "...",
    "pay_url": "https://clinic-pro.ir/api/v1/payment/pay/ORD-XXXXXXXXXXXXXXXX",
    "order_id": "ORD-XXXXXXXXXXXXXXXX",
    "price_rials": 290000,
    "tax_percent": 10,
    "tax_rials": 29000,
    "payable_rials": 319000
  }
}
```

---

## POST|GET /api/v1/payment/callback

callback مشترک همهٔ درگاه‌ها و همهٔ نوع‌های پرداخت — پس از پرداخت موفق، `ClinicSubscription` به صورت خودکار ایجاد می‌شود (بر اساس `period_uuid` ذخیره‌شده در metadata پرداخت).

مسیر اختصاصی قبلی `/api/v1/subscription-payment/callback/{gateway}` حذف شده و `404` می‌دهد. قرارداد کامل: [payment.md](payment.md#post-apiv1paymentcallback).

---

## Admin Endpoints

### GET /api/v1/admin/subscription/plans
**Permission:** `ROLE_ADMIN` — لیست **همه** پلن‌ها شامل غیرفعال‌ها (بر خلاف endpoint عمومی که فقط فعال‌ها را برمی‌گرداند). هر پلن فیلد `active` دارد و `periods` همیشه یک آرایه است (فقط دوره‌های فعال).

### POST /api/v1/admin/subscription/plan
**Permission:** `ROLE_ADMIN`

```json
{
  "name": "enterprise",
  "level": 3,
  "max_secretaries": 20,
  "max_resources": -1,
  "features": { "patient_records": true, "services": true, "sms_panel": true }
}
```

`max_resources` اختیاری است و پیش‌فرضش `1` — پلنِ ناشناخته نباید بی‌صدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته می‌شود.

مقدارهای پذیرفته‌شده: `-1` یا هر عدد مثبت. `0` و هر منفیِ دیگر → `422 ERR_VALIDATION_001` با پیام «max_resources باید عددی مثبت باشد یا -1 برای نامحدود».

پنل ادمین (`/admin/admin-subscription`) به‌جای گرفتنِ `-1` از کاربر، یک سوییچ «منابع نامحدود» دارد و خودش همان `-1` را می‌فرستد. تست: `tests/Subscription/AdminPlanResourceLimitTest.php`.

**خطاها:**

| کد | HTTP | شرح |
|----|------|-----|
| ERR_VALIDATION_001 | 422 | `name` یا `level` ارسال نشده |
| ERR_VALIDATION_001 | 422 | پلنی با این نام از قبل وجود دارد (نام یکتاست) |

### PATCH /api/v1/admin/subscription/plan/{uuid}
**Permission:** `ROLE_ADMIN` — ویرایش پنل (همه فیلدها اختیاری)

**خطاها:**

| کد | HTTP | شرح |
|----|------|-----|
| ERR_SUBSCRIPTION_NOT_FOUND | 404 | پلن یافت نشد |
| ERR_VALIDATION_001 | 422 | نام جدید متعلق به پلن دیگری است |

### POST /api/v1/admin/subscription/period
**Permission:** `ROLE_ADMIN`

```json
{
  "plan_uuid": "...",
  "label": "شش ماهه",
  "duration_months": 6,
  "price_rials": 1500000,
  "is_trial": false,
  "sort_order": 2
}
```

### PATCH /api/v1/admin/subscription/period/{uuid}
**Permission:** `ROLE_ADMIN` — ویرایش دوره

### DELETE /api/v1/admin/subscription/period/{uuid}
**Permission:** `ROLE_ADMIN` — غیرفعال کردن دوره (soft delete: `active=false`)

### POST /api/v1/admin/subscription/grant
**Permission:** `ROLE_ADMIN` — اعطای اشتراک به یک پزشک یا کلینیک، بدون پرداخت

مقصد با `uuid` مشخص می‌شود، نه `id`؛ `id` داخلی است و در پاسخ‌های ادمین نمی‌آید.

اشتراکِ ساخته‌شده هرگز `is_trial` نمی‌گیرد، پس تریالِ استفاده‌نشدهٔ مقصد نمی‌سوزد.
اگر مقصد اشتراک فعال داشته باشد، مدتِ دوره روی انقضای فعلی افزوده می‌شود، نه از امروز.

**Request**

| فیلد | نوع | الزامی | توضیح |
|------|-----|--------|-------|
| entity_type | string | بله | `doctor` یا `clinic` |
| entity_uuid | string | بله | uuid پزشک یا کلینیک |
| period_uuid | string | بله | uuid دورهٔ اشتراک؛ پلن از خود دوره خوانده می‌شود |

```json
{
  "entity_type": "doctor",
  "entity_uuid": "44279545-9eab-4fc5-8b81-d04485ca38a7",
  "period_uuid": "72dfbf23-b4fc-4bb0-a6f7-abcb6441754b"
}
```

**Response 201**

```json
{
  "success": true,
  "data": {
    "uuid": "2687342c-85fa-4610-aed9-bba42004f920",
    "plan": {
      "uuid": "6c2573e1-98e4-47e5-ba24-b0af92e55ffd",
      "name": "professional",
      "level": 2,
      "max_secretaries": 5,
      "max_resources": -1,
      "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true },
      "active": true
    },
    "period": {
      "uuid": "72dfbf23-b4fc-4bb0-a6f7-abcb6441754b",
      "plan_uuid": "6c2573e1-98e4-47e5-ba24-b0af92e55ffd",
      "label": "یک ماهه",
      "duration_months": 1,
      "price_rials": 20000000,
      "is_trial": false,
      "active": true,
      "sort_order": 1
    },
    "is_trial": false,
    "is_granted": true,
    "starts_at": 1786268482,
    "expires_at": 1788860482,
    "days_remaining": 30,
    "is_active": true,
    "created_at": 1786268482
  }
}
```

**خطاها**

| وضعیت | کد | حالت |
|-------|----|------|
| 422 | ERR_VALIDATION_001 | `entity_type` غیر از `doctor`/`clinic`، یا نبودِ `entity_uuid`/`period_uuid` |
| 404 | ERR_NOT_FOUND_001 | مقصد یافت نشد («مقصد اشتراک یافت نشد») |
| 404 | ERR_NOT_FOUND_001 | دوره یافت نشد |
| 401 | ERR_AUTH_001 | بدون توکن |
| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` |

> **هشدار downgrade:** `findActive` آخرین رکورد را بر اساس `id` برمی‌دارد، نه بالاترین
> پلن. پس اعطای پلنی پایین‌تر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش می‌دهد. پنل
> ادمین قبل از ثبت این حالت تأیید می‌گیرد؛ خودِ endpoint جلوی آن را نمی‌گیرد.

### DELETE /api/v1/admin/subscription/{uuid}
**Permission:** `ROLE_ADMIN` — حذف اشتراک، برای برگرداندن اعطای اشتباه

`uuid` همان `uuid` ردیف گزارش است. حذف سخت است، نه soft delete: رکورد از
`clinic_subscriptions` پاک می‌شود و پلن مؤثر مقصد به اشتراک فعال بعدی یا به `free`
برمی‌گردد.

اشتراکِ متصل به پرداخت حذف نمی‌شود. سند مالی‌اش باید بماند و مسیر درست آن استرداد
وجه است.

**Response 200**

```json
{
  "success": true,
  "data": null
}
```

| وضعیت | کد | حالت |
|-------|----|------|
| 404 | ERR_SUBSCRIPTION_NOT_FOUND | uuid یافت نشد |
| 409 | ERR_CONFLICT_001 | اشتراک به پرداخت متصل است |
| 401 | ERR_AUTH_001 | بدون توکن |
| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` |

> مسیر `DELETE /api/v1/admin/subscription/period/{uuid}` جداست و دورهٔ پلن را
> غیرفعال می‌کند، نه اشتراکِ یک مقصد را.

### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid}
**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا

`entityType` یکی از `doctor` یا `clinic`.

```json
{
  "success": true,
  "data": {
    "subscription": {
      "uuid": "2687342c-85fa-4610-aed9-bba42004f920",
      "is_trial": false,
      "is_granted": true,
      "expires_at": 1788860482,
      "days_remaining": 30
    }
  }
}
```

نبودِ اشتراک فعال با `"subscription": null` برمی‌گردد، نه ۴۰۴.

| وضعیت | کد | حالت |
|-------|----|------|
| 422 | ERR_VALIDATION_001 | `entityType` غیر از `doctor`/`clinic` |
| 404 | ERR_NOT_FOUND_001 | مقصد یافت نشد |

### GET /api/v1/admin/subscription/report
**Permission:** `ROLE_ADMIN`

Query params: `page`, `limit` — مقدار `limit` بین ۱۰ تا ۱۰۰ کلیپ می‌شود.

`isGranted` یعنی این اشتراک را ادمین بدون پرداخت داده و `grantedBy` نام یا شمارهٔ همان ادمین است.
`payment` تنها معیارِ تشخیص نیست: اشتراک تریال هم پرداختی ندارد.

```json
{
  "success": true,
  "data": [
    {
      "uuid": "2687342c-85fa-4610-aed9-bba42004f920",
      "entityType": "doctor",
      "entityId": 19545,
      "entityName": "پزشک دعوت شده2",
      "isTrial": false,
      "isGranted": true,
      "grantedBy": "ادمین",
      "startsAt": 1786268482,
      "expiresAt": 1788860482,
      "createdAt": 1786268482,
      "plan_name": "professional",
      "plan_level": 2
    },
    {
      "uuid": "ba1b8b92-f3d0-4cc6-8217-2dfc0ffe0d28",
      "entityType": "clinic",
      "entityId": 1,
      "entityName": "09398631203",
      "isTrial": true,
      "isGranted": false,
      "grantedBy": null,
      "startsAt": 1783093441,
      "expiresAt": 1785685441,
      "createdAt": 1783093441,
      "plan_name": "basic",
      "plan_level": 1
    }
  ],
  "meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
```

---

## Error Codes

| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | قابلیت نیاز به پنل Basic+ دارد |
| ERR_TRIAL_ALREADY_USED | 422 | تریال قبلاً استفاده شده |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است |
| ERR_SUBSCRIPTION_NOT_FOUND | 404 | پنل یافت نشد |
