feat(subscription): add resource quota management for subscription plans and update related components
This commit is contained in:
@@ -14,6 +14,16 @@ export function formatNumber(n: number): string {
|
|||||||
return new Intl.NumberFormat('fa-IR').format(n);
|
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 {
|
export function formatYear(year: number): string {
|
||||||
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
|
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||||
|
|
||||||
|
expect(await screen.findByText('حداکثر ۳ منبع')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('حداکثر نامحدود منبع')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('فرم ویرایش با مقدار فعلی پر میشود و همان را میفرستد', async () => {
|
||||||
|
renderWithProviders(<AdminSubscriptionPage />, { 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(<AdminSubscriptionPage />, { 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(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||||
|
await openEdit('حرفهای');
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { PaginatedResponse } from '../lib/api';
|
import type { PaginatedResponse } from '../lib/api';
|
||||||
import type { SubscriptionPlan, SubscriptionPeriod } from '../types';
|
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 Modal from '../components/ui/Modal';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
@@ -35,11 +35,23 @@ const planSchema = z.object({
|
|||||||
name: z.string().min(1, 'نام الزامی است'),
|
name: z.string().min(1, 'نام الزامی است'),
|
||||||
level: z.coerce.number().min(0),
|
level: z.coerce.number().min(0),
|
||||||
max_secretaries: z.coerce.number().min(1),
|
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()),
|
features: z.record(z.string(), z.boolean()),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
});
|
});
|
||||||
type PlanForm = z.infer<typeof planSchema>;
|
type PlanForm = z.infer<typeof planSchema>;
|
||||||
|
|
||||||
|
/** بدنهٔ واقعی API: سوییچِ «نامحدود» به همان `-1` قراردادی برمیگردد. */
|
||||||
|
type PlanPayload = Omit<PlanForm, 'resources_unlimited'>;
|
||||||
|
|
||||||
|
const toPlanPayload = ({ resources_unlimited, max_resources, ...rest }: PlanForm): PlanPayload => ({
|
||||||
|
...rest,
|
||||||
|
max_resources: resources_unlimited ? -1 : max_resources,
|
||||||
|
});
|
||||||
|
|
||||||
const periodSchema = z.object({
|
const periodSchema = z.object({
|
||||||
plan_uuid: z.string().min(1, 'پلن الزامی است'),
|
plan_uuid: z.string().min(1, 'پلن الزامی است'),
|
||||||
label: z.string().min(1, 'عنوان دوره الزامی است'),
|
label: z.string().min(1, 'عنوان دوره الزامی است'),
|
||||||
@@ -105,19 +117,21 @@ function PlansTab() {
|
|||||||
defaultValues: { features: emptyFeatures(), active: true },
|
defaultValues: { features: emptyFeatures(), active: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlimitedResources = planForm.watch('resources_unlimited');
|
||||||
|
|
||||||
const periodForm = useForm<PeriodForm>({
|
const periodForm = useForm<PeriodForm>({
|
||||||
resolver: zodResolver(periodSchema),
|
resolver: zodResolver(periodSchema),
|
||||||
defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 },
|
defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPlanMut = useMutation({
|
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('پلن ایجاد شد'); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پلن ایجاد شد'); },
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePlanMut = useMutation({
|
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('پلن بروزرسانی شد'); },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پلن بروزرسانی شد'); },
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -145,6 +159,10 @@ function PlansTab() {
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
level: plan.level,
|
level: plan.level,
|
||||||
max_secretaries: plan.max_secretaries,
|
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 },
|
features: { ...emptyFeatures(), ...plan.features },
|
||||||
active: (plan as any).active ?? true,
|
active: (plan as any).active ?? true,
|
||||||
});
|
});
|
||||||
@@ -173,7 +191,7 @@ function PlansTab() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||||
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1 }); setPlanModal('create'); }}>
|
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1, max_resources: 1, resources_unlimited: false }); setPlanModal('create'); }}>
|
||||||
<PlusIcon style={{ width: 16 }} /> پلن جدید
|
<PlusIcon style={{ width: 16 }} /> پلن جدید
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,6 +212,7 @@ function PlansTab() {
|
|||||||
<span className={`badge ${plan.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>{plan.active ? 'فعال' : 'غیرفعال'}</span>
|
<span className={`badge ${plan.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>{plan.active ? 'فعال' : 'غیرفعال'}</span>
|
||||||
<span className="badge blue" style={{ fontSize: 11 }}>سطح {plan.level}</span>
|
<span className="badge blue" style={{ fontSize: 11 }}>سطح {plan.level}</span>
|
||||||
<span className="muted" style={{ fontSize: 12 }}>حداکثر {plan.max_secretaries} منشی</span>
|
<span className="muted" style={{ fontSize: 12 }}>حداکثر {plan.max_secretaries} منشی</span>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>حداکثر {formatResourceLimit(plan.max_resources)} منبع</span>
|
||||||
<span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
|
<span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
|
||||||
{Object.entries(plan.features).map(([k, v]) => (
|
{Object.entries(plan.features).map(([k, v]) => (
|
||||||
<span key={k} className={`badge ${v ? 'green' : 'gray'}`} style={{ fontSize: 10 }}>{FEATURE_LABELS[k] ?? k}</span>
|
<span key={k} className={`badge ${v ? 'green' : 'gray'}`} style={{ fontSize: 10 }}>{FEATURE_LABELS[k] ?? k}</span>
|
||||||
@@ -260,8 +279,9 @@ function PlansTab() {
|
|||||||
title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'}
|
title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'}
|
||||||
>
|
>
|
||||||
<form onSubmit={planForm.handleSubmit((d) => {
|
<form onSubmit={planForm.handleSubmit((d) => {
|
||||||
if (planModal === 'create') createPlanMut.mutate(d);
|
const body = toPlanPayload(d);
|
||||||
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d });
|
if (planModal === 'create') createPlanMut.mutate(body);
|
||||||
|
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body });
|
||||||
})}>
|
})}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -279,6 +299,23 @@ function PlansTab() {
|
|||||||
<input {...numericField(planForm.register('max_secretaries'))} />
|
<input {...numericField(planForm.register('max_secretaries'))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="plan-max-resources">حداکثر منبع *</label>
|
||||||
|
<input
|
||||||
|
id="plan-max-resources"
|
||||||
|
{...numericField(planForm.register('max_resources'))}
|
||||||
|
disabled={unlimitedResources}
|
||||||
|
style={unlimitedResources ? { opacity: 0.5 } : undefined}
|
||||||
|
/>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: 13.5, cursor: 'pointer', marginTop: 8 }}>
|
||||||
|
<span style={{ color: 'var(--text)' }}>منابع نامحدود</span>
|
||||||
|
<SwitchToggle {...planForm.register('resources_unlimited')} />
|
||||||
|
</label>
|
||||||
|
<span className="field-hint">سقف اتاق و دستگاه و پرسنلِ هر محیط. منابعِ پزشک و پرسنل هم در این شمارش میآیند.</span>
|
||||||
|
{planForm.formState.errors.max_resources && (
|
||||||
|
<span className="field-error">{planForm.formState.errors.max_resources.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="field-label">قابلیتهای پلن</span>
|
<span className="field-label">قابلیتهای پلن</span>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ import SubscriptionPage from './SubscriptionPage';
|
|||||||
const get = api.get as ReturnType<typeof vi.fn>;
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const PLANS = [
|
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: [] },
|
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,
|
features: { patient_records: true, services: true, sms_panel: false }, active: true,
|
||||||
periods: [
|
periods: [
|
||||||
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
|
{ 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: '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: [] },
|
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_MY = {
|
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,
|
used_trial: true, effective_plan: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +54,16 @@ describe('SubscriptionPage', () => {
|
|||||||
expect(proCard.getByText('محبوبترین')).toBeInTheDocument();
|
expect(proCard.getByText('محبوبترین')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** سقف منابع کنار سقف منشی روی هر کارت پلن مینشیند؛ `-1` یعنی «نامحدود». */
|
||||||
|
it('shows the resource quota on every plan card', async () => {
|
||||||
|
renderWithProviders(<SubscriptionPage />, { 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 () => {
|
it('shows the current-plan card without an expiry warning when the plan is healthy', async () => {
|
||||||
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||||
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
|
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types';
|
import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types';
|
||||||
import { usePaymentConfig } from '../hooks/usePaymentConfig';
|
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 Modal from '../components/ui/Modal';
|
||||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||||
import {
|
import {
|
||||||
@@ -413,8 +413,8 @@ function PlanCard({
|
|||||||
{meta.desc}
|
{meta.desc}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secretaries pill */}
|
{/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */}
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<div dir="rtl" style={{
|
<div dir="rtl" style={{
|
||||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||||
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||||
@@ -425,6 +425,16 @@ function PlanCard({
|
|||||||
</span>
|
</span>
|
||||||
<PlanCardPerson size={18} />
|
<PlanCardPerson size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
<div dir="rtl" style={{
|
||||||
|
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||||
|
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '0 12px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||||
|
{formatResourceLimit(plan.max_resources)} منبع
|
||||||
|
</span>
|
||||||
|
<CubeIcon style={{ width: 17 }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period toggle */}
|
{/* Period toggle */}
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu
|
|||||||
|
|
||||||
`max_resources` اختیاری است و پیشفرضش `1` — پلنِ ناشناخته نباید بیصدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته میشود.
|
`max_resources` اختیاری است و پیشفرضش `1` — پلنِ ناشناخته نباید بیصدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته میشود.
|
||||||
|
|
||||||
|
مقدارهای پذیرفتهشده: `-1` یا هر عدد مثبت. `0` و هر منفیِ دیگر → `422 ERR_VALIDATION_001` با پیام «max_resources باید عددی مثبت باشد یا -1 برای نامحدود».
|
||||||
|
|
||||||
|
پنل ادمین (`/admin/admin-subscription`) بهجای گرفتنِ `-1` از کاربر، یک سوییچ «منابع نامحدود» دارد و خودش همان `-1` را میفرستد. تست: `tests/Subscription/AdminPlanResourceLimitTest.php`.
|
||||||
|
|
||||||
**خطاها:**
|
**خطاها:**
|
||||||
|
|
||||||
| کد | HTTP | شرح |
|
| کد | HTTP | شرح |
|
||||||
|
|||||||
@@ -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'])]
|
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
public function adminCreatePlan(Request $request): JsonResponse
|
public function adminCreatePlan(Request $request): JsonResponse
|
||||||
@@ -145,6 +170,10 @@ class SubscriptionController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
$plan = new SubscriptionPlan(
|
$plan = new SubscriptionPlan(
|
||||||
$name,
|
$name,
|
||||||
(int) $data['level'],
|
(int) $data['level'],
|
||||||
@@ -169,6 +198,10 @@ class SubscriptionController extends BaseController
|
|||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
|
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($data['name'])) {
|
if (isset($data['name'])) {
|
||||||
$existing = $this->planRepo->findByName(trim($data['name']));
|
$existing = $this->planRepo->findByName(trim($data['name']));
|
||||||
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
|
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Subscription;
|
||||||
|
|
||||||
|
use App\Subscription\Entity\SubscriptionPlan;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سقف منابعِ پلن از پنل ادمین نوشته و خوانده میشود.
|
||||||
|
*
|
||||||
|
* `-1` یعنی نامحدود؛ `0` و هر منفیِ دیگری رد میشود — پلنی که هیچ منبعی نمیدهد یا
|
||||||
|
* منفیِ بیمعنا دارد، خاموش شکست میخورد و در پنل چیزی نشان نمیدهد.
|
||||||
|
*/
|
||||||
|
class AdminPlanResourceLimitTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private function planName(): string
|
||||||
|
{
|
||||||
|
return 'quota_plan_' . bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlanIsCreatedWithItsResourceQuota(): void
|
||||||
|
{
|
||||||
|
$admin = $this->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<string, array{0: int}> */
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user