Feature gates and the resource cap are enforced against the environment the user is standing in, but /subscription/my only ever returned the plan of the environment they own. A doctor working as a guest in another clinic consumed the host clinic's resource quota while the panel showed their own plan's cap, so the quota number and the menu locks disagreed with what the server would allow. /subscription/my now also returns context_plan — limits and features of the acting environment, without the other environment's plan identity. effective_plan, subscription and used_trial stay on the owned environment so the purchase flow is unchanged, and useSubscription reads its caps and hasFeature from context_plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
469 lines
19 KiB
Markdown
469 lines
19 KiB
Markdown
# 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": {...} },
|
||
"context_plan": { "max_secretaries": 3, "max_resources": 3, "features": {...} }
|
||
}
|
||
}
|
||
```
|
||
|
||
`is_granted` یعنی این اشتراک را ادمین بدون پرداخت اعطا کرده است.
|
||
|
||
`effective_plan` و `context_plan` دو محیط متفاوت را جواب میدهند و برای کاربری که فقط محیط خودش را دارد یکی هستند:
|
||
|
||
- `effective_plan` و `subscription` و `used_trial` مالِ محیطِ **مالکیت**اند. مبنای خرید و ارتقا همین است.
|
||
- `context_plan` مالِ محیطی است که کاربر همین حالا **در آن ایستاده**. سرور سقف منابع و قفل قابلیتها را با همین محیط میسنجد، پس پنل هم باید سقفها و `hasFeature` را از این بخواند.
|
||
|
||
پزشکِ مهمانِ یک کلینیک نمونهٔ واگرایی است: منابعی که میسازد از سهمیهٔ کلینیک میزبان کم میشود، ولی اشتراکِ خودش همان اشتراک شخصی میماند. `context_plan` فقط سقفها و `features` را دارد؛ فیلدهای هویتیِ پلنِ محیط دیگر (`name`, `level`, `uuid`) در آن نمیآید.
|
||
|
||
اگر اشتراک فعالی نداشت `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 },
|
||
"context_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 | پنل یافت نشد |
|