feat(subscription): add resource quota management for subscription plans and update related components

This commit is contained in:
hamed
2026-08-04 19:50:47 +03:30
parent 3e7028d77a
commit 2db4c3c4b0
8 changed files with 306 additions and 14 deletions
+10
View File
@@ -14,6 +14,16 @@ export function formatNumber(n: number): string {
return new Intl.NumberFormat('fa-IR').format(n);
}
/**
* سقف منابعِ یک پلن اشتراک به زبان آدم — `-1` یعنی نامحدود.
*
* سه صفحه همین عدد را نشان می‌دهند (منابع، اشتراک، اشتراکِ ادمین)؛ یک جا نوشته می‌شود
* تا «نامحدود» در یکی از آن‌ها به «-۱» تبدیل نشود.
*/
export function formatResourceLimit(max: number): string {
return max < 0 ? 'نامحدود' : formatNumber(max);
}
// سال شمسی: رقم فارسی بدون جداکننده‌ی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»).
export function formatYear(year: number): string {
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
@@ -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();
});
});
+43 -6
View File
@@ -8,7 +8,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse } from '../lib/api';
import type { SubscriptionPlan, SubscriptionPeriod } from '../types';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
@@ -35,11 +35,23 @@ const planSchema = z.object({
name: z.string().min(1, 'نام الزامی است'),
level: z.coerce.number().min(0),
max_secretaries: z.coerce.number().min(1),
// API نامحدود را با `-1` می‌فهمد، ولی فرم عددِ منفی نمی‌گیرد: `numericField` علامت را
// پاک می‌کند و «۱-» هم چیزی نیست که ادمین حدس بزند. پس یک سوییچ، و نگاشت هنگام ارسال.
resources_unlimited: z.boolean(),
max_resources: z.coerce.number().int().min(1),
features: z.record(z.string(), z.boolean()),
active: z.boolean(),
});
type PlanForm = z.infer<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({
plan_uuid: z.string().min(1, 'پلن الزامی است'),
label: z.string().min(1, 'عنوان دوره الزامی است'),
@@ -105,19 +117,21 @@ function PlansTab() {
defaultValues: { features: emptyFeatures(), active: true },
});
const unlimitedResources = planForm.watch('resources_unlimited');
const periodForm = useForm<PeriodForm>({
resolver: zodResolver(periodSchema),
defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 },
});
const createPlanMut = useMutation({
mutationFn: (body: PlanForm) => api.post('/api/v1/admin/subscription/plan', body),
mutationFn: (body: PlanPayload) => api.post('/api/v1/admin/subscription/plan', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پلن ایجاد شد'); },
onError: (e: any) => toast.error(e.message),
});
const updatePlanMut = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: PlanForm }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
mutationFn: ({ uuid, body }: { uuid: string; body: PlanPayload }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پلن بروزرسانی شد'); },
onError: (e: any) => toast.error(e.message),
});
@@ -145,6 +159,10 @@ function PlansTab() {
name: plan.name,
level: plan.level,
max_secretaries: plan.max_secretaries,
// سقفِ نامحدود عددی برای نمایش ندارد؛ ۱ می‌نشیند تا خاموش‌کردن سوییچ یک مقدار
// معتبر بدهد، نه یک فیلدِ خالی.
resources_unlimited: plan.max_resources < 0,
max_resources: plan.max_resources < 0 ? 1 : plan.max_resources,
features: { ...emptyFeatures(), ...plan.features },
active: (plan as any).active ?? true,
});
@@ -173,7 +191,7 @@ function PlansTab() {
return (
<>
<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 }} /> پلن جدید
</button>
</div>
@@ -194,6 +212,7 @@ function PlansTab() {
<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="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 }}>
{Object.entries(plan.features).map(([k, v]) => (
<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' ? 'پلن جدید' : 'ویرایش پلن'}
>
<form onSubmit={planForm.handleSubmit((d) => {
if (planModal === 'create') createPlanMut.mutate(d);
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d });
const body = toPlanPayload(d);
if (planModal === 'create') createPlanMut.mutate(body);
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body });
})}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="field">
@@ -279,6 +299,23 @@ function PlansTab() {
<input {...numericField(planForm.register('max_secretaries'))} />
</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>
<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' }}>
+14 -4
View File
@@ -15,20 +15,20 @@ import SubscriptionPage from './SubscriptionPage';
const get = api.get as ReturnType<typeof vi.fn>;
const PLANS = [
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1,
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, max_resources: 1,
features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] },
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3,
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
features: { patient_records: true, services: true, sms_panel: false }, active: true,
periods: [
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
{ uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false },
] },
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10,
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
];
const DEFAULT_MY = {
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 },
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: {} }, is_trial: false, days_remaining: 25 },
used_trial: true, effective_plan: null,
};
@@ -54,6 +54,16 @@ describe('SubscriptionPage', () => {
expect(proCard.getByText('محبوب‌ترین')).toBeInTheDocument();
});
/** سقف منابع کنار سقف منشی روی هر کارت پلن می‌نشیند؛ `-1` یعنی «نامحدود». */
it('shows the resource quota on every plan card', async () => {
renderWithProviders(<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 () => {
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
+14 -4
View File
@@ -1,13 +1,13 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CreditCardIcon } from '@heroicons/react/24/outline';
import { CreditCardIcon, CubeIcon } from '@heroicons/react/24/outline';
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types';
import { usePaymentConfig } from '../hooks/usePaymentConfig';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
import Modal from '../components/ui/Modal';
import SettingsLayout from '../components/layout/SettingsLayout';
import {
@@ -413,8 +413,8 @@ function PlanCard({
{meta.desc}
</div>
{/* Secretaries pill */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
{/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */}
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' }}>
<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',
@@ -425,6 +425,16 @@ function PlanCard({
</span>
<PlanCardPerson size={18} />
</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>
{/* Period toggle */}
+4
View File
@@ -191,6 +191,10 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu
`max_resources` اختیاری است و پیش‌فرضش `1` — پلنِ ناشناخته نباید بی‌صدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته می‌شود.
مقدارهای پذیرفته‌شده: `-1` یا هر عدد مثبت. `0` و هر منفیِ دیگر → `422 ERR_VALIDATION_001` با پیام «max_resources باید عددی مثبت باشد یا -1 برای نامحدود».
پنل ادمین (`/admin/admin-subscription`) به‌جای گرفتنِ `-1` از کاربر، یک سوییچ «منابع نامحدود» دارد و خودش همان `-1` را می‌فرستد. تست: `tests/Subscription/AdminPlanResourceLimitTest.php`.
**خطاها:**
| کد | HTTP | شرح |
@@ -130,6 +130,31 @@ class SubscriptionController extends BaseController
);
}
/**
* سقف منابع فقط `-1` (نامحدود) یا عددی مثبت است.
*
* `0` پلنی می‌ساخت که هیچ منبعی نمی‌دهد و هر مقدار منفیِ دیگری هم مثل `-1` رفتار
* می‌کرد بی‌آنکه چیزی در پنل نشانش بدهد — هر دو خاموش و گیج‌کننده‌اند.
*/
private function rejectInvalidResourceLimit(array $data): ?JsonResponse
{
if (!isset($data['max_resources'])) {
return null;
}
$max = (int) $data['max_resources'];
if ($max === 0 || $max < SubscriptionPlan::UNLIMITED) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'max_resources باید عددی مثبت باشد یا -1 برای نامحدود',
422,
);
}
return null;
}
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePlan(Request $request): JsonResponse
@@ -145,6 +170,10 @@ class SubscriptionController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
}
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
return $error;
}
$plan = new SubscriptionPlan(
$name,
(int) $data['level'],
@@ -169,6 +198,10 @@ class SubscriptionController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
return $error;
}
if (isset($data['name'])) {
$existing = $this->planRepo->findByName(trim($data['name']));
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
@@ -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]);
}
}