Files
clinicpro/docs/phase2_taskes/task-11-subscription/architecture.md
T

208 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# معماری — تسک ۱۱: پنل اشتراکی (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
```