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

425 lines
16 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`
**نکته:** از نسخه فعلی، این 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 | پنل یافت نشد |