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:
hamed
2026-08-09 13:43:30 +03:30
parent 60ccd5cc1d
commit a6a965a2aa
10 changed files with 874 additions and 29 deletions
@@ -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 ─────────────────────────────────────────────────────────────
+17 -1
View File
@@ -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
{