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();