- 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.
425 lines
16 KiB
Markdown
425 lines
16 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`
|
||
|
||
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار میکند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا میکند و اشتراک همان 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 جلوی آن را نمیگیرد.
|
||
|
||
### 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 | پنل یافت نشد |
|