From 2db4c3c4b0ca078a0c712242cf2f11d305e41e8d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 19:50:47 +0330 Subject: [PATCH] feat(subscription): add resource quota management for subscription plans and update related components --- assets/admin/lib/utils.ts | 10 ++ .../pages/AdminSubscriptionPage.test.tsx | 87 +++++++++++++++ assets/admin/pages/AdminSubscriptionPage.tsx | 49 +++++++-- assets/admin/pages/SubscriptionPage.test.tsx | 18 +++- assets/admin/pages/SubscriptionPage.tsx | 18 +++- docs/api/subscription.md | 4 + .../Controller/SubscriptionController.php | 33 ++++++ .../AdminPlanResourceLimitTest.php | 101 ++++++++++++++++++ 8 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 assets/admin/pages/AdminSubscriptionPage.test.tsx create mode 100644 tests/Subscription/AdminPlanResourceLimitTest.php diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index 4ca1a348..d68ff527 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -14,6 +14,16 @@ export function formatNumber(n: number): string { return new Intl.NumberFormat('fa-IR').format(n); } +/** + * سقف منابعِ یک پلن اشتراک به زبان آدم — `-1` یعنی نامحدود. + * + * سه صفحه همین عدد را نشان می‌دهند (منابع، اشتراک، اشتراکِ ادمین)؛ یک جا نوشته می‌شود + * تا «نامحدود» در یکی از آن‌ها به «-۱» تبدیل نشود. + */ +export function formatResourceLimit(max: number): string { + return max < 0 ? 'نامحدود' : formatNumber(max); +} + // سال شمسی: رقم فارسی بدون جداکننده‌ی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»). export function formatYear(year: number): string { return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year); diff --git a/assets/admin/pages/AdminSubscriptionPage.test.tsx b/assets/admin/pages/AdminSubscriptionPage.test.tsx new file mode 100644 index 00000000..03d10aa7 --- /dev/null +++ b/assets/admin/pages/AdminSubscriptionPage.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import AdminSubscriptionPage from './AdminSubscriptionPage'; + +const get = api.get as ReturnType; +const patch = api.patch as ReturnType; + +const PLANS = [ + { + uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, + features: { patient_records: true, services: true, sms_panel: false }, active: true, periods: [], + }, + { + uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1, + features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [], + }, +]; + +function mockApi() { + get.mockImplementation((url: string) => { + if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS }); + return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } }); + }); + patch.mockResolvedValue({ success: true, data: PLANS[0] }); +} + +/** مودالِ ویرایشِ پلنِ داده‌شده را باز می‌کند. هر کارت یک دکمهٔ مداد دارد. */ +async function openEdit(planLabel: string) { + const card = (await screen.findByText(planLabel)).closest('.card') as HTMLElement; + const buttons = within(card).getAllByRole('button'); + fireEvent.click(buttons[buttons.length - 1]); +} + +describe('AdminSubscriptionPage — سقف منابع پلن', () => { + beforeEach(() => { get.mockReset(); patch.mockReset(); mockApi(); }); + + it('سقف منابع هر پلن را روی کارتش نشان می‌دهد', async () => { + renderWithProviders(, { route: '/admin/admin-subscription' }); + + expect(await screen.findByText('حداکثر ۳ منبع')).toBeInTheDocument(); + expect(screen.getByText('حداکثر نامحدود منبع')).toBeInTheDocument(); + }); + + it('فرم ویرایش با مقدار فعلی پر می‌شود و همان را می‌فرستد', async () => { + renderWithProviders(, { route: '/admin/admin-subscription' }); + await openEdit('پایه'); + + const input = await screen.findByLabelText('حداکثر منبع *'); + expect(input).toHaveValue('3'); + + fireEvent.change(input, { target: { value: '5' } }); + fireEvent.click(screen.getByText('ذخیره')); + + await waitFor(() => expect(patch).toHaveBeenCalled()); + expect(patch.mock.calls[0][0]).toBe('/api/v1/admin/subscription/plan/p-basic'); + expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: 5 }); + }); + + /** ادمین «۱-» تایپ نمی‌کند؛ سوییچ همان قرارداد را می‌سازد. */ + it('سوییچ «منابع نامحدود» مقدار ۱- می‌فرستد', async () => { + renderWithProviders(, { route: '/admin/admin-subscription' }); + await openEdit('پایه'); + + await screen.findByLabelText('حداکثر منبع *'); + fireEvent.click(screen.getByText('منابع نامحدود')); + fireEvent.click(screen.getByText('ذخیره')); + + await waitFor(() => expect(patch).toHaveBeenCalled()); + expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: -1 }); + }); + + it('پلن نامحدود با سوییچِ روشن باز می‌شود و فیلد عددی‌اش قفل است', async () => { + renderWithProviders(, { route: '/admin/admin-subscription' }); + await openEdit('حرفه‌ای'); + + expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled(); + }); +}); diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index 38a1fc29..703e31c1 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -8,7 +8,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse } from '../lib/api'; import type { SubscriptionPlan, SubscriptionPeriod } from '../types'; -import { formatRial, formatNumber, formatDate } from '../lib/utils'; +import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; @@ -35,11 +35,23 @@ const planSchema = z.object({ name: z.string().min(1, 'نام الزامی است'), level: z.coerce.number().min(0), max_secretaries: z.coerce.number().min(1), + // API نامحدود را با `-1` می‌فهمد، ولی فرم عددِ منفی نمی‌گیرد: `numericField` علامت را + // پاک می‌کند و «۱-» هم چیزی نیست که ادمین حدس بزند. پس یک سوییچ، و نگاشت هنگام ارسال. + resources_unlimited: z.boolean(), + max_resources: z.coerce.number().int().min(1), features: z.record(z.string(), z.boolean()), active: z.boolean(), }); type PlanForm = z.infer; +/** بدنهٔ واقعی API: سوییچِ «نامحدود» به همان `-1` قراردادی برمی‌گردد. */ +type PlanPayload = Omit; + +const toPlanPayload = ({ resources_unlimited, max_resources, ...rest }: PlanForm): PlanPayload => ({ + ...rest, + max_resources: resources_unlimited ? -1 : max_resources, +}); + const periodSchema = z.object({ plan_uuid: z.string().min(1, 'پلن الزامی است'), label: z.string().min(1, 'عنوان دوره الزامی است'), @@ -105,19 +117,21 @@ function PlansTab() { defaultValues: { features: emptyFeatures(), active: true }, }); + const unlimitedResources = planForm.watch('resources_unlimited'); + const periodForm = useForm({ resolver: zodResolver(periodSchema), defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 }, }); const createPlanMut = useMutation({ - mutationFn: (body: PlanForm) => api.post('/api/v1/admin/subscription/plan', body), + mutationFn: (body: PlanPayload) => api.post('/api/v1/admin/subscription/plan', body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پلن ایجاد شد'); }, onError: (e: any) => toast.error(e.message), }); const updatePlanMut = useMutation({ - mutationFn: ({ uuid, body }: { uuid: string; body: PlanForm }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body), + mutationFn: ({ uuid, body }: { uuid: string; body: PlanPayload }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پلن بروزرسانی شد'); }, onError: (e: any) => toast.error(e.message), }); @@ -145,6 +159,10 @@ function PlansTab() { name: plan.name, level: plan.level, max_secretaries: plan.max_secretaries, + // سقفِ نامحدود عددی برای نمایش ندارد؛ ۱ می‌نشیند تا خاموش‌کردن سوییچ یک مقدار + // معتبر بدهد، نه یک فیلدِ خالی. + resources_unlimited: plan.max_resources < 0, + max_resources: plan.max_resources < 0 ? 1 : plan.max_resources, features: { ...emptyFeatures(), ...plan.features }, active: (plan as any).active ?? true, }); @@ -173,7 +191,7 @@ function PlansTab() { return ( <>
-
@@ -194,6 +212,7 @@ function PlansTab() { {plan.active ? 'فعال' : 'غیرفعال'} سطح {plan.level} حداکثر {plan.max_secretaries} منشی + حداکثر {formatResourceLimit(plan.max_resources)} منبع {Object.entries(plan.features).map(([k, v]) => ( {FEATURE_LABELS[k] ?? k} @@ -260,8 +279,9 @@ function PlansTab() { title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'} >
{ - if (planModal === 'create') createPlanMut.mutate(d); - else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d }); + const body = toPlanPayload(d); + if (planModal === 'create') createPlanMut.mutate(body); + else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body }); })}>
@@ -279,6 +299,23 @@ function PlansTab() {
+
+ + + + سقف اتاق و دستگاه و پرسنلِ هر محیط. منابعِ پزشک و پرسنل هم در این شمارش می‌آیند. + {planForm.formState.errors.max_resources && ( + {planForm.formState.errors.max_resources.message} + )} +
قابلیت‌های پلن
diff --git a/assets/admin/pages/SubscriptionPage.test.tsx b/assets/admin/pages/SubscriptionPage.test.tsx index b0dd02ce..fb787e60 100644 --- a/assets/admin/pages/SubscriptionPage.test.tsx +++ b/assets/admin/pages/SubscriptionPage.test.tsx @@ -15,20 +15,20 @@ import SubscriptionPage from './SubscriptionPage'; const get = api.get as ReturnType; const PLANS = [ - { uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, + { uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, max_resources: 1, features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] }, - { uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, + { uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: { patient_records: true, services: true, sms_panel: false }, active: true, periods: [ { uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false }, { uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false }, ] }, - { uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, + { uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1, features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] }, ]; const DEFAULT_MY = { - subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 }, + subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: {} }, is_trial: false, days_remaining: 25 }, used_trial: true, effective_plan: null, }; @@ -54,6 +54,16 @@ describe('SubscriptionPage', () => { expect(proCard.getByText('محبوب‌ترین')).toBeInTheDocument(); }); + /** سقف منابع کنار سقف منشی روی هر کارت پلن می‌نشیند؛ `-1` یعنی «نامحدود». */ + it('shows the resource quota on every plan card', async () => { + renderWithProviders(, { route: '/admin/subscription' }); + + await screen.findByText('پلن پایه'); + expect(within(screen.getByTestId('plan-card-free')).getByText('۱ منبع')).toBeInTheDocument(); + expect(within(screen.getByTestId('plan-card-basic')).getByText('۳ منبع')).toBeInTheDocument(); + expect(within(screen.getByTestId('plan-card-professional')).getByText('نامحدود منبع')).toBeInTheDocument(); + }); + it('shows the current-plan card without an expiry warning when the plan is healthy', async () => { renderWithProviders(, { route: '/admin/subscription' }); expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument(); diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index 027a6bef..de2915c7 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -1,13 +1,13 @@ import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { CreditCardIcon } from '@heroicons/react/24/outline'; +import { CreditCardIcon, CubeIcon } from '@heroicons/react/24/outline'; import { CheckCircleIcon } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types'; import { usePaymentConfig } from '../hooks/usePaymentConfig'; -import { formatRial, formatNumber, formatDate } from '../lib/utils'; +import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils'; import Modal from '../components/ui/Modal'; import SettingsLayout from '../components/layout/SettingsLayout'; import { @@ -413,8 +413,8 @@ function PlanCard({ {meta.desc}
- {/* Secretaries pill */} -
+ {/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */} +
+
+ + {formatResourceLimit(plan.max_resources)} منبع + + +
{/* Period toggle */} diff --git a/docs/api/subscription.md b/docs/api/subscription.md index c34560f3..fb4a26f4 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -191,6 +191,10 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu `max_resources` اختیاری است و پیش‌فرضش `1` — پلنِ ناشناخته نباید بی‌صدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته می‌شود. +مقدارهای پذیرفته‌شده: `-1` یا هر عدد مثبت. `0` و هر منفیِ دیگر → `422 ERR_VALIDATION_001` با پیام «max_resources باید عددی مثبت باشد یا -1 برای نامحدود». + +پنل ادمین (`/admin/admin-subscription`) به‌جای گرفتنِ `-1` از کاربر، یک سوییچ «منابع نامحدود» دارد و خودش همان `-1` را می‌فرستد. تست: `tests/Subscription/AdminPlanResourceLimitTest.php`. + **خطاها:** | کد | HTTP | شرح | diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 96fac106..597a8115 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -130,6 +130,31 @@ class SubscriptionController extends BaseController ); } + /** + * سقف منابع فقط `-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 @@ -145,6 +170,10 @@ class SubscriptionController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422); } + if (($error = $this->rejectInvalidResourceLimit($data)) !== null) { + return $error; + } + $plan = new SubscriptionPlan( $name, (int) $data['level'], @@ -169,6 +198,10 @@ class SubscriptionController extends BaseController $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()) { diff --git a/tests/Subscription/AdminPlanResourceLimitTest.php b/tests/Subscription/AdminPlanResourceLimitTest.php new file mode 100644 index 00000000..e77437b0 --- /dev/null +++ b/tests/Subscription/AdminPlanResourceLimitTest.php @@ -0,0 +1,101 @@ +createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $body = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [ + 'name' => $this->planName(), + 'level' => 7, + 'max_secretaries' => 2, + 'max_resources' => 5, + 'features' => ['services' => true], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(5, $body['data']['max_resources']); + } + + /** نیامدنِ فیلد یعنی محافظه‌کارانه‌ترین سقف، نه نامحدود. */ + public function testOmittedQuotaDefaultsToOne(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $body = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [ + 'name' => $this->planName(), + 'level' => 7, + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame(1, $body['data']['max_resources']); + } + + public function testQuotaIsUpdatedAndUnlimitedIsAccepted(): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $created = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [ + 'name' => $this->planName(), + 'level' => 7, + 'max_resources' => 3, + ]); + + $body = $this->authJson('PATCH', '/api/v1/admin/subscription/plan/' . $created['data']['uuid'], $admin, [ + 'max_resources' => SubscriptionPlan::UNLIMITED, + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(SubscriptionPlan::UNLIMITED, $body['data']['max_resources']); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('invalidQuotas')] + public function testInvalidQuotaIsRejected(int $quota): void + { + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [ + 'name' => $this->planName(), + 'level' => 7, + 'max_resources' => $quota, + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** @return array */ + public static function invalidQuotas(): array + { + return [ + 'zero' => [0], + 'below unlimited' => [-2], + ]; + } + + /** فهرست پلن‌ها سقف را می‌دهد؛ وگرنه کارت‌های صفحهٔ اشتراک چیزی برای نشان‌دادن ندارند. */ + public function testPlanListExposesTheQuota(): void + { + $user = $this->createUser(['ROLE_USER']); + $body = $this->authJson('GET', '/api/v1/subscription/plans', $user); + + self::assertSame(200, $this->responseCode()); + self::assertArrayHasKey('max_resources', $body['data'][0]); + } +}