Files
clinicpro/docs/api/subscription.md
T
hamed 7716b40f6a feat: implement tax calculations for subscription and SMS wallet payments
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials.
- Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges.
- Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input.
- Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet.
- Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types.
- Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled.
- Updated frontend components to display tax information appropriately during payment processes.
2026-08-09 16:51:22 +03:30

16 KiB
Raw Blame History

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

نکته: از نسخه فعلی، این 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",
  "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 جلوی آن را نمی‌گیرد.

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