fix(subscription): report the acting environment's plan for gating

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 14:48:13 +03:30
co-authored by Claude Opus 5
parent 25a12e9a06
commit 601d211d6f
5 changed files with 99 additions and 12 deletions
+8 -4
View File
@@ -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<string, boolean> = effectivePlan?.features ?? {};
const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1;
// سقف‌ها و قابلیت‌ها از پلنِ محیطِ فعال می‌آیند، نه از پلنِ محیطِ مالکیت: سرور هم با
// همین محیط می‌سنجد. پزشکِ مهمانِ یک کلینیک، منابعش را از سهمیهٔ کلینیک میزبان
// برمی‌دارد ولی سقفِ پلنِ خودش نمایش داده می‌شد و عددِ سهمیه بی‌معنی بود.
const gatePlan = data?.data?.context_plan ?? effectivePlan;
const features: Record<string, boolean> = 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 {
+11 -1
View File
@@ -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<string, boolean>;
} | null;
/**
* پلن محیطی که کاربر همین حالا در آن ایستاده — مبنای سقف‌ها و قفل قابلیت‌ها،
* چون سرور هم با همین محیط می‌سنجد. برای کاربری که فقط محیط خودش را دارد با
* `effective_plan` یکی است.
*/
context_plan: {
max_secretaries: number;
max_resources: number;
features: Record<string, boolean>;
} | null;
}
/** @deprecated use MySubscriptionData */
export interface MySubscription {
+11 -2
View File
@@ -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 }
}}
```
@@ -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);
@@ -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();