# 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` **نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان 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 جلوی آن را نمی‌گیرد. ### 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 | پنل یافت نشد |