Files
clinicpro/src/Subscription/Controller/SubscriptionController.php
T
hamedandClaude Opus 5 32044c8fa9 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>
2026-08-19 22:07:56 +03:30

453 lines
20 KiB
PHP

<?php
namespace App\Subscription\Controller;
use App\Auth\Entity\User;
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;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Repository\SubscriptionPlanRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Service\SubscriptionService;
use App\Subscription\Service\SubscriptionTaxCalculator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Subscription')]
class SubscriptionController extends BaseController
{
public function __construct(
private readonly SubscriptionService $subscriptionService,
private readonly SubscriptionPlanRepository $planRepo,
private readonly SubscriptionPeriodRepository $periodRepo,
private readonly ClinicSubscriptionRepository $subscriptionRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityContextResolver $contextResolver,
private readonly EntityManagerInterface $em,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
private readonly SubscriptionTaxCalculator $tax,
) {}
// ── Public ──────────────────────────────────────────────────────────────
#[Route('/api/v1/subscription/plans', methods: ['GET'])]
public function plans(): JsonResponse
{
$plans = $this->planRepo->findAllActive();
return $this->success($this->tax->decoratePlans(array_map(
fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true),
$plans
)));
}
// ── 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
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$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(),
'max_resources' => $effectivePlan->getMaxResources(),
],
]);
}
return $this->success([
'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(),
'used_trial' => $this->subscriptionService->hasUsedTrial($entityType, $entityId),
'effective_plan' => $effectivePlan?->toArray(),
]);
}
#[Route('/api/v1/subscription/trial', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function trial(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
try {
$subscription = $this->subscriptionService->activateTrial($entityType, $entityId);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success($subscription->toArray(), 201);
}
// ── Admin ────────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/subscription/plans', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminPlans(): JsonResponse
{
$plans = $this->planRepo->findAllForAdmin();
return $this->paginated(
$this->tax->decoratePlans(array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans)),
count($plans),
1,
100
);
}
/**
* سقف منابع فقط `-1` (نامحدود) یا عددی مثبت است.
*
* `0` پلنی می‌ساخت که هیچ منبعی نمی‌دهد و هر مقدار منفیِ دیگری هم مثل `-1` رفتار
* می‌کرد بی‌آنکه چیزی در پنل نشانش بدهد — هر دو خاموش و گیج‌کننده‌اند.
*/
private function rejectInvalidResourceLimit(array $data): ?JsonResponse
{
if (!isset($data['max_resources'])) {
return null;
}
$max = (int) $data['max_resources'];
if ($max === 0 || $max < SubscriptionPlan::UNLIMITED) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'max_resources باید عددی مثبت باشد یا -1 برای نامحدود',
422,
);
}
return null;
}
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePlan(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '' || !isset($data['level'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422);
}
if ($this->planRepo->findByName($name) !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
}
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
return $error;
}
$plan = new SubscriptionPlan(
$name,
(int) $data['level'],
(int) ($data['max_secretaries'] ?? 1),
$data['features'] ?? [],
(int) ($data['max_resources'] ?? 1)
);
$this->planRepo->save($plan);
return $this->success($plan->toArray(), 201);
}
#[Route('/api/v1/admin/subscription/plan/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePlan(string $uuid, Request $request): JsonResponse
{
$plan = $this->planRepo->findByUuid($uuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
return $error;
}
if (isset($data['name'])) {
$existing = $this->planRepo->findByName(trim($data['name']));
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
}
$plan->setName($data['name']);
}
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
if (isset($data['max_resources'])) { $plan->setMaxResources((int) $data['max_resources']); }
if (isset($data['features'])) { $plan->setFeatures($data['features']); }
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
$this->planRepo->save($plan);
return $this->success($plan->toArray());
}
#[Route('/api/v1/admin/subscription/period', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePeriod(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$planUuid = $data['plan_uuid'] ?? '';
$plan = $this->planRepo->findByUuid($planUuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پنل یافت نشد', 404);
}
if (empty($data['label']) || !isset($data['duration_months'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label و duration_months الزامی هستند', 422);
}
$period = new SubscriptionPeriod(
$plan,
$data['label'],
(int) $data['duration_months'],
(int) ($data['price_rials'] ?? 0),
(bool) ($data['is_trial'] ?? false)
);
$period->setSortOrder((int) ($data['sort_order'] ?? 0));
$this->periodRepo->save($period);
return $this->success($this->tax->decoratePeriod($period->toArray()), 201);
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePeriod(string $uuid, Request $request): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['label'])) { $period->setLabel($data['label']); }
if (isset($data['duration_months'])) { $period->setDurationMonths((int) $data['duration_months']); }
if (isset($data['price_rials'])) { $period->setPriceRials((int) $data['price_rials']); }
if (isset($data['active'])) { $period->setActive((bool) $data['active']); }
if (isset($data['sort_order'])) { $period->setSortOrder((int) $data['sort_order']); }
$this->periodRepo->save($period);
return $this->success($this->tax->decoratePeriod($period->toArray()));
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function adminDeletePeriod(string $uuid): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$period->setActive(false);
$this->periodRepo->save($period);
return $this->success(['message' => 'دوره غیرفعال شد']);
}
/**
* اعطای اشتراک به یک پزشک یا کلینیک، بدون پرداخت.
*
* مقصد با uuid گرفته می‌شود نه با id: id داخلی است و در هیچ پاسخِ ادمینی
* نمی‌آید، پس پنل چیزی برای فرستادن نداشت.
*/
#[Route('/api/v1/admin/subscription/grant', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminGrant(Request $request, #[CurrentUser] User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$entityType = (string) ($data['entity_type'] ?? '');
$entityUuid = (string) ($data['entity_uuid'] ?? '');
$periodUuid = (string) ($data['period_uuid'] ?? '');
if (!in_array($entityType, ['doctor', 'clinic'], true) || $entityUuid === '' || $periodUuid === '') {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'entity_type (doctor یا clinic) و entity_uuid و period_uuid الزامی هستند',
422,
);
}
$entityId = $entityType === 'doctor'
? $this->doctorRepo->findByUuid($entityUuid)?->getId()
: $this->clinicRepo->findByUuid($entityUuid)?->getId();
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404);
}
try {
$subscription = $this->subscriptionService->grant($entityType, $entityId, $periodUuid, $admin);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success($subscription->toArray(), 201);
}
/**
* حذف اشتراک — برگرداندنِ اعطای اشتباه.
*
* `grant` هر بار ردیف تازه می‌سازد و روی انقضای قبلی سوار می‌شود، پس بدون این
* مسیر، یک کلیک اضافی در تب اعطا راه برگشت نداشت.
*/
#[Route('/api/v1/admin/subscription/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function adminRevoke(string $uuid): JsonResponse
{
try {
$this->subscriptionService->revoke($uuid);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success(null);
}
/**
* اشتراک فعالِ یک مقصد — پیش از اعطا، تا ادمین downgrade را ناخواسته انجام ندهد.
*/
#[Route('/api/v1/admin/subscription/active/{entityType}/{entityUuid}', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminActiveSubscription(string $entityType, string $entityUuid): JsonResponse
{
if (!in_array($entityType, ['doctor', 'clinic'], true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'entity_type باید doctor یا clinic باشد', 422);
}
$entityId = $entityType === 'doctor'
? $this->doctorRepo->findByUuid($entityUuid)?->getId()
: $this->clinicRepo->findByUuid($entityUuid)?->getId();
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404);
}
return $this->success([
'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(),
]);
}
#[Route('/api/v1/admin/subscription/report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
$conn = $this->em->getConnection();
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM clinic_subscriptions');
$offset = ($page - 1) * $limit;
// نامِ مقصد با JOIN خام گرفته می‌شود، نه DQL: جفت (entity_type, entity_id)
// پلی‌مورفیک است و به هیچ association دکترینی وصل نیست.
$rows = $conn->fetchAllAssociative(
"SELECT s.uuid, s.entity_type, s.entity_id, s.is_trial, s.starts_at, s.expires_at, s.created_at,
s.granted_by_user_id, p.name AS plan_name, p.level AS plan_level,
d.name AS doctor_name, c.name AS clinic_name,
g.real_name AS granted_by_name, g.mobile_number AS granted_by_mobile
FROM clinic_subscriptions s
JOIN subscription_plans p ON p.id = s.plan_id
LEFT JOIN doctors d ON s.entity_type = 'doctor' AND d.id = s.entity_id
LEFT JOIN clinics c ON s.entity_type = 'clinic' AND c.id = s.entity_id
LEFT JOIN users g ON g.id = s.granted_by_user_id
ORDER BY s.id DESC
LIMIT $limit OFFSET $offset"
);
$items = array_map(fn(array $r) => [
'uuid' => $r['uuid'],
'entityType' => $r['entity_type'],
'entityId' => (int) $r['entity_id'],
'entityName' => $r['entity_type'] === 'doctor' ? $r['doctor_name'] : $r['clinic_name'],
'isTrial' => (bool) $r['is_trial'],
'isGranted' => $r['granted_by_user_id'] !== null,
'grantedBy' => $r['granted_by_user_id'] === null
? null
: ($r['granted_by_name'] ?: $r['granted_by_mobile']),
'startsAt' => (int) $r['starts_at'],
'expiresAt' => $r['expires_at'] === null ? null : (int) $r['expires_at'],
'createdAt' => (int) $r['created_at'],
'plan_name' => $r['plan_name'],
'plan_level' => (int) $r['plan_level'],
], $rows);
return $this->paginated($items, $total, $page, $limit);
}
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* عمداً از EntityContextResolver استفاده نمی‌کند: اشتراک به مالکِ حقوقی تعلق
* دارد، نه به محیطِ کاری لحظه‌ای — پزشکِ عضو در محیط کلینیک همچنان اشتراک
* خودش را می‌بیند، نه اشتراک کلینیک. انتقال به رزولور این را عوض می‌کند.
*
* @return array{0: string, 1: int|null} [entityType, entityId]
*/
/**
* محیطی که اشتراکِ این کاربر روی آن می‌نشیند.
*
* همان مرجعی که خریدِ اشتراک استفاده می‌کند (`PaymentController::subscriptionInit`)،
* تا نمایش پنل و پرداخت و اعطای ادمین هر سه یک محیط را ببینند. نقش‌محورِ محلی
* بود و برای کاربری که هم پزشک است و هم مالک کلینیک، همیشه پزشک را برمی‌گرداند:
* اشتراکِ کلینیک چنین کاربری در پنل «اعمال‌نشده» دیده می‌شد.
*
* منشی و پرسنل صاحب محیطی نیستند، پس برایشان محیط فعال خوانده می‌شود.
*/
private function resolveEntity(User $user): array
{
$owned = $this->contextResolver->ownedEntity($user);
if ($owned->isResolved()) {
return $owned->toEntityPair();
}
return $this->contextResolver->tryResolve($user)?->toEntityPair() ?? ['unknown', null];
}
}