fix(subscription): read the subscription of the environment the user owns

A user who is both a doctor and a clinic owner always resolved to the
doctor: SubscriptionController had its own role-first resolveEntity, and
ownedEntity() returned the doctor whenever one existed. So a subscription
granted to that user's clinic was stored correctly but never surfaced —
/subscription/my kept reporting the free plan and the panel kept the
feature-gated menu items locked.

ownedEntity() now disambiguates with the active context when the user owns
both environments, and the controller delegates to it instead of re-deriving
the pair from roles. Payment already used ownedEntity(), so display, purchase
and admin grant now agree on one environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 22:07:56 +03:30
co-authored by Claude Opus 5
parent b81cb8a90e
commit 32044c8fa9
4 changed files with 89 additions and 28 deletions
+7 -1
View File
@@ -82,7 +82,13 @@
**Permission:** `IS_AUTHENTICATED_FULLY`
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان entity برگردانده می‌شود.
**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده می‌کند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.
کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحب‌شده دارد. آنجا محیط فعال
(`UserActiveContext`) تعیین می‌کند اشتراک کدام‌یک خوانده شود. بدون محیط فعال، مطب
شخصی پیش‌فرض است.
**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار می‌کند. منشی محیط صاحب‌شده ندارد، پس محیط فعالش خوانده می‌شود و اشتراک همان entity برمی‌گردد.
**Response 200:**
```json
+21 -2
View File
@@ -81,16 +81,35 @@ class EntityContextResolver
* برای خریدهایی است که به حساب خودِ صاحب می‌نشیند (اشتراک): آنجا «کجا ایستاده‌ام»
* مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ
* اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند.
*
* استثنا: کاربری که هر دو محیط را دارد. آنجا محیط فعال تعیین می‌کند کدام‌یک،
* وگرنه یکی از دو محیطِ صاحب‌شده هرگز اشتراک نمی‌گیرد.
*/
public function ownedEntity(User $user): EntityContext
{
$doctor = $this->doctorRepo->findByUser($user);
$clinic = $this->clinicRepo->findByUser($user);
// کاربری که هم پزشک است و هم مالک کلینیک، دو محیطِ صاحب‌شده دارد و «چه چیزی
// دارم» به‌تنهایی جواب یکتا ندارد. محیط فعال گره را باز می‌کند: اشتراک همان
// جایی می‌نشیند که کاربر ایستاده و آن را می‌بیند. بدون این، اعطای اشتراک به
// کلینیک برای چنین کاربری بی‌اثر می‌ماند، چون پنل همیشه پزشک را می‌خواند.
if ($doctor !== null && $clinic !== null) {
$active = $this->fromActiveContext($user);
if ($active !== null) {
if ($active->isClinic() && $active->toEntityPair()[1] === $clinic->getId()) {
return EntityContext::forClinic($clinic);
}
if (!$active->isClinic() && $active->toEntityPair()[1] === $doctor->getId()) {
return EntityContext::forDoctor($doctor);
}
}
}
if ($doctor !== null) {
return EntityContext::forDoctor($doctor);
}
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
@@ -3,10 +3,10 @@
namespace App\Subscription\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\SubscriptionPeriod;
@@ -34,7 +34,7 @@ class SubscriptionController extends BaseController
private readonly ClinicSubscriptionRepository $subscriptionRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly EntityManagerInterface $em,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
private readonly SubscriptionTaxCalculator $tax,
@@ -430,32 +430,23 @@ class SubscriptionController extends BaseController
*
* @return array{0: string, 1: int|null} [entityType, entityId]
*/
/**
* محیطی که اشتراکِ این کاربر روی آن می‌نشیند.
*
* همان مرجعی که خریدِ اشتراک استفاده می‌کند (`PaymentController::subscriptionInit`)،
* تا نمایش پنل و پرداخت و اعطای ادمین هر سه یک محیط را ببینند. نقش‌محورِ محلی
* بود و برای کاربری که هم پزشک است و هم مالک کلینیک، همیشه پزشک را برمی‌گرداند:
* اشتراکِ کلینیک چنین کاربری در پنل «اعمال‌نشده» دیده می‌شد.
*
* منشی و پرسنل صاحب محیطی نیستند، پس برایشان محیط فعال خوانده می‌شود.
*/
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
$owned = $this->contextResolver->ownedEntity($user);
if ($owned->isResolved()) {
return $owned->toEntityPair();
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
if ($user->hasRole('ROLE_SECRETARY')) {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid !== null) {
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return ['clinic', $clinic->getId()];
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
return ['doctor', $doctor->getId()];
}
}
}
return ['unknown', null];
return $this->contextResolver->tryResolve($user)?->toEntityPair() ?? ['unknown', null];
}
}
@@ -158,6 +158,51 @@ class EntityContextResolverTest extends ApiTestCase
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
}
// ── محیطِ صاحب‌شده (اشتراک) ──────────────────────────────────────────────
/**
* اشتراک روی محیطی می‌نشیند که کاربر صاحبش است. کاربری که هم پزشک است و هم
* مالک کلینیک، دو محیط صاحب‌شده دارد و محیط فعال تعیین می‌کند کدام‌یک؛ وگرنه
* اشتراکِ اعطاشده به کلینیک هرگز در پنل دیده نمی‌شود.
*/
public function testOwnedEntityFollowsActiveContextWhenUserOwnsBoth(): void
{
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$this->makeDoctor($user);
$clinic = $this->makeClinic($user);
$this->setClinicContext($user, $clinic);
self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
}
public function testOwnedEntityIsOwnPracticeWhenActiveContextIsSelf(): void
{
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = $this->makeDoctor($user);
$this->makeClinic($user);
$this->setDoctorContext($user, $doctor);
self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
}
/** بدون محیط فعال، مطب شخصی همان پیش‌فرض قبلی می‌ماند. */
public function testOwnedEntityFallsBackToOwnPracticeWithoutActiveContext(): void
{
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = $this->makeDoctor($user);
$this->makeClinic($user);
self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
}
/** مالک کلینیکِ غیرپزشک تنها یک محیط دارد و محیط فعال چیزی را عوض نمی‌کند. */
public function testOwnedEntityIsClinicForNonDoctorOwner(): void
{
$clinic = $this->makeClinic();
self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($clinic->getUser())->toEntityPair());
}
// ── سناریو ۴: مدیر/مالک کلینیک (غیرپزشک) ────────────────────────────────
public function testClinicManagerAlwaysResolvesToClinic(): void