feat(subscription): implement effective plan logic and update free plan features

This commit is contained in:
hamed
2026-07-05 10:46:31 +03:30
parent f3ca6d844f
commit 828e3552c0
10 changed files with 148 additions and 30 deletions
+18
View File
@@ -43,6 +43,24 @@ describe('useSubscription', () => {
expect(result.current.isExpiringSoon).toBe(true); expect(result.current.isExpiringSoon).toBe(true);
}); });
it('نبود اشتراک: امکانات را از effective_plan (پلن free) می‌گیرد', async () => {
useAuthStore.setState({ primaryRole: 'doctor' });
get.mockResolvedValue({
data: {
subscription: null,
effective_plan: { features: { patient_records: true, services: true, sms_panel: true }, max_secretaries: 1 },
},
});
const { result } = renderHookWithClient(() => useSubscription());
await waitFor(() => expect(result.current.hasFeature('patient_records')).toBe(true));
expect(result.current.hasFeature('services')).toBe(true);
expect(result.current.hasFeature('sms_panel')).toBe(true);
expect(result.current.hasPlan).toBe(false);
expect(result.current.maxSecretaries).toBe(1);
});
it('برای نقش admin غیرفعال است (query اجرا نمی‌شود)', async () => { it('برای نقش admin غیرفعال است (query اجرا نمی‌شود)', async () => {
useAuthStore.setState({ primaryRole: 'admin' }); useAuthStore.setState({ primaryRole: 'admin' });
const { result } = renderHookWithClient(() => useSubscription()); const { result } = renderHookWithClient(() => useSubscription());
+4 -3
View File
@@ -16,13 +16,14 @@ export function useSubscription() {
}); });
const sub = data?.data?.subscription ?? null; const sub = data?.data?.subscription ?? null;
const features: Record<string, boolean> = sub?.plan?.features ?? {}; const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null;
const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; const features: Record<string, boolean> = effectivePlan?.features ?? {};
const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1;
const hasPlan = sub !== null; const hasPlan = sub !== null;
return { return {
subscription: sub, subscription: sub,
hasFeature: (key: string) => hasPlan && (features[key] ?? false), hasFeature: (key: string) => features[key] ?? false,
maxSecretaries, maxSecretaries,
hasPlan, hasPlan,
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
+11 -5
View File
@@ -181,11 +181,16 @@ function PlansTab() {
<div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div> <div className="card card-pad" style={{ color: 'var(--text-3)' }}>در حال بارگذاری...</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{plans.map((plan) => ( {plans.map((plan) => {
const periods: SubscriptionPeriod[] = Array.isArray(plan.periods)
? plan.periods
: Object.values(plan.periods ?? {});
return (
<div key={plan.uuid} className="card"> <div key={plan.uuid} className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<b style={{ fontSize: 15 }}>{PLAN_DISPLAY[plan.name] ?? plan.name}</b> <b style={{ fontSize: 15 }}>{PLAN_DISPLAY[plan.name] ?? plan.name}</b>
<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 style={{ fontSize: 12, display: 'flex', gap: 6 }}> <span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
@@ -204,7 +209,7 @@ function PlansTab() {
</div> </div>
</div> </div>
{plan.periods.length === 0 ? ( {periods.length === 0 ? (
<div style={{ padding: '20px 16px', color: 'var(--text-3)', fontSize: 13 }}>هنوز دورهای تعریف نشده</div> <div style={{ padding: '20px 16px', color: 'var(--text-3)', fontSize: 13 }}>هنوز دورهای تعریف نشده</div>
) : ( ) : (
<div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <div className="table-wrap"><table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
@@ -218,8 +223,8 @@ function PlansTab() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.periods.map((period, i) => ( {periods.map((period, i) => (
<tr key={period.uuid} style={{ borderBottom: i < plan.periods.length - 1 ? '1px solid var(--border)' : 'none' }}> <tr key={period.uuid} style={{ borderBottom: i < periods.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '8px 16px' }}><b>{period.label}</b></td> <td style={{ padding: '8px 16px' }}><b>{period.label}</b></td>
<td style={{ padding: '8px 16px' }}>{period.duration_months} ماه</td> <td style={{ padding: '8px 16px' }}>{period.duration_months} ماه</td>
<td style={{ padding: '8px 16px' }}>{period.is_trial ? 'رایگان' : formatRial(period.price_rials)}</td> <td style={{ padding: '8px 16px' }}>{period.is_trial ? 'رایگان' : formatRial(period.price_rials)}</td>
@@ -242,7 +247,8 @@ function PlansTab() {
</table></div> </table></div>
)} )}
</div> </div>
))} );
})}
</div> </div>
)} )}
+8
View File
@@ -362,6 +362,7 @@ export interface SubscriptionPlan {
level: number; level: number;
max_secretaries: number; max_secretaries: number;
features: Record<string, boolean>; features: Record<string, boolean>;
active: boolean;
periods: SubscriptionPeriod[]; periods: SubscriptionPeriod[];
} }
@@ -388,6 +389,13 @@ export interface MySubscriptionData {
days_remaining?: number; days_remaining?: number;
} | null; } | null;
used_trial: boolean; used_trial: boolean;
/** پلن مؤثر: پلن اشتراک فعال یا پلن پیش‌فرض free در نبود اشتراک. */
effective_plan: {
name: string;
level: number;
max_secretaries: number;
features: Record<string, boolean>;
} | null;
} }
/** @deprecated use MySubscriptionData */ /** @deprecated use MySubscriptionData */
export interface MySubscription { export interface MySubscription {
+21 -4
View File
@@ -8,6 +8,8 @@
لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth). لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth).
> پلن `free`: همه امکانات (`patient_records`, `services`, `sms_panel`, `insurance`) فعال‌اند؛ تنها محدودیت آن تعداد منشی (`max_secretaries`) است.
**Response 200:** **Response 200:**
```json ```json
{ {
@@ -18,7 +20,7 @@
"name": "free", "name": "free",
"level": 0, "level": 0,
"max_secretaries": 1, "max_secretaries": 1,
"features": { "patient_records": false, "services": false, "sms_panel": false }, "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true },
"active": true, "active": true,
"periods": [] "periods": []
}, },
@@ -71,12 +73,13 @@
"days_remaining": 30, "days_remaining": 30,
"is_active": true "is_active": true
}, },
"used_trial": false "used_trial": false,
"effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "features": {...} }
} }
} }
``` ```
اگر اشتراک فعالی نداشت `subscription` برابر `null` است. اگر اشتراک فعالی نداشت `subscription` برابر `null` است، اما `effective_plan` همیشه مقدار دارد: پلن اشتراک فعال، یا در نبود اشتراک، **پلن پیش‌فرض `free`**. فرانت‌اند برای تعیین دسترسی به امکانات (`hasFeature`) باید از `effective_plan` استفاده کند (نه `subscription`) تا کاربرانِ بدون اشتراک هم امکانات پلن free را داشته باشند. `subscription`/`hasPlan` صرفاً برای نمایش وضعیت اشتراک پولی است.
--- ---
@@ -143,7 +146,7 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu
## Admin Endpoints ## Admin Endpoints
### GET /api/v1/admin/subscription/plans ### GET /api/v1/admin/subscription/plans
**Permission:** `ROLE_ADMIN` — لیست همه پنل‌ها **Permission:** `ROLE_ADMIN` — لیست **همه** پلن‌ها شامل غیرفعال‌ها (بر خلاف endpoint عمومی که فقط فعال‌ها را برمی‌گرداند). هر پلن فیلد `active` دارد و `periods` همیشه یک آرایه است (فقط دوره‌های فعال).
### POST /api/v1/admin/subscription/plan ### POST /api/v1/admin/subscription/plan
**Permission:** `ROLE_ADMIN` **Permission:** `ROLE_ADMIN`
@@ -157,9 +160,23 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu
} }
``` ```
**خطاها:**
| کد | HTTP | شرح |
|----|------|-----|
| ERR_VALIDATION_001 | 422 | `name` یا `level` ارسال نشده |
| ERR_VALIDATION_001 | 422 | پلنی با این نام از قبل وجود دارد (نام یکتاست) |
### PATCH /api/v1/admin/subscription/plan/{uuid} ### PATCH /api/v1/admin/subscription/plan/{uuid}
**Permission:** `ROLE_ADMIN` — ویرایش پنل (همه فیلدها اختیاری) **Permission:** `ROLE_ADMIN` — ویرایش پنل (همه فیلدها اختیاری)
**خطاها:**
| کد | HTTP | شرح |
|----|------|-----|
| ERR_SUBSCRIPTION_NOT_FOUND | 404 | پلن یافت نشد |
| ERR_VALIDATION_001 | 422 | نام جدید متعلق به پلن دیگری است |
### POST /api/v1/admin/subscription/period ### POST /api/v1/admin/subscription/period
**Permission:** `ROLE_ADMIN` **Permission:** `ROLE_ADMIN`
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Free plan: enable all features (patient_records, services, sms_panel, insurance).
* Free plan's only limitation is the number of secretaries (max_secretaries).
*/
final class Version20260705070546 extends AbstractMigration
{
public function getDescription(): string
{
return 'Free plan gets all features enabled; only secretary count is limited';
}
public function up(Schema $schema): void
{
$now = time();
$this->addSql(
"UPDATE subscription_plans
SET features = '{\"patient_records\":true,\"services\":true,\"sms_panel\":true,\"insurance\":true}',
active = 1,
updated_at = {$now}
WHERE name = 'free'"
);
}
public function down(Schema $schema): void
{
$now = time();
$this->addSql(
"UPDATE subscription_plans
SET features = '{\"patient_records\":false,\"services\":false,\"sms_panel\":false}',
updated_at = {$now}
WHERE name = 'free'"
);
}
}
@@ -61,12 +61,14 @@ class SubscriptionController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
} }
$subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId); $subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId);
$usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId); $usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId);
$effectivePlan = $this->subscriptionService->getEffectivePlan($entityType, $entityId);
return $this->success([ return $this->success([
'subscription' => $subscription?->toArray(), 'subscription' => $subscription?->toArray(),
'used_trial' => $usedTrial, 'used_trial' => $usedTrial,
'effective_plan' => $effectivePlan?->toArray(),
]); ]);
} }
@@ -94,7 +96,7 @@ class SubscriptionController extends BaseController
#[IsGranted('ROLE_ADMIN')] #[IsGranted('ROLE_ADMIN')]
public function adminPlans(): JsonResponse public function adminPlans(): JsonResponse
{ {
$plans = $this->planRepo->findAllActive(); $plans = $this->planRepo->findAllForAdmin();
return $this->paginated( return $this->paginated(
array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans), array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans),
@@ -115,6 +117,10 @@ class SubscriptionController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422); return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422);
} }
if ($this->planRepo->findByName($name) !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
}
$plan = new SubscriptionPlan( $plan = new SubscriptionPlan(
$name, $name,
(int) $data['level'], (int) $data['level'],
@@ -138,7 +144,13 @@ class SubscriptionController extends BaseController
$data = json_decode($request->getContent(), true) ?? []; $data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) { $plan->setName($data['name']); } if (isset($data['name'])) {
$existing = $this->planRepo->findByName(trim($data['name']));
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
}
$plan->setName($data['name']);
}
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); } if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); } if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
if (isset($data['features'])) { $plan->setFeatures($data['features']); } if (isset($data['features'])) { $plan->setFeatures($data['features']); }
+2 -2
View File
@@ -90,10 +90,10 @@ class SubscriptionPlan
]; ];
if ($withPeriods) { if ($withPeriods) {
$data['periods'] = array_map( $data['periods'] = array_values(array_map(
fn(SubscriptionPeriod $p) => $p->toArray(), fn(SubscriptionPeriod $p) => $p->toArray(),
$this->periods->filter(fn(SubscriptionPeriod $p) => $p->isActive())->toArray() $this->periods->filter(fn(SubscriptionPeriod $p) => $p->isActive())->toArray()
); ));
} }
return $data; return $data;
@@ -32,6 +32,14 @@ class SubscriptionPlanRepository extends ServiceEntityRepository
->getResult(); ->getResult();
} }
public function findAllForAdmin(): array
{
return $this->createQueryBuilder('p')
->orderBy('p.level', 'ASC')
->getQuery()
->getResult();
}
public function save(SubscriptionPlan $plan): void public function save(SubscriptionPlan $plan): void
{ {
$this->getEntityManager()->persist($plan); $this->getEntityManager()->persist($plan);
@@ -7,6 +7,7 @@ use App\Payment\Entity\Payment;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException; use App\Shared\Exception\AppException;
use App\Subscription\Entity\ClinicSubscription; use App\Subscription\Entity\ClinicSubscription;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Repository\ClinicSubscriptionRepository; use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository; use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\SubscriptionPlanRepository; use App\Subscription\Repository\SubscriptionPlanRepository;
@@ -25,24 +26,28 @@ class SubscriptionService
return $this->subscriptionRepo->findActive($entityType, $entityId); return $this->subscriptionRepo->findActive($entityType, $entityId);
} }
public function hasFeature(string $entityType, int $entityId, string $feature): bool /**
* پلن مؤثر: پلن اشتراک فعال، یا در نبود اشتراک، پلن پیش‌فرض «free».
*/
public function getEffectivePlan(string $entityType, int $entityId): ?SubscriptionPlan
{ {
$subscription = $this->getActiveSubscription($entityType, $entityId); $subscription = $this->getActiveSubscription($entityType, $entityId);
if ($subscription === null) {
return false;
}
return $subscription->getPlan()->hasFeature($feature); return $subscription?->getPlan() ?? $this->planRepo->findByName('free');
}
public function hasFeature(string $entityType, int $entityId, string $feature): bool
{
$plan = $this->getEffectivePlan($entityType, $entityId);
return $plan !== null && $plan->hasFeature($feature);
} }
public function getSecretaryLimit(string $entityType, int $entityId): int public function getSecretaryLimit(string $entityType, int $entityId): int
{ {
$subscription = $this->getActiveSubscription($entityType, $entityId); $plan = $this->getEffectivePlan($entityType, $entityId);
if ($subscription === null) {
return 1;
}
return $subscription->getPlan()->getMaxSecretaries(); return $plan?->getMaxSecretaries() ?? 1;
} }
public function hasUsedTrial(string $entityType, int $entityId): bool public function hasUsedTrial(string $entityType, int $entityId): bool