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.
This commit is contained in:
@@ -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(<SearchableSelect options={[]} onChange={() => {}} inputId="srv" onInputChange={onInputChange} />);
|
||||
|
||||
fireEvent.change(document.getElementById('srv') as HTMLInputElement, { target: { value: 'رضا' } });
|
||||
|
||||
expect(onInputChange).toHaveBeenCalledWith('رضا');
|
||||
});
|
||||
|
||||
/** نتیجهٔ سرور نباید دوباره روی متنِ تایپشده فیلتر شود. */
|
||||
it('در حالت سرور، گزینهای که با متن تایپشده نمیخواند هم میماند', () => {
|
||||
render(
|
||||
<SearchableSelect
|
||||
options={[{ value: 'd1', label: 'دکتر رضایی' }]}
|
||||
onChange={() => {}}
|
||||
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(<SearchableSelect options={[{ value: 'd1', label: 'دکتر رضایی' }]} onChange={() => {}} 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,6 +13,7 @@ import AdminSubscriptionPage from './AdminSubscriptionPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(<AdminSubscriptionPage />, { 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(<AdminSubscriptionPage />, { 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<EntityType>('doctor');
|
||||
const [entityUuid, setEntityUuid] = useState<string | null>(null);
|
||||
const [periodUuid, setPeriodUuid] = useState<string | null>(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<PaginatedResponse<EntityRow>>(
|
||||
`/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<PaginatedResponse<SubscriptionPlan>>('/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 (
|
||||
<>
|
||||
<div className="card card-pad" style={{ maxWidth: 560 }}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="field-block">
|
||||
<label>نوع مقصد</label>
|
||||
<div className="seg">
|
||||
<button type="button" className={entityType === 'doctor' ? 'on' : ''} onClick={() => changeEntityType('doctor')}>پزشک</button>
|
||||
<button type="button" className={entityType === 'clinic' ? 'on' : ''} onClick={() => changeEntityType('clinic')}>کلینیک</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="grant-entity">{entityType === 'doctor' ? 'پزشک' : 'کلینیک'} <span className="req">*</span></label>
|
||||
<SearchableSelect
|
||||
inputId="grant-entity"
|
||||
options={entityOptions}
|
||||
value={entityUuid}
|
||||
onChange={(v) => setEntityUuid(v === null ? null : String(v))}
|
||||
onInputChange={setSearchInput}
|
||||
isLoading={entitiesLoading}
|
||||
isClearable
|
||||
placeholder="نام یا شماره موبایل را بنویسید..."
|
||||
ariaLabelledBy="grant-entity-label"
|
||||
/>
|
||||
<span className="field-hint">برای یافتن مقصد، بخشی از نام یا شمارهٔ موبایل را تایپ کنید.</span>
|
||||
</div>
|
||||
|
||||
{entityUuid !== null && (
|
||||
<div style={{ background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '11px 14px', fontSize: 13 }}>
|
||||
{activeLoading ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>در حال بررسی اشتراک فعلی...</span>
|
||||
) : activeSub === null ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>این مقصد اشتراک فعالی ندارد.</span>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>اشتراک فعلی:</span>
|
||||
<b>{PLAN_DISPLAY[activeSub.plan.name] ?? activeSub.plan.name}</b>
|
||||
{activeSub.is_trial && <span className="badge amber">تریال</span>}
|
||||
{activeSub.is_granted && <span className="badge violet">اعطایی</span>}
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
انقضا: {activeSub.expires_at ? formatDate(activeSub.expires_at) : 'بینهایت'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field-block">
|
||||
<label htmlFor="grant-period">پلن و دوره <span className="req">*</span></label>
|
||||
<SearchableSelect
|
||||
inputId="grant-period"
|
||||
options={periodOptions}
|
||||
value={periodUuid}
|
||||
onChange={(v) => setPeriodUuid(v === null ? null : String(v))}
|
||||
isClearable
|
||||
placeholder="انتخاب کنید..."
|
||||
ariaLabel="پلن و دوره اشتراک"
|
||||
/>
|
||||
<span className="field-hint">
|
||||
فقط دورههای پولی نمایش داده میشوند. اگر مقصد اشتراک فعال دارد، مدت روی انقضای فعلی افزوده میشود، نه از امروز.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 20 }}>
|
||||
<button type="submit" className="btn primary" disabled={!entityUuid || !periodUuid || grantMut.isPending}>
|
||||
اعطای اشتراک
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={downgrade !== null}
|
||||
title="کاهش سطح پلن"
|
||||
message={`پلن این ${entityType === 'doctor' ? 'پزشک' : 'کلینیک'} از «${downgrade?.from}» به «${downgrade?.to}» کاهش مییابد. ادامه میدهید؟`}
|
||||
confirmLabel="اعطا کن"
|
||||
danger
|
||||
loading={grantMut.isPending}
|
||||
onConfirm={submitGrant}
|
||||
onCancel={() => setDowngrade(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Report tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ReportTab() {
|
||||
@@ -441,7 +649,7 @@ function ReportTab() {
|
||||
<div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نوع</th>
|
||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>مقصد</th>
|
||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>پلن</th>
|
||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>نوع اشتراک</th>
|
||||
<th style={{ textAlign: 'right', padding: '10px 16px', color: 'var(--text-3)', fontWeight: 500 }}>شروع</th>
|
||||
@@ -453,8 +661,11 @@ function ReportTab() {
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.uuid} style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<span className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
|
||||
{row.entityType === 'clinic' ? 'کلینیک' : 'دکتر'} #{row.entityId}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className={`badge ${row.entityType === 'clinic' ? 'blue' : 'green'}`}>
|
||||
{row.entityType === 'clinic' ? 'کلینیک' : 'پزشک'}
|
||||
</span>
|
||||
<b>{row.entityName ?? `#${row.entityId}`}</b>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
@@ -462,7 +673,16 @@ function ReportTab() {
|
||||
<span className="muted" style={{ fontSize: 11, marginRight: 6 }}>سطح {row.plan_level}</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
{row.isTrial ? <span className="badge amber">تریال</span> : <span className="badge blue">پولی</span>}
|
||||
{row.isTrial ? (
|
||||
<span className="badge amber">تریال</span>
|
||||
) : row.isGranted ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="badge violet">اعطایی</span>
|
||||
{row.grantedBy && <span className="muted" style={{ fontSize: 11 }}>{row.grantedBy}</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge blue">پولی</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>{formatDate(row.startsAt)}</td>
|
||||
<td style={{ padding: '10px 16px', color: 'var(--text-2)' }}>
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageHeader title="مدیریت اشتراکها" description="تعریف پلنها، دورهها و گزارش فروش" />
|
||||
<PageHeader title="مدیریت اشتراکها" description="تعریف پلنها و دورهها، اعطای اشتراک و گزارش فروش" />
|
||||
|
||||
<div className="seg" style={{ marginBottom: 20 }}>
|
||||
<button className={tab === 'plans' ? 'on' : ''} onClick={() => setTab('plans')}>پلنها و دورهها</button>
|
||||
<button className={tab === 'grant' ? 'on' : ''} onClick={() => setTab('grant')}>اعطای اشتراک</button>
|
||||
<button className={tab === 'report' ? 'on' : ''} onClick={() => setTab('report')}>گزارش فروش</button>
|
||||
</div>
|
||||
|
||||
{tab === 'plans' && <PlansTab />}
|
||||
{tab === 'grant' && <GrantTab />}
|
||||
{tab === 'report' && <ReportTab />}
|
||||
</>
|
||||
);
|
||||
|
||||
+134
-8
@@ -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 }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Track the admin who granted a subscription without payment.
|
||||
*/
|
||||
final class Version20260809092336 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add granted_by_user_id to clinic_subscriptions';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Subscription;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Subscription\Entity\ClinicSubscription;
|
||||
use App\Subscription\Entity\SubscriptionPeriod;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* An admin granting a subscription must produce a paid-tier subscription with no
|
||||
* payment attached, an audit trail, and no side effect on the target's unused
|
||||
* trial.
|
||||
*/
|
||||
class GrantSubscriptionTest extends TestCase
|
||||
{
|
||||
private ?ClinicSubscription $saved = null;
|
||||
|
||||
private function service(?SubscriptionPeriod $period, ?ClinicSubscription $active): SubscriptionService
|
||||
{
|
||||
$subscriptionRepo = $this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user