From 4653cd0e4a357aecba460526546503a8fdbe5de1 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 12:54:12 +0330 Subject: [PATCH] feat(subscription): return plan features for users without subscription permission --- docs/api/subscription.md | 23 ++++++++++++++ .../Controller/SubscriptionController.php | 30 +++++++++++++++---- .../SecretaryResourceEnforcementTest.php | 24 ++++++++++++--- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/api/subscription.md b/docs/api/subscription.md index 04c4f398..be9283cc 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -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 diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 4bdade64..474edf5f 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -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(), ]); } diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index 6276937e..a8decc60 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -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