Files
clinicpro/docs/api/subscription.md
T
hamed a6a965a2aa feat: add admin subscription granting feature
- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
2026-08-09 13:43:30 +03:30

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"
  }
}

GET /api/v1/subscription-payment/callback/{gateway}

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


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 پنل یافت نشد