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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user