From a6a965a2aa4e678c51246e78d730a9392431d86d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 9 Aug 2026 13:43:30 +0330 Subject: [PATCH] feat: add admin subscription granting feature - Implemented the ability for admins to grant subscriptions to doctors and clinics without payment. - Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions. - Updated the subscription model to track the admin who granted the subscription. - Enhanced the subscription report to include details about granted subscriptions. - Introduced a new `is_granted` field to indicate if a subscription was granted by an admin. - Updated the database schema to support the new functionality with a migration. - Added tests to ensure the correct behavior of the subscription granting process. --- .../components/ui/SearchableSelect.test.tsx | 43 +++- .../admin/components/ui/SearchableSelect.tsx | 15 ++ .../pages/AdminSubscriptionPage.test.tsx | 157 ++++++++++++ assets/admin/pages/AdminSubscriptionPage.tsx | 237 +++++++++++++++++- docs/api/subscription.md | 142 ++++++++++- migrations/Version20260809092336.php | 33 +++ .../Controller/SubscriptionController.php | 115 ++++++++- .../Entity/ClinicSubscription.php | 18 +- .../Service/SubscriptionService.php | 34 +++ tests/Subscription/GrantSubscriptionTest.php | 109 ++++++++ 10 files changed, 874 insertions(+), 29 deletions(-) create mode 100644 migrations/Version20260809092336.php create mode 100644 tests/Subscription/GrantSubscriptionTest.php diff --git a/assets/admin/components/ui/SearchableSelect.test.tsx b/assets/admin/components/ui/SearchableSelect.test.tsx index 58338e7a..c7dc2b84 100644 --- a/assets/admin/components/ui/SearchableSelect.test.tsx +++ b/assets/admin/components/ui/SearchableSelect.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import SearchableSelect from './SearchableSelect'; vi.mock('../../stores/uiStore', () => ({ useUiStore: () => false })); @@ -53,3 +53,44 @@ describe('SearchableSelect — نام دسترس‌پذیر', () => { expect(screen.getByRole('combobox', { name: 'شهر' })).toBeInTheDocument(); }); }); + +describe('SearchableSelect — جستجوی سمت سرور', () => { + it('با تایپ، onInputChange صدا زده می‌شود', () => { + const onInputChange = vi.fn(); + render( {}} inputId="srv" onInputChange={onInputChange} />); + + fireEvent.change(document.getElementById('srv') as HTMLInputElement, { target: { value: 'رضا' } }); + + expect(onInputChange).toHaveBeenCalledWith('رضا'); + }); + + /** نتیجهٔ سرور نباید دوباره روی متنِ تایپ‌شده فیلتر شود. */ + it('در حالت سرور، گزینه‌ای که با متن تایپ‌شده نمی‌خواند هم می‌ماند', () => { + render( + {}} + inputId="srv2" + onInputChange={() => {}} + />, + ); + + const input = document.getElementById('srv2') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.change(input, { target: { value: '0912' } }); + + expect(screen.getByText('دکتر رضایی')).toBeInTheDocument(); + }); + + it('بدون onInputChange، فیلتر داخلی سر جایش می‌ماند', () => { + render( {}} inputId="srv3" />); + + const input = document.getElementById('srv3') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.change(input, { target: { value: '0912' } }); + + expect(screen.queryByText('دکتر رضایی')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/ui/SearchableSelect.tsx b/assets/admin/components/ui/SearchableSelect.tsx index 6d4cb1cc..8251b85b 100644 --- a/assets/admin/components/ui/SearchableSelect.tsx +++ b/assets/admin/components/ui/SearchableSelect.tsx @@ -26,6 +26,14 @@ interface Props { ariaLabel?: string; /** اگر label قابل‌مشاهده‌ای وجود دارد، id آن را بده (بر ariaLabel اولویت دارد). */ ariaLabelledBy?: string; + /** + * جستجوی سمت سرور: با هر تایپ صدا زده می‌شود تا مصرف‌کننده `options` تازه بدهد. + * + * وقتی داده می‌شود، فیلترِ داخلی react-select خاموش می‌شود؛ وگرنه نتیجهٔ سرور + * دوباره روی متنِ تایپ‌شده فیلتر می‌شد و گزینه‌هایی که سرور با معیارِ دیگری + * (مثلاً شمارهٔ موبایل) پیدا کرده بود ناپدید می‌شدند. + */ + onInputChange?: (input: string) => void; } export default function SearchableSelect({ @@ -41,6 +49,7 @@ export default function SearchableSelect({ height = 42, ariaLabel, ariaLabelledBy, + onInputChange, }: Props) { const darkMode = useUiStore((s) => s.darkMode); @@ -121,6 +130,12 @@ export default function SearchableSelect({ isLoading={isLoading} isDisabled={isDisabled} isClearable={isClearable} + onInputChange={onInputChange ? (input, meta) => { + // react-select ورودی را هنگام بستن منو و blur هم «تغییر» می‌داند؛ آن دو را + // رد نکنیم، هر بار بستنِ منو لیست را به حالت خالی برمی‌گرداند. + if (meta.action === 'input-change') { onInputChange(input); } + } : undefined} + filterOption={onInputChange ? null : undefined} styles={styles} isRtl menuPortalTarget={typeof document !== 'undefined' ? document.body : undefined} diff --git a/assets/admin/pages/AdminSubscriptionPage.test.tsx b/assets/admin/pages/AdminSubscriptionPage.test.tsx index 03d10aa7..07425219 100644 --- a/assets/admin/pages/AdminSubscriptionPage.test.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.test.tsx @@ -13,6 +13,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage'; const get = api.get as ReturnType; const patch = api.patch as ReturnType; +const post = api.post as ReturnType; const PLANS = [ { @@ -25,6 +26,23 @@ const PLANS = [ }, ]; +/** همان پلن‌ها، با دوره — تب «اعطای اشتراک» فهرست دوره‌ها را از همین‌جا می‌سازد. */ +const PLANS_WITH_PERIODS = [ + { + ...PLANS[0], + periods: [ + { uuid: 'per-basic-1', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false }, + { uuid: 'per-basic-trial', label: 'آزمایشی', duration_months: 1, price_rials: 0, is_trial: true }, + ], + }, + { + ...PLANS[1], + periods: [ + { uuid: 'per-pro-1', label: 'یک ماهه', duration_months: 1, price_rials: 3000000, is_trial: false }, + ], + }, +]; + function mockApi() { get.mockImplementation((url: string) => { if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS }); @@ -85,3 +103,142 @@ describe('AdminSubscriptionPage — سقف منابع پلن', () => { expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled(); }); }); + +// ── اعطای اشتراک ────────────────────────────────────────────────────────── + +const DOCTORS = [{ uuid: 'doc-1', name: 'دکتر رضایی', mobile: '09120000001' }]; + +/** پاسخ `admin/subscription/active` — `null` یعنی مقصد اشتراک فعالی ندارد. */ +function mockGrantApi(activeSubscription: unknown = null) { + get.mockImplementation((url: string) => { + if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS_WITH_PERIODS }); + if (url.includes('/admin/subscription/active')) return Promise.resolve({ success: true, data: { subscription: activeSubscription } }); + if (url.includes('/admin/doctors')) return Promise.resolve({ success: true, data: DOCTORS, meta: { totalRecords: 1 } }); + if (url.includes('/admin/clinics')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } }); + return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } }); + }); + post.mockResolvedValue({ success: true, data: {} }); +} + +/** انتخاب گزینه از SearchableSelect با inputId */ +async function pickOption(inputId: string, option: string) { + const input = document.getElementById(inputId) as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText(option)); +} + +async function openGrantTab() { + renderWithProviders(, { route: '/admin/admin-subscription' }); + // تبِ «اعطای اشتراک» و دکمهٔ ثبتِ فرم هم‌نام‌اند؛ تب همیشه اولی است. + fireEvent.click((await screen.findAllByText('اعطای اشتراک'))[0]); +} + +/** دکمهٔ ثبتِ فرم اعطا — با نقش تنها قابل تفکیک نیست، چون تب هم‌نام است. */ +function submitGrantForm() { + fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement); +} + +describe('AdminSubscriptionPage — اعطای اشتراک', () => { + beforeEach(() => { get.mockReset(); post.mockReset(); }); + + it('برای مقصد بدون اشتراک، دوره را می‌فرستد و پرداختی در کار نیست', async () => { + mockGrantApi(null); + await openGrantTab(); + + await pickOption('grant-entity', 'دکتر رضایی — 09120000001'); + expect(await screen.findByText('این مقصد اشتراک فعالی ندارد.')).toBeInTheDocument(); + + await pickOption('grant-period', 'حرفه‌ای — یک ماهه (۳۰۰٬۰۰۰ تومان)'); + submitGrantForm(); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][0]).toBe('/api/v1/admin/subscription/grant'); + expect(post.mock.calls[0][1]).toEqual({ entity_type: 'doctor', entity_uuid: 'doc-1', period_uuid: 'per-pro-1' }); + }); + + /** دورهٔ تریال، تریالِ نگرفتهٔ کاربر را می‌سوزاند؛ نباید در فهرست باشد. */ + it('دوره‌های تریال در فهرست اعطا نمی‌آیند', async () => { + mockGrantApi(null); + await openGrantTab(); + + const input = document.getElementById('grant-period') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + + expect(await screen.findByText('پایه — یک ماهه (۱۰۰٬۰۰۰ تومان)')).toBeInTheDocument(); + expect(screen.queryByText(/آزمایشی/)).not.toBeInTheDocument(); + }); + + it('اشتراک فعالِ مقصد را قبل از اعطا نشان می‌دهد', async () => { + mockGrantApi({ plan: { name: 'professional', level: 2 }, expires_at: 1800000000, is_trial: false, is_granted: true }); + await openGrantTab(); + + await pickOption('grant-entity', 'دکتر رضایی — 09120000001'); + + expect(await screen.findByText('حرفه‌ای')).toBeInTheDocument(); + expect(screen.getByText('اعطایی')).toBeInTheDocument(); + }); + + it('پلن پایین‌تر از پلن فعال، اول تأیید می‌خواهد', async () => { + mockGrantApi({ plan: { name: 'professional', level: 2 }, expires_at: 1800000000, is_trial: false, is_granted: false }); + await openGrantTab(); + + await pickOption('grant-entity', 'دکتر رضایی — 09120000001'); + await screen.findByText(/اشتراک فعلی/); + await pickOption('grant-period', 'پایه — یک ماهه (۱۰۰٬۰۰۰ تومان)'); + submitGrantForm(); + + expect(await screen.findByText('کاهش سطح پلن')).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('اعطا کن')); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][1]).toMatchObject({ period_uuid: 'per-basic-1' }); + }); + + it('ارتقا به پلن بالاتر بدون تأیید اضافه ثبت می‌شود', async () => { + mockGrantApi({ plan: { name: 'basic', level: 1 }, expires_at: 1800000000, is_trial: false, is_granted: false }); + await openGrantTab(); + + await pickOption('grant-entity', 'دکتر رضایی — 09120000001'); + await screen.findByText(/اشتراک فعلی/); + await pickOption('grant-period', 'حرفه‌ای — یک ماهه (۳۰۰٬۰۰۰ تومان)'); + submitGrantForm(); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(screen.queryByText('کاهش سطح پلن')).not.toBeInTheDocument(); + }); +}); + +describe('AdminSubscriptionPage — گزارش', () => { + beforeEach(() => { get.mockReset(); post.mockReset(); }); + + it('اشتراک اعطایی را «اعطایی» نشان می‌دهد، نه «پولی»', async () => { + get.mockImplementation((url: string) => { + if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS }); + if (url.includes('/admin/subscription/report')) { + return Promise.resolve({ + success: true, + meta: { totalRecords: 1 }, + data: [{ + uuid: 's-1', entityType: 'doctor', entityId: 4, entityName: 'دکتر رضایی', + isTrial: false, isGranted: true, grantedBy: 'ادمین', + startsAt: 1700000000, expiresAt: 1800000000, createdAt: 1700000000, + plan_name: 'professional', plan_level: 2, + }], + }); + } + return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } }); + }); + + renderWithProviders(, { route: '/admin/admin-subscription' }); + fireEvent.click(await screen.findByText('گزارش فروش')); + + expect(await screen.findByText('اعطایی')).toBeInTheDocument(); + expect(screen.getByText('دکتر رضایی')).toBeInTheDocument(); + expect(screen.getByText('ادمین')).toBeInTheDocument(); + expect(screen.queryByText('پولی')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/AdminSubscriptionPage.tsx b/assets/admin/pages/AdminSubscriptionPage.tsx index 210bd621..c4b00b6c 100644 --- a/assets/admin/pages/AdminSubscriptionPage.tsx +++ b/assets/admin/pages/AdminSubscriptionPage.tsx @@ -13,8 +13,10 @@ import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; import PriceInput from '../components/ui/PriceInput'; +import SearchableSelect from '../components/ui/SearchableSelect'; import Pagination from '../components/ui/Pagination'; import { numericField } from '../lib/forms'; +import { useUrlState } from '../hooks/useUrlState'; // ── Types ───────────────────────────────────────────────────────────────── @@ -22,7 +24,10 @@ interface ReportRow { uuid: string; entityType: string; entityId: number; + entityName: string | null; isTrial: boolean; + isGranted: boolean; + grantedBy: string | null; startsAt: number; expiresAt: number | null; createdAt: number; @@ -416,6 +421,209 @@ function PlansTab() { ); } +// ── Grant tab ───────────────────────────────────────────────────────────── + +type EntityType = 'doctor' | 'clinic'; + +interface EntityRow { uuid: string; name: string; mobile?: string; owner_mobile?: string } + +interface ActiveSubscriptionData { + subscription: { + plan: { name: string; level: number }; + period?: { label: string }; + expires_at: number | null; + is_trial: boolean; + is_granted: boolean; + } | null; +} + +function GrantTab() { + const qc = useQueryClient(); + const [entityType, setEntityType] = useState('doctor'); + const [entityUuid, setEntityUuid] = useState(null); + const [periodUuid, setPeriodUuid] = useState(null); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [downgrade, setDowngrade] = useState<{ from: string; to: string } | null>(null); + + // جستجوی سمت سرور، چون فهرست پزشکان از سقف یک صفحهٔ endpoint بیشتر است. + React.useEffect(() => { + const t = setTimeout(() => setSearch(searchInput), 350); + return () => clearTimeout(t); + }, [searchInput]); + + const { data: entityData, isFetching: entitiesLoading } = useQuery({ + queryKey: ['admin-grant-entities', entityType, search], + queryFn: () => api.get>( + `/api/v1/admin/${entityType === 'doctor' ? 'doctors' : 'clinics'}?limit=25&search=${encodeURIComponent(search)}`, + ), + }); + + const entityOptions = (entityData?.data ?? []).map((e) => ({ + value: e.uuid, + label: e.mobile || e.owner_mobile ? `${e.name} — ${e.mobile ?? e.owner_mobile}` : e.name, + })); + + const { data: plansData } = useQuery({ + queryKey: ['admin-subscription-plans'], + queryFn: () => api.get>('/api/v1/admin/subscription/plans'), + }); + + const plans: SubscriptionPlan[] = (plansData as any)?.data ?? []; + + // فقط دوره‌های پولی: اعطای دورهٔ تریال، تریالِ نگرفتهٔ کاربر را می‌سوزاند. + const periodOptions = plans.flatMap((plan) => { + const periods: SubscriptionPeriod[] = Array.isArray(plan.periods) ? plan.periods : Object.values(plan.periods ?? {}); + return periods + .filter((p) => !p.is_trial) + .map((p) => ({ + value: p.uuid, + label: `${PLAN_DISPLAY[plan.name] ?? plan.name} — ${p.label} (${formatRial(p.price_rials)})`, + planName: plan.name, + planLevel: plan.level, + })); + }); + + const selectedPeriod = periodOptions.find((p) => p.value === periodUuid) ?? null; + + const { data: activeData, isFetching: activeLoading } = useQuery({ + queryKey: ['admin-grant-active', entityType, entityUuid], + queryFn: () => api.get<{ data: ActiveSubscriptionData }>(`/api/v1/admin/subscription/active/${entityType}/${entityUuid}`), + enabled: entityUuid !== null, + }); + + const activeSub = (activeData as any)?.data?.subscription ?? null; + + const grantMut = useMutation({ + mutationFn: (body: { entity_type: EntityType; entity_uuid: string; period_uuid: string }) => + api.post('/api/v1/admin/subscription/grant', body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin-subscription-report'] }); + qc.invalidateQueries({ queryKey: ['admin-grant-active'] }); + setDowngrade(null); + setPeriodUuid(null); + toast.success('اشتراک اعطا شد'); + }, + onError: (e: any) => { setDowngrade(null); toast.error(e.message); }, + }); + + const submitGrant = () => { + if (!entityUuid || !periodUuid) { return; } + grantMut.mutate({ entity_type: entityType, entity_uuid: entityUuid, period_uuid: periodUuid }); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!entityUuid || !periodUuid || selectedPeriod === null) { return; } + + // `findActive` آخرین رکورد را برمی‌دارد، نه بالاترین پلن را — پس اعطای پلن + // پایین‌تر واقعاً downgrade می‌کند و باید صریح تأیید شود. + if (activeSub !== null && selectedPeriod.planLevel < activeSub.plan.level) { + setDowngrade({ + from: PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name, + to: PLAN_DISPLAY[selectedPeriod.planName] ?? selectedPeriod.planName, + }); + return; + } + + submitGrant(); + }; + + const changeEntityType = (type: EntityType) => { + setEntityType(type); + setEntityUuid(null); + setSearchInput(''); + setSearch(''); + }; + + return ( + <> +
+
+
+
+ +
+ + +
+
+ +
+ + setEntityUuid(v === null ? null : String(v))} + onInputChange={setSearchInput} + isLoading={entitiesLoading} + isClearable + placeholder="نام یا شماره موبایل را بنویسید..." + ariaLabelledBy="grant-entity-label" + /> + برای یافتن مقصد، بخشی از نام یا شمارهٔ موبایل را تایپ کنید. +
+ + {entityUuid !== null && ( +
+ {activeLoading ? ( + در حال بررسی اشتراک فعلی... + ) : activeSub === null ? ( + این مقصد اشتراک فعالی ندارد. + ) : ( + + اشتراک فعلی: + {PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name} + {activeSub.is_trial && تریال} + {activeSub.is_granted && اعطایی} + + انقضا: {activeSub.expires_at ? formatDate(activeSub.expires_at) : 'بی‌نهایت'} + + + )} +
+ )} + +
+ + setPeriodUuid(v === null ? null : String(v))} + isClearable + placeholder="انتخاب کنید..." + ariaLabel="پلن و دوره اشتراک" + /> + + فقط دوره‌های پولی نمایش داده می‌شوند. اگر مقصد اشتراک فعال دارد، مدت روی انقضای فعلی افزوده می‌شود، نه از امروز. + +
+
+ +
+ +
+
+
+ + setDowngrade(null)} + /> + + ); +} + // ── Report tab ──────────────────────────────────────────────────────────── function ReportTab() { @@ -441,7 +649,7 @@ function ReportTab() {
- + @@ -453,8 +661,11 @@ function ReportTab() { {rows.map((row, i) => (
نوعمقصد پلن نوع اشتراک شروع
- - {row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId} + + + {row.entityType === 'clinic' ? 'کلینیک' : 'پزشک'} + + {row.entityName ?? `#${row.entityId}`} @@ -462,7 +673,16 @@ function ReportTab() { سطح {row.plan_level} - {row.isTrial ? تریال : پولی} + {row.isTrial ? ( + تریال + ) : row.isGranted ? ( + + اعطایی + {row.grantedBy && {row.grantedBy}} + + ) : ( + پولی + )} {formatDate(row.startsAt)} @@ -485,18 +705,23 @@ function ReportTab() { // ── Main ────────────────────────────────────────────────────────────────── export default function AdminSubscriptionPage() { - const [tab, setTab] = useState<'plans' | 'report'>('plans'); + // تب در URL می‌نشیند، نه در state: بازگشت از صفحهٔ دیگر باید همان تب را برگرداند. + const [urlState, setUrlState] = useUrlState({ tab: 'plans' }); + const tab = urlState.tab; + const setTab = (next: string) => setUrlState({ tab: next }); return ( <> - +
+
{tab === 'plans' && } + {tab === 'grant' && } {tab === 'report' && } ); diff --git a/docs/api/subscription.md b/docs/api/subscription.md index fb4a26f4..b395e030 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -72,6 +72,7 @@ "plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} }, "period": { "label": "یک ماهه", "duration_months": 1, "price_rials": 290000 }, "is_trial": false, + "is_granted": false, "starts_at": 1718000000, "expires_at": 1720678400, "days_remaining": 30, @@ -83,6 +84,8 @@ } ``` +`is_granted` یعنی این اشتراک را ادمین بدون پرداخت اعطا کرده است. + اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است. ### پاسخ کاهش‌یافته برای کاربرِ بدون مجوزِ `subscription.view` (2026-08) @@ -232,27 +235,150 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu ### DELETE /api/v1/admin/subscription/period/{uuid} **Permission:** `ROLE_ADMIN` — غیرفعال کردن دوره (soft delete: `active=false`) +### POST /api/v1/admin/subscription/grant +**Permission:** `ROLE_ADMIN` — اعطای اشتراک به یک پزشک یا کلینیک، بدون پرداخت + +مقصد با `uuid` مشخص می‌شود، نه `id`؛ `id` داخلی است و در پاسخ‌های ادمین نمی‌آید. + +اشتراکِ ساخته‌شده هرگز `is_trial` نمی‌گیرد، پس تریالِ استفاده‌نشدهٔ مقصد نمی‌سوزد. +اگر مقصد اشتراک فعال داشته باشد، مدتِ دوره روی انقضای فعلی افزوده می‌شود، نه از امروز. + +**Request** + +| فیلد | نوع | الزامی | توضیح | +|------|-----|--------|-------| +| entity_type | string | بله | `doctor` یا `clinic` | +| entity_uuid | string | بله | uuid پزشک یا کلینیک | +| period_uuid | string | بله | uuid دورهٔ اشتراک؛ پلن از خود دوره خوانده می‌شود | + +```json +{ + "entity_type": "doctor", + "entity_uuid": "44279545-9eab-4fc5-8b81-d04485ca38a7", + "period_uuid": "72dfbf23-b4fc-4bb0-a6f7-abcb6441754b" +} +``` + +**Response 201** + +```json +{ + "success": true, + "data": { + "uuid": "2687342c-85fa-4610-aed9-bba42004f920", + "plan": { + "uuid": "6c2573e1-98e4-47e5-ba24-b0af92e55ffd", + "name": "professional", + "level": 2, + "max_secretaries": 5, + "max_resources": -1, + "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true }, + "active": true + }, + "period": { + "uuid": "72dfbf23-b4fc-4bb0-a6f7-abcb6441754b", + "plan_uuid": "6c2573e1-98e4-47e5-ba24-b0af92e55ffd", + "label": "یک ماهه", + "duration_months": 1, + "price_rials": 20000000, + "is_trial": false, + "active": true, + "sort_order": 1 + }, + "is_trial": false, + "is_granted": true, + "starts_at": 1786268482, + "expires_at": 1788860482, + "days_remaining": 30, + "is_active": true, + "created_at": 1786268482 + } +} +``` + +**خطاها** + +| وضعیت | کد | حالت | +|-------|----|------| +| 422 | ERR_VALIDATION_001 | `entity_type` غیر از `doctor`/`clinic`، یا نبودِ `entity_uuid`/`period_uuid` | +| 404 | ERR_NOT_FOUND_001 | مقصد یافت نشد («مقصد اشتراک یافت نشد») | +| 404 | ERR_NOT_FOUND_001 | دوره یافت نشد | +| 401 | ERR_AUTH_001 | بدون توکن | +| 403 | — | توکن معتبر ولی بدون `ROLE_ADMIN` | + +> **هشدار downgrade:** `findActive` آخرین رکورد را بر اساس `id` برمی‌دارد، نه بالاترین +> پلن. پس اعطای پلنی پایین‌تر از پلن فعال، عملاً پلن مؤثر مقصد را کاهش می‌دهد. پنل +> ادمین قبل از ثبت این حالت تأیید می‌گیرد؛ خودِ endpoint جلوی آن را نمی‌گیرد. + +### GET /api/v1/admin/subscription/active/{entityType}/{entityUuid} +**Permission:** `ROLE_ADMIN` — اشتراک فعالِ یک مقصد، برای نمایش پیش از اعطا + +`entityType` یکی از `doctor` یا `clinic`. + +```json +{ + "success": true, + "data": { + "subscription": { + "uuid": "2687342c-85fa-4610-aed9-bba42004f920", + "is_trial": false, + "is_granted": true, + "expires_at": 1788860482, + "days_remaining": 30 + } + } +} +``` + +نبودِ اشتراک فعال با `"subscription": null` برمی‌گردد، نه ۴۰۴. + +| وضعیت | کد | حالت | +|-------|----|------| +| 422 | ERR_VALIDATION_001 | `entityType` غیر از `doctor`/`clinic` | +| 404 | ERR_NOT_FOUND_001 | مقصد یافت نشد | + ### GET /api/v1/admin/subscription/report **Permission:** `ROLE_ADMIN` -Query params: `page`, `limit` +Query params: `page`, `limit` — مقدار `limit` بین ۱۰ تا ۱۰۰ کلیپ می‌شود. + +`isGranted` یعنی این اشتراک را ادمین بدون پرداخت داده و `grantedBy` نام یا شمارهٔ همان ادمین است. +`payment` تنها معیارِ تشخیص نیست: اشتراک تریال هم پرداختی ندارد. ```json { "success": true, "data": [ { - "uuid": "...", - "entity_type": "clinic", - "entity_id": 5, - "is_trial": false, - "starts_at": 1718000000, - "expires_at": 1720678400, + "uuid": "2687342c-85fa-4610-aed9-bba42004f920", + "entityType": "doctor", + "entityId": 19545, + "entityName": "پزشک دعوت شده2", + "isTrial": false, + "isGranted": true, + "grantedBy": "ادمین", + "startsAt": 1786268482, + "expiresAt": 1788860482, + "createdAt": 1786268482, + "plan_name": "professional", + "plan_level": 2 + }, + { + "uuid": "ba1b8b92-f3d0-4cc6-8217-2dfc0ffe0d28", + "entityType": "clinic", + "entityId": 1, + "entityName": "09398631203", + "isTrial": true, + "isGranted": false, + "grantedBy": null, + "startsAt": 1783093441, + "expiresAt": 1785685441, + "createdAt": 1783093441, "plan_name": "basic", "plan_level": 1 } ], - "meta": { "totalRecords": 50, "totalPages": 3, "currentPage": 1 } + "meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 } } ``` diff --git a/migrations/Version20260809092336.php b/migrations/Version20260809092336.php new file mode 100644 index 00000000..581acec5 --- /dev/null +++ b/migrations/Version20260809092336.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE clinic_subscriptions ADD granted_by_user_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE clinic_subscriptions ADD CONSTRAINT FK_E4D1CC0FF6097589 FOREIGN KEY (granted_by_user_id) REFERENCES users (id) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_E4D1CC0FF6097589 ON clinic_subscriptions (granted_by_user_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE clinic_subscriptions DROP FOREIGN KEY FK_E4D1CC0FF6097589'); + $this->addSql('DROP INDEX IDX_E4D1CC0FF6097589 ON clinic_subscriptions'); + $this->addSql('ALTER TABLE clinic_subscriptions DROP granted_by_user_id'); + } +} diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 597a8115..c6a3a893 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -287,6 +287,71 @@ class SubscriptionController extends BaseController return $this->success(['message' => 'دوره غیرفعال شد']); } + /** + * اعطای اشتراک به یک پزشک یا کلینیک، بدون پرداخت. + * + * مقصد با uuid گرفته می‌شود نه با id: id داخلی است و در هیچ پاسخِ ادمینی + * نمی‌آید، پس پنل چیزی برای فرستادن نداشت. + */ + #[Route('/api/v1/admin/subscription/grant', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function adminGrant(Request $request, #[CurrentUser] User $admin): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + + $entityType = (string) ($data['entity_type'] ?? ''); + $entityUuid = (string) ($data['entity_uuid'] ?? ''); + $periodUuid = (string) ($data['period_uuid'] ?? ''); + + if (!in_array($entityType, ['doctor', 'clinic'], true) || $entityUuid === '' || $periodUuid === '') { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'entity_type (doctor یا clinic) و entity_uuid و period_uuid الزامی هستند', + 422, + ); + } + + $entityId = $entityType === 'doctor' + ? $this->doctorRepo->findByUuid($entityUuid)?->getId() + : $this->clinicRepo->findByUuid($entityUuid)?->getId(); + + if ($entityId === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404); + } + + try { + $subscription = $this->subscriptionService->grant($entityType, $entityId, $periodUuid, $admin); + } catch (AppException $e) { + return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus()); + } + + return $this->success($subscription->toArray(), 201); + } + + /** + * اشتراک فعالِ یک مقصد — پیش از اعطا، تا ادمین downgrade را ناخواسته انجام ندهد. + */ + #[Route('/api/v1/admin/subscription/active/{entityType}/{entityUuid}', methods: ['GET'])] + #[IsGranted('ROLE_ADMIN')] + public function adminActiveSubscription(string $entityType, string $entityUuid): JsonResponse + { + if (!in_array($entityType, ['doctor', 'clinic'], true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'entity_type باید doctor یا clinic باشد', 422); + } + + $entityId = $entityType === 'doctor' + ? $this->doctorRepo->findByUuid($entityUuid)?->getId() + : $this->clinicRepo->findByUuid($entityUuid)?->getId(); + + if ($entityId === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404); + } + + return $this->success([ + 'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(), + ]); + } + #[Route('/api/v1/admin/subscription/report', methods: ['GET'])] #[IsGranted('ROLE_ADMIN')] public function adminReport(Request $request): JsonResponse @@ -294,21 +359,45 @@ class SubscriptionController extends BaseController $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(10, (int) $request->query->get('limit', 20))); - $total = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Subscription\Entity\ClinicSubscription s') - ->getSingleScalarResult(); + $conn = $this->em->getConnection(); + $total = (int) $conn->fetchOne('SELECT COUNT(*) FROM clinic_subscriptions'); - $subscriptions = $this->em->createQuery(' - SELECT s.uuid, s.entityType, s.entityId, s.isTrial, s.startsAt, s.expiresAt, s.createdAt, - p.name AS plan_name, p.level AS plan_level - FROM App\Subscription\Entity\ClinicSubscription s - JOIN s.plan p - ORDER BY s.id DESC - ') - ->setFirstResult(($page - 1) * $limit) - ->setMaxResults($limit) - ->getArrayResult(); + $offset = ($page - 1) * $limit; - return $this->paginated($subscriptions, $total, $page, $limit); + // نامِ مقصد با JOIN خام گرفته می‌شود، نه DQL: جفت (entity_type, entity_id) + // پلی‌مورفیک است و به هیچ association دکترینی وصل نیست. + $rows = $conn->fetchAllAssociative( + "SELECT s.uuid, s.entity_type, s.entity_id, s.is_trial, s.starts_at, s.expires_at, s.created_at, + s.granted_by_user_id, p.name AS plan_name, p.level AS plan_level, + d.name AS doctor_name, c.name AS clinic_name, + g.real_name AS granted_by_name, g.mobile_number AS granted_by_mobile + FROM clinic_subscriptions s + JOIN subscription_plans p ON p.id = s.plan_id + LEFT JOIN doctors d ON s.entity_type = 'doctor' AND d.id = s.entity_id + LEFT JOIN clinics c ON s.entity_type = 'clinic' AND c.id = s.entity_id + LEFT JOIN users g ON g.id = s.granted_by_user_id + ORDER BY s.id DESC + LIMIT $limit OFFSET $offset" + ); + + $items = array_map(fn(array $r) => [ + 'uuid' => $r['uuid'], + 'entityType' => $r['entity_type'], + 'entityId' => (int) $r['entity_id'], + 'entityName' => $r['entity_type'] === 'doctor' ? $r['doctor_name'] : $r['clinic_name'], + 'isTrial' => (bool) $r['is_trial'], + 'isGranted' => $r['granted_by_user_id'] !== null, + 'grantedBy' => $r['granted_by_user_id'] === null + ? null + : ($r['granted_by_name'] ?: $r['granted_by_mobile']), + 'startsAt' => (int) $r['starts_at'], + 'expiresAt' => $r['expires_at'] === null ? null : (int) $r['expires_at'], + 'createdAt' => (int) $r['created_at'], + 'plan_name' => $r['plan_name'], + 'plan_level' => (int) $r['plan_level'], + ], $rows); + + return $this->paginated($items, $total, $page, $limit); } // ── Helpers ───────────────────────────────────────────────────────────── diff --git a/src/Subscription/Entity/ClinicSubscription.php b/src/Subscription/Entity/ClinicSubscription.php index 8a5d83c7..4143cdae 100644 --- a/src/Subscription/Entity/ClinicSubscription.php +++ b/src/Subscription/Entity/ClinicSubscription.php @@ -2,6 +2,7 @@ namespace App\Subscription\Entity; +use App\Auth\Entity\User; use App\Payment\Entity\Payment; use App\Subscription\Repository\ClinicSubscriptionRepository; use Doctrine\ORM\Mapping as ORM; @@ -38,6 +39,16 @@ class ClinicSubscription #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?Payment $payment = null; + /** + * ادمینی که این اشتراک را بدون پرداخت اعطا کرده. + * + * تنها جای سیستم است که ارزش مالی بدون تراکنش جابه‌جا می‌شود، پس مسئولش باید + * بماند. `payment === null` به‌تنهایی کافی نیست: اشتراک تریال هم پرداخت ندارد. + */ + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'granted_by_user_id', nullable: true, onDelete: 'SET NULL')] + private ?User $grantedBy = null; + #[ORM\Column(name: 'is_trial', type: 'boolean')] private bool $isTrial = false; @@ -57,7 +68,8 @@ class ClinicSubscription SubscriptionPeriod $period, bool $isTrial = false, ?int $expiresAt = null, - ?Payment $payment = null + ?Payment $payment = null, + ?User $grantedBy = null ) { $this->uuid = Uuid::v4()->toRfc4122(); $this->entityType = $entityType; @@ -68,6 +80,7 @@ class ClinicSubscription $this->startsAt = time(); $this->expiresAt = $expiresAt; $this->payment = $payment; + $this->grantedBy = $grantedBy; $this->createdAt = time(); } @@ -78,6 +91,8 @@ class ClinicSubscription public function getPlan(): SubscriptionPlan { return $this->plan; } public function getPeriod(): SubscriptionPeriod { return $this->period; } public function getPayment(): ?Payment { return $this->payment; } + public function getGrantedBy(): ?User { return $this->grantedBy; } + public function isGranted(): bool { return $this->grantedBy !== null; } public function isTrial(): bool { return $this->isTrial; } public function getStartsAt(): int { return $this->startsAt; } public function getExpiresAt(): ?int { return $this->expiresAt; } @@ -104,6 +119,7 @@ class ClinicSubscription 'plan' => $this->plan->toArray(), 'period' => $this->period->toArray(), 'is_trial' => $this->isTrial, + 'is_granted' => $this->isGranted(), 'starts_at' => $this->startsAt, 'expires_at' => $this->expiresAt, 'days_remaining' => $this->getDaysRemaining(), diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index 368d82f2..cdd3288b 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -2,6 +2,7 @@ namespace App\Subscription\Service; +use App\Auth\Entity\User; use App\Config\Repository\SiteConfigRepository; use App\Payment\Entity\Payment; use App\Shared\Constant\ErrorCodes; @@ -136,6 +137,39 @@ class SubscriptionService return $subscription; } + /** + * اعطای اشتراک توسط ادمین، بدون پرداخت. + * + * عمداً `isTrial` را ست نمی‌کند: تریال یک‌بارمصرف است و `hasUsedTrial` روی همین + * پرچم تصمیم می‌گیرد، پس اشتراک هدیه نباید تریالِ نگرفتهٔ کاربر را بسوزاند. + * + * تمدید هم مثل مسیر پرداخت روی انقضای فعلی سوار می‌شود، نه از امروز. + */ + public function grant(string $entityType, int $entityId, string $periodUuid, User $grantedBy): ClinicSubscription + { + $period = $this->periodRepo->findByUuid($periodUuid); + if ($period === null) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 404); + } + + $currentExpires = $this->getActiveSubscription($entityType, $entityId)?->getExpiresAt(); + + $subscription = new ClinicSubscription( + $entityType, + $entityId, + $period->getPlan(), + $period, + false, + $this->calculateExpiresAt($currentExpires, $period->getDurationMonths()), + null, + $grantedBy + ); + + $this->subscriptionRepo->save($subscription); + + return $subscription; + } + /** حذف اشتراکِ ساخته‌شده از یک پرداخت (هنگام استرداد/برگشت وجه). */ public function deleteByPayment(\App\Payment\Entity\Payment $payment): void { diff --git a/tests/Subscription/GrantSubscriptionTest.php b/tests/Subscription/GrantSubscriptionTest.php new file mode 100644 index 00000000..de239536 --- /dev/null +++ b/tests/Subscription/GrantSubscriptionTest.php @@ -0,0 +1,109 @@ +createMock(ClinicSubscriptionRepository::class); + $subscriptionRepo->method('findActive')->willReturn($active); + $subscriptionRepo->method('save')->willReturnCallback(function (ClinicSubscription $s): void { + $this->saved = $s; + }); + + $periodRepo = $this->createMock(SubscriptionPeriodRepository::class); + $periodRepo->method('findByUuid')->willReturn($period); + + return new SubscriptionService( + $subscriptionRepo, + $this->createMock(SubscriptionPlanRepository::class), + $periodRepo, + $this->createMock(SiteConfigRepository::class), + ); + } + + private function period(int $durationMonths): SubscriptionPeriod + { + $period = $this->createMock(SubscriptionPeriod::class); + $period->method('getPlan')->willReturn($this->createMock(SubscriptionPlan::class)); + $period->method('getDurationMonths')->willReturn($durationMonths); + + return $period; + } + + public function testGrantCreatesSubscriptionWithoutPaymentAndRecordsTheAdmin(): void + { + $admin = $this->createMock(User::class); + $service = $this->service($this->period(1), null); + + $subscription = $service->grant('doctor', 7, 'period-uuid', $admin); + + $this->assertSame($subscription, $this->saved); + $this->assertSame('doctor', $subscription->getEntityType()); + $this->assertSame(7, $subscription->getEntityId()); + $this->assertNull($subscription->getPayment()); + $this->assertSame($admin, $subscription->getGrantedBy()); + $this->assertTrue($subscription->isGranted()); + $this->assertEqualsWithDelta(time() + 30 * 86400, $subscription->getExpiresAt(), 5); + } + + /** Granting must never burn an unused trial — `hasUsedTrial` reads this flag. */ + public function testGrantIsNeverMarkedAsTrial(): void + { + $service = $this->service($this->period(1), null); + + $subscription = $service->grant('clinic', 3, 'period-uuid', $this->createMock(User::class)); + + $this->assertFalse($subscription->isTrial()); + $this->assertTrue($subscription->toArray()['is_granted']); + } + + public function testUnknownPeriodIsRejected(): void + { + $service = $this->service(null, null); + + try { + $service->grant('doctor', 1, 'missing-uuid', $this->createMock(User::class)); + $this->fail('expected AppException'); + } catch (AppException $e) { + $this->assertSame(ErrorCodes::ERR_NOT_FOUND_001, $e->getErrorCode()); + $this->assertSame(404, $e->getHttpStatus()); + } + } + + /** Boundary: an active subscription is extended from its own expiry, not from today. */ + public function testGrantExtendsAnActiveSubscriptionInsteadOfRestartingIt(): void + { + $currentExpiry = time() + 20 * 86400; + + $active = $this->createMock(ClinicSubscription::class); + $active->method('getExpiresAt')->willReturn($currentExpiry); + + $service = $this->service($this->period(1), $active); + + $subscription = $service->grant('doctor', 7, 'period-uuid', $this->createMock(User::class)); + + $this->assertEqualsWithDelta($currentExpiry + 30 * 86400, $subscription->getExpiresAt(), 5); + } +}