From 828e3552c077cb7088e7af4222df25f49c7c8e29 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 5 Jul 2026 10:46:31 +0330 Subject: [PATCH] feat(subscription): implement effective plan logic and update free plan features --- assets/admin/hooks/hooks.test.tsx | 18 ++++++++ assets/admin/hooks/useSubscription.ts | 7 +-- assets/admin/pages/AdminSubscriptionPage.tsx | 16 ++++--- assets/admin/types/index.ts | 8 ++++ docs/api/subscription.md | 25 +++++++++-- migrations/Version20260705070546.php | 43 +++++++++++++++++++ .../Controller/SubscriptionController.php | 24 ++++++++--- src/Subscription/Entity/SubscriptionPlan.php | 4 +- .../Repository/SubscriptionPlanRepository.php | 8 ++++ .../Service/SubscriptionService.php | 25 ++++++----- 10 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 migrations/Version20260705070546.php diff --git a/assets/admin/hooks/hooks.test.tsx b/assets/admin/hooks/hooks.test.tsx index d0ebce63..72546737 100644 --- a/assets/admin/hooks/hooks.test.tsx +++ b/assets/admin/hooks/hooks.test.tsx @@ -43,6 +43,24 @@ describe('useSubscription', () => { expect(result.current.isExpiringSoon).toBe(true); }); + it('نبود اشتراک: امکانات را از effective_plan (پلن free) می‌گیرد', async () => { + useAuthStore.setState({ primaryRole: 'doctor' }); + get.mockResolvedValue({ + data: { + subscription: null, + effective_plan: { features: { patient_records: true, services: true, sms_panel: true }, max_secretaries: 1 }, + }, + }); + + const { result } = renderHookWithClient(() => useSubscription()); + await waitFor(() => expect(result.current.hasFeature('patient_records')).toBe(true)); + + expect(result.current.hasFeature('services')).toBe(true); + expect(result.current.hasFeature('sms_panel')).toBe(true); + expect(result.current.hasPlan).toBe(false); + expect(result.current.maxSecretaries).toBe(1); + }); + it('برای نقش admin غیرفعال است (query اجرا نمی‌شود)', async () => { useAuthStore.setState({ primaryRole: 'admin' }); const { result } = renderHookWithClient(() => useSubscription()); diff --git a/assets/admin/hooks/useSubscription.ts b/assets/admin/hooks/useSubscription.ts index b4f016ea..a6790af3 100644 --- a/assets/admin/hooks/useSubscription.ts +++ b/assets/admin/hooks/useSubscription.ts @@ -16,13 +16,14 @@ export function useSubscription() { }); const sub = data?.data?.subscription ?? null; - const features: Record = sub?.plan?.features ?? {}; - const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; + const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null; + const features: Record = effectivePlan?.features ?? {}; + const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1; const hasPlan = sub !== null; return { subscription: sub, - hasFeature: (key: string) => hasPlan && (features[key] ?? false), + hasFeature: (key: string) => features[key] ?? false, maxSecretaries, hasPlan, isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index 0fa5244d..6e3a8f37 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -181,11 +181,16 @@ function PlansTab() {
در حال بارگذاری...
) : (
- {plans.map((plan) => ( + {plans.map((plan) => { + const periods: SubscriptionPeriod[] = Array.isArray(plan.periods) + ? plan.periods + : Object.values(plan.periods ?? {}); + return (
{PLAN_DISPLAY[plan.name] ?? plan.name} + {plan.active ? 'فعال' : 'غیرفعال'} سطح {plan.level} حداکثر {plan.max_secretaries} منشی @@ -204,7 +209,7 @@ function PlansTab() {
- {plan.periods.length === 0 ? ( + {periods.length === 0 ? (
هنوز دوره‌ای تعریف نشده
) : (
@@ -218,8 +223,8 @@ function PlansTab() { - {plan.periods.map((period, i) => ( - + {periods.map((period, i) => ( + @@ -242,7 +247,8 @@ function PlansTab() {
{period.label} {period.duration_months} ماه {period.is_trial ? 'رایگان' : formatRial(period.price_rials)}
)}
- ))} + ); + })}
)} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index c36ee67f..aa91a12a 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -362,6 +362,7 @@ export interface SubscriptionPlan { level: number; max_secretaries: number; features: Record; + active: boolean; periods: SubscriptionPeriod[]; } @@ -388,6 +389,13 @@ export interface MySubscriptionData { days_remaining?: number; } | null; used_trial: boolean; + /** پلن مؤثر: پلن اشتراک فعال یا پلن پیش‌فرض free در نبود اشتراک. */ + effective_plan: { + name: string; + level: number; + max_secretaries: number; + features: Record; + } | null; } /** @deprecated use MySubscriptionData */ export interface MySubscription { diff --git a/docs/api/subscription.md b/docs/api/subscription.md index 494e94b0..bfbdbbb9 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -8,6 +8,8 @@ لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth). +> پلن `free`: همه امکانات (`patient_records`, `services`, `sms_panel`, `insurance`) فعال‌اند؛ تنها محدودیت آن تعداد منشی (`max_secretaries`) است. + **Response 200:** ```json { @@ -18,7 +20,7 @@ "name": "free", "level": 0, "max_secretaries": 1, - "features": { "patient_records": false, "services": false, "sms_panel": false }, + "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true }, "active": true, "periods": [] }, @@ -71,12 +73,13 @@ "days_remaining": 30, "is_active": true }, - "used_trial": false + "used_trial": false, + "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "features": {...} } } } ``` -اگر اشتراک فعالی نداشت `subscription` برابر `null` است. +اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است. --- @@ -143,7 +146,7 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu ## Admin Endpoints ### GET /api/v1/admin/subscription/plans -**Permission:** `ROLE_ADMIN` — لیست همه پنل‌ها +**Permission:** `ROLE_ADMIN` — لیست **همه** پلن‌ها شامل غیرفعال‌ها (بر خلاف endpoint عمومی که فقط فعال‌ها را برمی‌گرداند). هر پلن فیلد `active` دارد و `periods` همیشه یک آرایه است (فقط دوره‌های فعال). ### POST /api/v1/admin/subscription/plan **Permission:** `ROLE_ADMIN` @@ -157,9 +160,23 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu } ``` +**خطاها:** + +| کد | HTTP | شرح | +|----|------|-----| +| ERR_VALIDATION_001 | 422 | `name` یا `level` ارسال نشده | +| ERR_VALIDATION_001 | 422 | پلنی با این نام از قبل وجود دارد (نام یکتاست) | + ### PATCH /api/v1/admin/subscription/plan/{uuid} **Permission:** `ROLE_ADMIN` — ویرایش پنل (همه فیلدها اختیاری) +**خطاها:** + +| کد | HTTP | شرح | +|----|------|-----| +| ERR_SUBSCRIPTION_NOT_FOUND | 404 | پلن یافت نشد | +| ERR_VALIDATION_001 | 422 | نام جدید متعلق به پلن دیگری است | + ### POST /api/v1/admin/subscription/period **Permission:** `ROLE_ADMIN` diff --git a/migrations/Version20260705070546.php b/migrations/Version20260705070546.php new file mode 100644 index 00000000..fbab8548 --- /dev/null +++ b/migrations/Version20260705070546.php @@ -0,0 +1,43 @@ +addSql( + "UPDATE subscription_plans + SET features = '{\"patient_records\":true,\"services\":true,\"sms_panel\":true,\"insurance\":true}', + active = 1, + updated_at = {$now} + WHERE name = 'free'" + ); + } + + public function down(Schema $schema): void + { + $now = time(); + $this->addSql( + "UPDATE subscription_plans + SET features = '{\"patient_records\":false,\"services\":false,\"sms_panel\":false}', + updated_at = {$now} + WHERE name = 'free'" + ); + } +} diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index cdb8f16d..3b21c931 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -61,12 +61,14 @@ class SubscriptionController extends BaseController return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } - $subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId); - $usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId); + $subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId); + $usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId); + $effectivePlan = $this->subscriptionService->getEffectivePlan($entityType, $entityId); return $this->success([ - 'subscription' => $subscription?->toArray(), - 'used_trial' => $usedTrial, + 'subscription' => $subscription?->toArray(), + 'used_trial' => $usedTrial, + 'effective_plan' => $effectivePlan?->toArray(), ]); } @@ -94,7 +96,7 @@ class SubscriptionController extends BaseController #[IsGranted('ROLE_ADMIN')] public function adminPlans(): JsonResponse { - $plans = $this->planRepo->findAllActive(); + $plans = $this->planRepo->findAllForAdmin(); return $this->paginated( array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans), @@ -115,6 +117,10 @@ class SubscriptionController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422); } + if ($this->planRepo->findByName($name) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422); + } + $plan = new SubscriptionPlan( $name, (int) $data['level'], @@ -138,7 +144,13 @@ class SubscriptionController extends BaseController $data = json_decode($request->getContent(), true) ?? []; - if (isset($data['name'])) { $plan->setName($data['name']); } + 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['features'])) { $plan->setFeatures($data['features']); } diff --git a/src/Subscription/Entity/SubscriptionPlan.php b/src/Subscription/Entity/SubscriptionPlan.php index 6a465c68..b80b6e92 100644 --- a/src/Subscription/Entity/SubscriptionPlan.php +++ b/src/Subscription/Entity/SubscriptionPlan.php @@ -90,10 +90,10 @@ class SubscriptionPlan ]; if ($withPeriods) { - $data['periods'] = array_map( + $data['periods'] = array_values(array_map( fn(SubscriptionPeriod $p) => $p->toArray(), $this->periods->filter(fn(SubscriptionPeriod $p) => $p->isActive())->toArray() - ); + )); } return $data; diff --git a/src/Subscription/Repository/SubscriptionPlanRepository.php b/src/Subscription/Repository/SubscriptionPlanRepository.php index 70bf86ad..75ed56d1 100644 --- a/src/Subscription/Repository/SubscriptionPlanRepository.php +++ b/src/Subscription/Repository/SubscriptionPlanRepository.php @@ -32,6 +32,14 @@ class SubscriptionPlanRepository extends ServiceEntityRepository ->getResult(); } + public function findAllForAdmin(): array + { + return $this->createQueryBuilder('p') + ->orderBy('p.level', 'ASC') + ->getQuery() + ->getResult(); + } + public function save(SubscriptionPlan $plan): void { $this->getEntityManager()->persist($plan); diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index ed67eee5..51830658 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -7,6 +7,7 @@ use App\Payment\Entity\Payment; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; use App\Subscription\Entity\ClinicSubscription; +use App\Subscription\Entity\SubscriptionPlan; use App\Subscription\Repository\ClinicSubscriptionRepository; use App\Subscription\Repository\SubscriptionPeriodRepository; use App\Subscription\Repository\SubscriptionPlanRepository; @@ -25,24 +26,28 @@ class SubscriptionService return $this->subscriptionRepo->findActive($entityType, $entityId); } - public function hasFeature(string $entityType, int $entityId, string $feature): bool + /** + * پلن مؤثر: پلن اشتراک فعال، یا در نبود اشتراک، پلن پیش‌فرض «free». + */ + public function getEffectivePlan(string $entityType, int $entityId): ?SubscriptionPlan { $subscription = $this->getActiveSubscription($entityType, $entityId); - if ($subscription === null) { - return false; - } - return $subscription->getPlan()->hasFeature($feature); + return $subscription?->getPlan() ?? $this->planRepo->findByName('free'); + } + + public function hasFeature(string $entityType, int $entityId, string $feature): bool + { + $plan = $this->getEffectivePlan($entityType, $entityId); + + return $plan !== null && $plan->hasFeature($feature); } public function getSecretaryLimit(string $entityType, int $entityId): int { - $subscription = $this->getActiveSubscription($entityType, $entityId); - if ($subscription === null) { - return 1; - } + $plan = $this->getEffectivePlan($entityType, $entityId); - return $subscription->getPlan()->getMaxSecretaries(); + return $plan?->getMaxSecretaries() ?? 1; } public function hasUsedTrial(string $entityType, int $entityId): bool