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>
453 lines
20 KiB
PHP
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];
|
|
}
|
|
}
|