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

6.0 KiB
Raw Blame History

معماری — تسک ۱۱: پنل اشتراکی (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

#[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

#[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

#[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

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

// src/Payment/Controller/PaymentController.php
// متد subscriptionCallback() — بعد از تأیید پرداخت:

$subscriptionService->createFromPayment($payment, $periodUuid, $entityType, $entityId);

// این متد:
// 1. Period را پیدا می‌کند
// 2. expires_at را محاسبه می‌کند (در صورت تمدید از expires_at قبلی)
// 3. ClinicSubscription جدید می‌سازد
// 4. persist/flush