feat(subscription): return plan features for users without subscription permission
This commit is contained in:
@@ -81,6 +81,29 @@
|
|||||||
|
|
||||||
اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیشفرض `free`**. فرانتاند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است.
|
اگر اشتراک فعالی نداشت `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
|
## POST /api/v1/subscription/trial
|
||||||
|
|||||||
@@ -53,23 +53,43 @@ class SubscriptionController extends BaseController
|
|||||||
|
|
||||||
// ── Authenticated ────────────────────────────────────────────────────────
|
// ── Authenticated ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اشتراکِ محیط جاری — و برای کاربرِ بدونِ مجوزِ اشتراک، فقط **قابلیتهای** پلن.
|
||||||
|
*
|
||||||
|
* پنل بدون دانستنِ قابلیتهای پلن نمیتواند منو را درست بسازد: هر آیتمِ
|
||||||
|
* feature-دار (پروندهٔ بیماران، بیمه) وقتی این فهرست نیاید «قفل» میشود و کاربر
|
||||||
|
* را به صفحهٔ اشتراک میفرستد — حتی وقتی خودِ API آن قابلیت را به او میدهد.
|
||||||
|
* پس ۴۰۳ اینجا به یک قفلِ دروغین در UI ترجمه میشد.
|
||||||
|
*
|
||||||
|
* افشای تازهای هم ندارد: `GET /subscription/plans` عمومی است و همین قابلیتها
|
||||||
|
* (بهعلاوهٔ قیمتها) را برای همهٔ پلنها میدهد. چیزی که خصوصی میماند وضعیت و
|
||||||
|
* تاریخِ اشتراکِ همین محیط و سابقهٔ دورهٔ آزمایشی است.
|
||||||
|
*/
|
||||||
#[Route('/api/v1/subscription/my', methods: ['GET'])]
|
#[Route('/api/v1/subscription/my', methods: ['GET'])]
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function my(#[CurrentUser] User $user): JsonResponse
|
public function my(#[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'view');
|
|
||||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||||
if ($entityId === null) {
|
if ($entityId === null) {
|
||||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
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);
|
$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([
|
return $this->success([
|
||||||
'subscription' => $subscription?->toArray(),
|
'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(),
|
||||||
'used_trial' => $usedTrial,
|
'used_trial' => $this->subscriptionService->hasUsedTrial($entityType, $entityId),
|
||||||
'effective_plan' => $effectivePlan?->toArray(),
|
'effective_plan' => $effectivePlan?->toArray(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,23 +272,39 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
|
|||||||
$this->assertSame(403, $this->responseCode());
|
$this->assertSame(403, $this->responseCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSubscriptionDeniedByDefault(): void
|
/**
|
||||||
|
* بدون مجوز اشتراک، `subscription/my` میماند ولی فقط قابلیتهای پلن را میدهد:
|
||||||
|
* پنل بدون این فهرست، آیتمهای feature-دار منو را قفل نشان میداد در حالی که خودِ
|
||||||
|
* API آنها را به منشی میدهد. وضعیت/تاریخ اشتراک همچنان پنهان است.
|
||||||
|
*/
|
||||||
|
public function testSubscriptionWithoutPermissionReturnsFeaturesOnly(): void
|
||||||
{
|
{
|
||||||
[$secretary] = $this->makeClinicSecretary();
|
[$secretary] = $this->makeClinicSecretary();
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->authJson('GET', '/api/v1/subscription/my', $secretary);
|
$res = $this->authJson('GET', '/api/v1/subscription/my', $secretary);
|
||||||
$this->assertSame(403, $this->responseCode());
|
$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
|
public function testSubscriptionAllowedWhenGranted(): void
|
||||||
{
|
{
|
||||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||||
$rel->mergePermissions(['resources' => ['subscription' => ['view' => true]]]);
|
$rel->mergePermissions(['resources' => ['subscription' => ['view' => true]]]);
|
||||||
$this->em->flush();
|
$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->assertSame(200, $this->responseCode());
|
||||||
|
$this->assertArrayHasKey('name', $res['data']['effective_plan']);
|
||||||
|
$this->assertArrayHasKey('used_trial', $res['data']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPatientDeleteSeparateFromUpdate(): void
|
public function testPatientDeleteSeparateFromUpdate(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user