A user who is both a doctor and a clinic owner always resolved to the doctor: SubscriptionController had its own role-first resolveEntity, and ownedEntity() returned the doctor whenever one existed. So a subscription granted to that user's clinic was stored correctly but never surfaced — /subscription/my kept reporting the free plan and the panel kept the feature-gated menu items locked. ownedEntity() now disambiguates with the active context when the user owns both environments, and the controller delegates to it instead of re-deriving the pair from roles. Payment already used ownedEntity(), so display, purchase and admin grant now agree on one environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
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.
مالیات دورهها
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:
{
"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:
{
"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 است ولی فقط توانمندیهای پلن را دارد:
{"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:
{
"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:
{
"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.
Admin Endpoints
GET /api/v1/admin/subscription/plans
Permission: ROLE_ADMIN — لیست همه پلنها شامل غیرفعالها (بر خلاف endpoint عمومی که فقط فعالها را برمیگرداند). هر پلن فیلد active دارد و periods همیشه یک آرایه است (فقط دورههای فعال).
POST /api/v1/admin/subscription/plan
Permission: ROLE_ADMIN
{
"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
{
"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 دورهٔ اشتراک؛ پلن از خود دوره خوانده میشود |
{
"entity_type": "doctor",
"entity_uuid": "44279545-9eab-4fc5-8b81-d04485ca38a7",
"period_uuid": "72dfbf23-b4fc-4bb0-a6f7-abcb6441754b"
}
Response 201
{
"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
{
"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.
{
"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 تنها معیارِ تشخیص نیست: اشتراک تریال هم پرداختی ندارد.
{
"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 | پنل یافت نشد |