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]; } }