feat(subscription): return plan features for users without subscription permission

This commit is contained in:
hamed
2026-08-04 12:54:12 +03:30
parent 85a27812c7
commit 4653cd0e4a
3 changed files with 68 additions and 9 deletions
+23
View File
@@ -81,6 +81,29 @@
اگر اشتراک فعالی نداشت `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 }
}}
```
- `subscription`، `used_trial` و فیلدهای هویتی/سطحِ پلن (`name`, `level`, `uuid`, `active`,
`periods`) در این حالت **نمی‌آیند**.
- افشای تازه‌ای نیست: `GET /subscription/plans` عمومی است و همین `features` را (به‌همراه
قیمت‌ها) برای همهٔ پلن‌ها می‌دهد.
- سایر نقش‌ها و منشیِ دارای `subscription.view` همان پاسخ کامل بالا را می‌گیرند.
---
## POST /api/v1/subscription/trial
@@ -53,23 +53,43 @@ class SubscriptionController extends BaseController
// ── Authenticated ────────────────────────────────────────────────────────
/**
* اشتراکِ محیط جاری — و برای کاربرِ بدونِ مجوزِ اشتراک، فقط **قابلیت‌های** پلن.
*
* پنل بدون دانستنِ قابلیت‌های پلن نمی‌تواند منو را درست بسازد: هر آیتمِ
* feature-دار (پروندهٔ بیماران، بیمه) وقتی این فهرست نیاید «قفل» می‌شود و کاربر
* را به صفحهٔ اشتراک می‌فرستد — حتی وقتی خودِ API آن قابلیت را به او می‌دهد.
* پس ۴۰۳ اینجا به یک قفلِ دروغین در UI ترجمه می‌شد.
*
* افشای تازه‌ای هم ندارد: `GET /subscription/plans` عمومی است و همین قابلیت‌ها
* (به‌علاوهٔ قیمت‌ها) را برای همهٔ پلن‌ها می‌دهد. چیزی که خصوصی می‌ماند وضعیت و
* تاریخِ اشتراکِ همین محیط و سابقهٔ دورهٔ آزمایشی است.
*/
#[Route('/api/v1/subscription/my', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function my(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId);
$usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId);
$effectivePlan = $this->subscriptionService->getEffectivePlan($entityType, $entityId);
if (!$this->secretaryAccess->canOrNonSecretary($user, 'subscription', 'view')) {
return $this->success([
'subscription' => null,
'used_trial' => false,
'effective_plan' => $effectivePlan === null ? null : [
'features' => $effectivePlan->getFeatures(),
'max_secretaries' => $effectivePlan->getMaxSecretaries(),
],
]);
}
return $this->success([
'subscription' => $subscription?->toArray(),
'used_trial' => $usedTrial,
'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(),
'used_trial' => $this->subscriptionService->hasUsedTrial($entityType, $entityId),
'effective_plan' => $effectivePlan?->toArray(),
]);
}
@@ -272,23 +272,39 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$this->assertSame(403, $this->responseCode());
}
public function testSubscriptionDeniedByDefault(): void
/**
* بدون مجوز اشتراک، `subscription/my` می‌ماند ولی فقط قابلیت‌های پلن را می‌دهد:
* پنل بدون این فهرست، آیتم‌های feature-دار منو را قفل نشان می‌داد در حالی که خودِ
* API آن‌ها را به منشی می‌دهد. وضعیت/تاریخ اشتراک همچنان پنهان است.
*/
public function testSubscriptionWithoutPermissionReturnsFeaturesOnly(): void
{
[$secretary] = $this->makeClinicSecretary();
$this->em->flush();
$this->authJson('GET', '/api/v1/subscription/my', $secretary);
$this->assertSame(403, $this->responseCode());
$res = $this->authJson('GET', '/api/v1/subscription/my', $secretary);
$this->assertSame(200, $this->responseCode());
$this->assertNull($res['data']['subscription']);
$this->assertFalse($res['data']['used_trial']);
$this->assertArrayHasKey('features', $res['data']['effective_plan']);
$this->assertTrue($res['data']['effective_plan']['features']['patient_records']);
// فیلدهای مالی/هویتیِ پلن نباید بیایند.
$this->assertArrayNotHasKey('periods', $res['data']['effective_plan']);
$this->assertArrayNotHasKey('name', $res['data']['effective_plan']);
}
/** با مجوز، پاسخ کامل است — نه نسخهٔ کاهش‌یافته. */
public function testSubscriptionAllowedWhenGranted(): void
{
[$secretary, $rel] = $this->makeClinicSecretary();
$rel->mergePermissions(['resources' => ['subscription' => ['view' => true]]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/subscription/my', $secretary);
$res = $this->authJson('GET', '/api/v1/subscription/my', $secretary);
$this->assertSame(200, $this->responseCode());
$this->assertArrayHasKey('name', $res['data']['effective_plan']);
$this->assertArrayHasKey('used_trial', $res['data']);
}
public function testPatientDeleteSeparateFromUpdate(): void