# معماری — تسک ۱۱: پنل اشتراکی (Subscription Tiers) ## ساختار فایل‌ها ``` src/Subscription/ ├── Controller/ │ └── SubscriptionController.php ├── Entity/ │ ├── SubscriptionPlan.php │ ├── SubscriptionPeriod.php │ └── ClinicSubscription.php ├── Repository/ │ ├── SubscriptionPlanRepository.php │ ├── SubscriptionPeriodRepository.php │ └── ClinicSubscriptionRepository.php └── Service/ └── SubscriptionService.php ``` **فایل‌هایی که تغییر می‌کنند:** - `src/Payment/Controller/PaymentController.php` — متد `subscriptionCallback()` باید پس از تأیید پرداخت، `ClinicSubscription` بسازد ## Entity: SubscriptionPlan ```php #[ORM\Entity] #[ORM\Table(name: 'subscription_plans')] class SubscriptionPlan { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; #[ORM\Column(type: 'string', length: 30)] private string $name; // 'free' | 'basic' | 'professional' #[ORM\Column(type: 'smallint')] private int $level; // 0 | 1 | 2 #[ORM\Column(type: 'smallint')] private int $maxSecretaries; // 1 | 2 | 5 #[ORM\Column(type: 'json')] private array $features; // {"patient_records": bool, "services": bool, "sms_panel": bool} #[ORM\Column(type: 'boolean')] private bool $active = true; #[ORM\Column(type: 'integer')] private int $createdAt; #[ORM\Column(type: 'integer')] private int $updatedAt; // OneToMany → SubscriptionPeriod } ``` ## Entity: SubscriptionPeriod ```php #[ORM\Entity] #[ORM\Table(name: 'subscription_periods')] class SubscriptionPeriod { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; #[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)] #[ORM\JoinColumn(name: 'plan_id', nullable: false, onDelete: 'CASCADE')] private SubscriptionPlan $plan; #[ORM\Column(type: 'string', length: 50)] private string $label; // '۶ ماهه', 'تریال ۱ ماهه' #[ORM\Column(type: 'smallint')] private int $durationMonths; #[ORM\Column(type: 'integer')] private int $priceRials; // 0 برای تریال #[ORM\Column(type: 'boolean')] private bool $isTrial = false; #[ORM\Column(type: 'boolean')] private bool $active = true; #[ORM\Column(type: 'smallint')] private int $sortOrder = 0; #[ORM\Column(type: 'integer')] private int $createdAt; #[ORM\Column(type: 'integer')] private int $updatedAt; } ``` ## Entity: ClinicSubscription ```php #[ORM\Entity] #[ORM\Table(name: 'clinic_subscriptions')] #[ORM\Index(columns: ['entity_type', 'entity_id', 'expires_at'], name: 'idx_clinic_sub_entity')] class ClinicSubscription { #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] private ?int $id = null; #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; #[ORM\Column(type: 'string', length: 10)] private string $entityType; // 'doctor' | 'clinic' #[ORM\Column(type: 'integer')] private int $entityId; #[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)] #[ORM\JoinColumn(nullable: false)] private SubscriptionPlan $plan; #[ORM\ManyToOne(targetEntity: SubscriptionPeriod::class)] #[ORM\JoinColumn(nullable: false)] private SubscriptionPeriod $period; #[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?\App\Payment\Entity\Payment $payment = null; // null برای تریال #[ORM\Column(type: 'boolean')] private bool $isTrial = false; #[ORM\Column(type: 'integer')] private int $startsAt; #[ORM\Column(type: 'integer', nullable: true)] private ?int $expiresAt = null; // null = بی‌نهایت (Free) #[ORM\Column(type: 'integer')] private int $createdAt; } ``` ## Service: SubscriptionService ```php class SubscriptionService { public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription { // آخرین رکورد که expires_at > time() OR expires_at IS NULL } public function hasFeature(string $entityType, int $entityId, string $feature): bool { $sub = $this->getActiveSubscription($entityType, $entityId); if (!$sub) return false; return $sub->getPlan()->getFeatures()[$feature] ?? false; } public function getSecretaryLimit(string $entityType, int $entityId): int { $sub = $this->getActiveSubscription($entityType, $entityId); return $sub ? $sub->getPlan()->getMaxSecretaries() : 1; // Free = 1 } public function hasUsedTrial(string $entityType, int $entityId): bool { return $this->subscriptionRepo->existsTrial($entityType, $entityId); } public function activateTrial(string $entityType, int $entityId): ClinicSubscription { // بررسی: آیا قبلاً تریال استفاده شده؟ → throw AppException // بررسی: آیا trial_enabled در SiteConfig فعال است؟ // ساخت ClinicSubscription با is_trial=true, payment=null } public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int { $base = max($currentExpiresAt ?? 0, time()); return $base + ($durationMonths * 30 * 86400); } } ``` ## وابستگی PaymentController ```php // src/Payment/Controller/PaymentController.php // متد subscriptionCallback() — بعد از تأیید پرداخت: $subscriptionService->createFromPayment($payment, $periodUuid, $entityType, $entityId); // این متد: // 1. Period را پیدا می‌کند // 2. expires_at را محاسبه می‌کند (در صورت تمدید از expires_at قبلی) // 3. ClinicSubscription جدید می‌سازد // 4. persist/flush ```