14 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.
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,
"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:
{
"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",
"amount_rials": 290000,
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://example.com/payment-result"
}
| فیلد | نوع | الزامی |
|---|---|---|
| gateway | string (mellat|sep) | ✅ |
| amount_rials | integer | ✅ |
| period_uuid | string (UUID) | ✅ |
| frontend_address | string (URL) | ❌ |
Response 200:
{
"success": true,
"data": {
"payment_uuid": "...",
"redirect_url": "https://gateway.shaparak.ir/...",
"order_id": "ORD-XXXXXXXXXXXXXXXX"
}
}
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 جلوی آن را نمیگیرد.
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 | پنل یافت نشد |