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