From 601d211d6fddaeed91dcd61b893eb2604980c182 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 20 Aug 2026 14:48:13 +0330 Subject: [PATCH] fix(subscription): report the acting environment's plan for gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature gates and the resource cap are enforced against the environment the user is standing in, but /subscription/my only ever returned the plan of the environment they own. A doctor working as a guest in another clinic consumed the host clinic's resource quota while the panel showed their own plan's cap, so the quota number and the menu locks disagreed with what the server would allow. /subscription/my now also returns context_plan — limits and features of the acting environment, without the other environment's plan identity. effective_plan, subscription and used_trial stay on the owned environment so the purchase flow is unchanged, and useSubscription reads its caps and hasFeature from context_plan. Co-Authored-By: Claude Opus 5 (1M context) --- assets/admin/hooks/useSubscription.ts | 12 ++++-- assets/admin/types/index.ts | 12 +++++- docs/api/subscription.md | 13 ++++++- .../Controller/SubscriptionController.php | 38 ++++++++++++++++--- .../DualEnvironmentSubscriptionTest.php | 36 ++++++++++++++++++ 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/assets/admin/hooks/useSubscription.ts b/assets/admin/hooks/useSubscription.ts index 60a03dac..6d70fa66 100644 --- a/assets/admin/hooks/useSubscription.ts +++ b/assets/admin/hooks/useSubscription.ts @@ -21,13 +21,17 @@ export function useSubscription() { const sub = data?.data?.subscription ?? null; const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null; - const features: Record = effectivePlan?.features ?? {}; - const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1; + // سقف‌ها و قابلیت‌ها از پلنِ محیطِ فعال می‌آیند، نه از پلنِ محیطِ مالکیت: سرور هم با + // همین محیط می‌سنجد. پزشکِ مهمانِ یک کلینیک، منابعش را از سهمیهٔ کلینیک میزبان + // برمی‌دارد ولی سقفِ پلنِ خودش نمایش داده می‌شد و عددِ سهمیه بی‌معنی بود. + const gatePlan = data?.data?.context_plan ?? effectivePlan; + const features: Record = gatePlan?.features ?? {}; + const maxSecretaries: number = gatePlan?.max_secretaries ?? 1; // `-1` یعنی بی‌نهایت. - const maxResources: number = effectivePlan?.max_resources ?? 1; + const maxResources: number = gatePlan?.max_resources ?? 1; // نقش‌هایی که اشتراک ندارند (ادمین) و لحظهٔ پیش از رسیدن پاسخ: سقف ناشناخته است و // نباید با پیش‌فرضِ ۱ به‌جای کاربر تصمیم گرفت — گیت‌کردن کارِ سرور است. - const planLoaded = effectivePlan !== null; + const planLoaded = gatePlan !== null; const hasPlan = sub !== null; return { diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index abefc113..014251f7 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -604,7 +604,7 @@ export interface MySubscriptionData { days_remaining?: number; } | null; used_trial: boolean; - /** پلن مؤثر: پلن اشتراک فعال یا پلن پیش‌فرض free در نبود اشتراک. */ + /** پلن مؤثرِ محیطِ **مالکیت** — مبنای خرید و ارتقا. */ effective_plan: { name: string; level: number; @@ -612,6 +612,16 @@ export interface MySubscriptionData { max_resources: number; features: Record; } | null; + /** + * پلن محیطی که کاربر همین حالا در آن ایستاده — مبنای سقف‌ها و قفل قابلیت‌ها، + * چون سرور هم با همین محیط می‌سنجد. برای کاربری که فقط محیط خودش را دارد با + * `effective_plan` یکی است. + */ + context_plan: { + max_secretaries: number; + max_resources: number; + features: Record; + } | null; } /** @deprecated use MySubscriptionData */ export interface MySubscription { diff --git a/docs/api/subscription.md b/docs/api/subscription.md index 41e36352..543f28a0 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -107,13 +107,21 @@ "is_active": true }, "used_trial": false, - "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} } + "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} }, + "context_plan": { "max_secretaries": 3, "max_resources": 3, "features": {...} } } } ``` `is_granted` یعنی این اشتراک را ادمین بدون پرداخت اعطا کرده است. +`effective_plan` و `context_plan` دو محیط متفاوت را جواب می‌دهند و برای کاربری که فقط محیط خودش را دارد یکی هستند: + +- `effective_plan` و `subscription` و `used_trial` مالِ محیطِ **مالکیت**‌اند. مبنای خرید و ارتقا همین است. +- `context_plan` مالِ محیطی است که کاربر همین حالا **در آن ایستاده**. سرور سقف منابع و قفل قابلیت‌ها را با همین محیط می‌سنجد، پس پنل هم باید سقف‌ها و `hasFeature` را از این بخواند. + +پزشکِ مهمانِ یک کلینیک نمونهٔ واگرایی است: منابعی که می‌سازد از سهمیهٔ کلینیک میزبان کم می‌شود، ولی اشتراکِ خودش همان اشتراک شخصی می‌ماند. `context_plan` فقط سقف‌ها و `features` را دارد؛ فیلدهای هویتیِ پلنِ محیط دیگر (`name`, `level`, `uuid`) در آن نمی‌آید. + اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است. ### پاسخ کاهش‌یافته برای کاربرِ بدون مجوزِ `subscription.view` (2026-08) @@ -129,7 +137,8 @@ {"success":true,"data":{ "subscription": null, "used_trial": false, - "effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 } + "effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 }, + "context_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 } }} ``` diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 94800644..aa5269b6 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -77,16 +77,14 @@ class SubscriptionController extends BaseController } $effectivePlan = $this->subscriptionService->getEffectivePlan($entityType, $entityId); + $contextPlan = $this->contextPlan($user, $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(), - 'max_resources' => $effectivePlan->getMaxResources(), - ], + 'effective_plan' => $this->planLimits($effectivePlan), + 'context_plan' => $this->planLimits($contextPlan), ]); } @@ -94,6 +92,7 @@ class SubscriptionController extends BaseController 'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(), 'used_trial' => $this->subscriptionService->hasUsedTrial($entityType, $entityId), 'effective_plan' => $effectivePlan?->toArray(), + 'context_plan' => $this->planLimits($contextPlan), ]); } @@ -440,6 +439,35 @@ class SubscriptionController extends BaseController * * منشی و پرسنل صاحب محیطی نیستند، پس برایشان محیط فعال خوانده می‌شود. */ + /** + * پلنِ محیطی که کاربر همین حالا **در آن ایستاده** — نه محیطی که مالکش است. + * + * سقف‌ها و قابلیت‌ها را سرور با همین محیط می‌سنجد (`assertPatientGate`, + * `assertServicesGate`, سقف منابع). پزشکِ مهمانِ یک کلینیک، مصرفش از کلینیک + * میزبان شمرده می‌شود ولی `effective_plan` پلنِ خودش را می‌داد، پس عددِ سهمیه و + * قفلِ منو در پنل با تصمیم سرور یکی نبود. خریدِ اشتراک همچنان روی محیطِ مالکیت + * می‌نشیند، پس `effective_plan` دست‌نخورده می‌ماند. + */ + private function contextPlan(User $user, string $ownedType, int $ownedId): ?SubscriptionPlan + { + $acting = $this->contextResolver->tryResolve($user)?->toEntityPair(); + if ($acting === null || $acting[1] === null) { + return $this->subscriptionService->getEffectivePlan($ownedType, $ownedId); + } + + return $this->subscriptionService->getEffectivePlan($acting[0], (int) $acting[1]); + } + + /** فقط سقف‌ها و قابلیت‌ها — هویت و وضعیت اشتراکِ محیط دیگر افشا نمی‌شود. */ + private function planLimits(?SubscriptionPlan $plan): ?array + { + return $plan === null ? null : [ + 'features' => $plan->getFeatures(), + 'max_secretaries' => $plan->getMaxSecretaries(), + 'max_resources' => $plan->getMaxResources(), + ]; + } + private function resolveEntity(User $user): array { $owned = $this->contextResolver->ownedEntity($user); diff --git a/tests/Subscription/DualEnvironmentSubscriptionTest.php b/tests/Subscription/DualEnvironmentSubscriptionTest.php index 22b1a39e..c5ec07a4 100644 --- a/tests/Subscription/DualEnvironmentSubscriptionTest.php +++ b/tests/Subscription/DualEnvironmentSubscriptionTest.php @@ -5,6 +5,7 @@ namespace App\Tests\Subscription; use App\Auth\Entity\User; use App\Auth\Entity\UserActiveContext; use App\Clinic\Entity\Clinic; +use App\Clinic\Entity\ClinicDoctorPermission; use App\Doctor\Entity\Doctor; use App\Shared\Context\EntityContext; use App\Subscription\Entity\SubscriptionPeriod; @@ -83,6 +84,41 @@ class DualEnvironmentSubscriptionTest extends ApiTestCase self::assertNotNull($body['data']['subscription']); } + /** + * پزشکِ مهمانِ کلینیکِ دیگری سقف و قابلیت‌هایش را از کلینیکِ میزبان می‌گیرد — چون + * سرور هم با همان محیط می‌سنجد — ولی اشتراکِ خودش دست‌نخورده می‌ماند. + */ + public function testContextPlanFollowsTheHostClinicForAGuestDoctor(): void + { + $guest = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($guest, 'دکتر مهمان'); + $this->em->persist($doctor); + + $host = new Clinic($this->createUser(['ROLE_CLINIC'])); + $host->setName('کلینیک میزبان'); + $this->em->persist($host); + $this->em->flush(); + + $host->getDoctors()->add($doctor); + $this->em->persist(new ClinicDoctorPermission($host, $doctor)); + $this->em->flush(); + + $period = $this->makePeriod(); + $this->service()->grant('clinic', $host->getId(), $period->getUuid(), $host->getUser()); + + $this->setActiveContext($guest, $host->getUuid(), EntityContext::TYPE_CLINIC); + $body = $this->authJson('GET', '/api/v1/subscription/my', $guest); + + // سقف‌ها از کلینیک میزبان + self::assertSame($period->getPlan()->getMaxResources(), $body['data']['context_plan']['max_resources']); + self::assertTrue($body['data']['context_plan']['features']['patient_records']); + // ولی اشتراکِ خودِ پزشک همچنان free و بدون اشتراک فعال + self::assertSame('free', $body['data']['effective_plan']['name']); + self::assertNull($body['data']['subscription']); + // هویتِ پلنِ محیطِ میزبان افشا نمی‌شود + self::assertArrayNotHasKey('name', $body['data']['context_plan']); + } + public function testMyFallsBackToFreeWhileStandingInThePersonalPractice(): void { [$user, $doctor, $clinic] = $this->makeUserWithBothEnvironments();