Files
clinicpro/docs/api/subscription.md
T
hamedandClaude Opus 5 32044c8fa9 fix(subscription): read the subscription of the environment the user owns
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>
2026-08-19 22:07:56 +03:30

460 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده می‌کند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.
کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحب‌شده دارد. آنجا محیط فعال
(`UserActiveContext`) تعیین می‌کند اشتراک کدام‌یک خوانده شود. بدون محیط فعال، مطب
شخصی پیش‌فرض است.
**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار می‌کند. منشی محیط صاحب‌شده ندارد، پس محیط فعالش خوانده می‌شود و اشتراک همان 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 جلوی آن را نمی‌گیرد.
### DELETE /api/v1/admin/subscription/{uuid}
**Permission:** `ROLE_ADMIN` — حذف اشتراک، برای برگرداندن اعطای اشتباه
`uuid` همان `uuid` ردیف گزارش است. حذف سخت است، نه soft delete: رکورد از
`clinic_subscriptions` پاک می‌شود و پلن مؤثر مقصد به اشتراک فعال بعدی یا به `free`
برمی‌گردد.
اشتراکِ متصل به پرداخت حذف نمی‌شود. سند مالی‌اش باید بماند و مسیر درست آن استرداد
وجه است.
**Response 200**
```json
{
"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`.
```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 | پنل یافت نشد |