<?php
namespace App\Service\Api\Subscription;
use App\Constant\InvoiceStatus;
use App\Constant\SubscriptionStatus;
use App\Constant\TimelineDataConstant;
use App\Entity\Plan\Invoice;
use App\Entity\Plan\Plan;
use App\Entity\Plan\PlanSubscription;
use App\Entity\User;
use App\Service\Api\ReferralService;
use App\Service\Api\Timeline\OtherMonitoring;
use App\Service\Realtime\PublisherInterface;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Stripe\Exception\ApiErrorException;
use Stripe\StripeClient;
use Stripe\Subscription;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SubscriptionService
{
private $em;
private $stripe;
private $notificationService;
/**
* @var NormalizerInterface
*/
private $normalizer;
/**
* @var PublisherInterface
*/
private $publisher;
/**
* @var OtherMonitoring
*/
private $otherMonitoring;
/**
* @var ReferralService
*/
private $referralService;
/**
* @var FeatureService
*/
private $featureService;
public function __construct(
EntityManagerInterface $em,
StripeClient $stripe,
NotificationService $notificationService,
NormalizerInterface $normalizer,
PublisherInterface $publisher,
OtherMonitoring $otherMonitoring,
ReferralService $referralService,
FeatureService $featureService
) {
$this->em = $em;
$this->stripe = $stripe;
$this->notificationService = $notificationService;
$this->normalizer = $normalizer;
$this->publisher = $publisher;
$this->otherMonitoring = $otherMonitoring;
$this->referralService = $referralService;
$this->featureService = $featureService;
}
/**
* Cancel user's subscription
*
* @param User $user
* @param bool $cancelAtPeriodEnd
* @param string|null $reason
* @return array
* @throws Exception|ExceptionInterface
*/
public function cancelSubscription(User $user, bool $cancelAtPeriodEnd = true, ?string $reason = null): array
{
// Get current subscription
$subscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['user' => $user, 'status' => SubscriptionStatus::ACTIVE]);
if (!$subscription) {
throw new Exception('No active subscription found');
}
try {
if (!$subscription->getPlan()->getIsFree()) {
// Cancel Stripe subscription
$this->stripe->subscriptions->update(
$subscription->getStripeSubscriptionId(),
[
'cancel_at_period_end' => $cancelAtPeriodEnd,
'metadata' => [
'cancellation_reason' => $reason
]
]
);
} else {
// For free subscriptions, cancel immediately
$subscription->setStatus(SubscriptionStatus::CANCELED);
$subscription->setCurrentPeriodEnd(new \DateTime());
$subscription->setCanceledAt(new \DateTime());
}
// Store cancellation reason if provided
if ($reason) {
$subscription->setCancellationReason($reason);
}
$subscription->setCancelAtPeriodEnd($cancelAtPeriodEnd);
$this->em->flush();
$this->em->refresh($user);
$normalizedUser = $this->normalizer
->normalize($user, 'json', ['groups' => ['user:read', 'society:read', 'societyApproval:read']]);
if (isset($normalizedUser['activeSubscription'])) {
$normalizedUser['activeSubscription']['featureUsage'] = $this->featureService->getFormattedFeatureUsage($user);
}
return [
'status' => 'success',
'message' => $cancelAtPeriodEnd ?
'Subscription will be canceled at the end of the billing period' :
'Subscription cancellation has been removed',
'subscription_id' => $subscription->getId(),
'end_date' => $subscription->getCurrentPeriodEnd()->format('Y-m-d H:i:s'),
'cancellation_reason' => $subscription->getCancellationReason(),
'user' => $normalizedUser
];
} catch (Exception $e) {
throw new Exception('Failed to cancel subscription: ' . $e->getMessage());
}
}
/**
* Change user's subscription plan
*
* @param User $user
* @param Plan $newPlan
* @param bool $prorate
* @return array
* @throws Exception
* @throws ExceptionInterface
*/
public function changePlan(User $user, Plan $newPlan, bool $prorate = true): array
{
// Get current subscription
$currentSubscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['user' => $user, 'status' => SubscriptionStatus::ACTIVE]);
$currentPlan = $currentSubscription ? $currentSubscription->getPlan() : null;
// Handle different scenarios
if ($currentPlan && $currentPlan->getId() === $newPlan->getId()) {
return [
'status' => 'error',
'message' => 'You are already subscribed to this plan. To select a different plan, please choose another option.',
];
} elseif (!$currentPlan || ($currentPlan->getIsFree() && !$newPlan->getIsFree())) {
// Upgrading from free to paid
return $this->upgradeFromFreeToPaid($user, $currentSubscription, $newPlan);
} elseif (!$currentPlan->getIsFree() && $newPlan->getIsFree()) {
// Downgrading from paid to free
return $this->downgradeFromPaidToFree($currentSubscription, $newPlan);
} elseif (!$currentPlan->getIsFree() && !$newPlan->getIsFree()) {
// Changing between paid plans
return $this->changeBetweenPaidPlans($currentSubscription, $newPlan, $prorate);
} else {
// Changing between free plans
return $this->changeBetweenFreePlans($currentSubscription, $newPlan);
}
}
/**
* @param User $user
* @param string|int $planId
* @return array|string[]
* @throws Exception
*/
public function createSubscription(User $user, $planId): array
{
$plan = $this->em->getRepository(Plan::class)->find($planId);
if (!$plan) {
throw new Exception('Plan not found');
}
if ($plan->getIsFree()) {
return $this->createFreeSubscription($user, $plan);
}
return $this->createPaidSubscription($user, $plan);
}
private function createFreeSubscription(User $user, Plan $plan): array
{
$subscription = new PlanSubscription();
$subscription->setUser($user);
$subscription->setPlan($plan);
$subscription->setStatus(SubscriptionStatus::ACTIVE);
$subscription->setCurrentPeriodStart(new \DateTime());
$this->em->persist($subscription);
$this->em->flush();
return [
'status' => 'success',
'message' => 'Free subscription created successfully',
'clientSecret' => null,
'subscriptionId' => $subscription->getId(),
];
}
/**
* @throws ApiErrorException
* @throws Exception
*/
private function createPaidSubscription(User $user, Plan $plan): array
{
// Reuse the existing Stripe customer if the user already has one,
// so that previously attached payment methods (cards) are preserved.
$existingCustomerId = $user->getStripeCustomerId();
if ($existingCustomerId) {
// Verify the customer still exists on Stripe side
try {
$stripeCustomer = $this->stripe->customers->retrieve($existingCustomerId);
if ($stripeCustomer->isDeleted()) {
$existingCustomerId = null;
}
} catch (\Exception $e) {
$existingCustomerId = null;
}
}
if (!$existingCustomerId) {
// Create a new Stripe customer only when the user has none
$stripeCustomer = $this->stripe->customers->create([
'email' => $user->getEmail(),
'name' => $user->getFullName(),
]);
$user->setStripeCustomerId($stripeCustomer->id);
$this->em->persist($user);
}
$customerId = $user->getStripeCustomerId();
$stripeSubscription = $this->stripe->subscriptions->create([
'customer' => $customerId,
'items' => [['price' => $plan->getStripePriceId()]],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
]);
$subscription = new PlanSubscription();
$subscription->setUser($user);
$subscription->setPlan($plan);
$subscription->setStripeSubscriptionId($stripeSubscription->id);
$subscription->setStatus(SubscriptionStatus::INCOMPLETE);
$subscription->setCurrentPeriodStart(new \DateTime('@' . $stripeSubscription->current_period_start));
$subscription->setCurrentPeriodEnd(new \DateTime('@' . $stripeSubscription->current_period_end));
$this->em->persist($subscription);
$this->em->flush();
return [
'status' => 'success',
'message' => 'Paid subscription created successfully',
'clientSecret' => $stripeSubscription->latest_invoice->payment_intent->client_secret,
'subscriptionId' => $subscription->getId(),
];
}
/**
* @throws ApiErrorException
*/
private function upgradeFromFreeToPaid(User $user, ?PlanSubscription $currentSubscription, Plan $newPlan): array
{
// Create new paid subscription
return $this->createPaidSubscription($user, $newPlan);
}
/**
* @throws ApiErrorException
*/
private function downgradeFromPaidToFree(PlanSubscription $currentSubscription, Plan $newPlan): array
{
// Cancel the Stripe subscription
if ($currentSubscription->getStripeSubscriptionId()) {
$this->stripe->subscriptions->cancel($currentSubscription->getStripeSubscriptionId());
}
// Create new free subscription
$user = $currentSubscription->getUser();
$newSubscription = new PlanSubscription();
$newSubscription->setUser($user);
$newSubscription->setPlan($newPlan);
$newSubscription->setStatus(SubscriptionStatus::ACTIVE);
$newSubscription->setCurrentPeriodStart(new \DateTime());
// Cancel the old subscription
$currentSubscription->setStatus(SubscriptionStatus::CANCELED);
$currentSubscription->setCanceledAt(new \DateTime());
$this->em->persist($newSubscription);
$this->em->flush();
return [
'status' => 'success',
'message' => 'Successfully downgraded to free plan',
'subscriptionId' => $newSubscription->getId(),
'user' => $this->getUserArray($user),
];
}
public function getUserArray(User $user)
{
try {
$this->em->refresh($user);
return $this->normalizer->normalize($user, null, ['groups' => "user:read"]);
} catch (ExceptionInterface $e) {
return null;
}
}
/**
* @throws ApiErrorException
*/
private function changeBetweenPaidPlans(PlanSubscription $currentSubscription, Plan $newPlan, bool $prorate): array
{
// Update the Stripe subscription
$stripeSubscription = $this->stripe->subscriptions->retrieve($currentSubscription->getStripeSubscriptionId());
$updateParams = [
'proration_behavior' => $prorate ? 'create_prorations' : 'none',
'items' => [
[
'id' => $stripeSubscription->items->data[0]->id,
'price' => $newPlan->getStripePriceId(),
],
],
];
$updatedSubscription = $this->stripe->subscriptions->update(
$currentSubscription->getStripeSubscriptionId(),
$updateParams
);
// Update the local subscription
$currentSubscription->setPlan($newPlan);
$currentSubscription->setCurrentPeriodEnd(new \DateTime('@' . $updatedSubscription->current_period_end));
$this->em->flush();
return [
'status' => 'success',
'message' => 'Successfully changed plan',
'subscriptionId' => $currentSubscription->getId(),
'user' => $this->getUserArray($currentSubscription->getUser())
];
}
/**
*/
private function changeBetweenFreePlans(PlanSubscription $currentSubscription, Plan $newPlan): array
{
// Simply update the plan reference
$currentSubscription->setPlan($newPlan);
$this->em->flush();
return [
'status' => 'success',
'message' => 'Successfully changed free plan',
'subscriptionId' => $currentSubscription->getId(),
'user' => $this->getUserArray($currentSubscription->getUser()),
];
}
/**
* @throws Exception
*/
public function handleSubscriptionUpdated(Subscription $stripeSubscription): void
{
$subscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['stripeSubscriptionId' => $stripeSubscription->id]);
if (!$subscription) {
throw new Exception('Subscription not found');
}
$planName = $subscription->getPlan()->getName();
$user = $subscription->getUser();
$newStatus = $this->mapStripeStatus($stripeSubscription->status);
$subscription->setStatus($newStatus);
$subscriptionItem = $stripeSubscription->items->data[0];
$subscription->setCurrentPeriodStart(new \DateTime('@' . $subscriptionItem->current_period_start));
$subscription->setCurrentPeriodEnd(new \DateTime('@' . $subscriptionItem->current_period_end));
$subscription->setCancelAtPeriodEnd($stripeSubscription->cancel_at_period_end);
if ($newStatus === SubscriptionStatus::ACTIVE) {
try {
$this->referralService->markReferralAsPaid($user);
} catch (\Exception $e) {
// Ignore
}
}
$this->em->flush();
try {
$this->otherMonitoring->eventOnTheserviceCreation(
$user,
$planName,
TimelineDataConstant::getRandomVariantColor('user'),
);
} catch (\Exception $e) {
// Ignore
}
}
/**
* @throws Exception
*/
public function handleInvoicePaid(\Stripe\Invoice $invoice): void
{
$subscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['stripeSubscriptionId' => $invoice->subscription]);
if (!$subscription) {
throw new Exception('Subscription not found');
}
$planName = $subscription->getPlan()->getName();
$this->updatePreviousSubscription($subscription);
$subscription->setStatus(SubscriptionStatus::ACTIVE);
$newInvoice = new Invoice();
$newInvoice->setStripeInvoiceId($invoice->id);
$newInvoice->setSubscription($subscription);
$newInvoice->setAmount($invoice->amount_paid / 100);
$newInvoice->setStatus(InvoiceStatus::PAID);
$newInvoice->setPaidAt(new \DateTime('@' . $invoice->status_transitions->paid_at));
$user = $subscription->getUser();
$user->setStatus(true);
$this->em->persist($newInvoice);
$this->em->flush();
// Mark referral as paid (if user was a referee)
try {
$this->referralService->markReferralAsPaid($user);
} catch (\Exception $e) {
// Log or ignore to prevent breaking the webhook
}
try {
$this->sendSuccessMessage($user);
} catch (\Exception $e) {
// Ignore
}
try {
$this->notificationService->sendPaymentSuccessNotification($subscription->getUser(), $newInvoice);
} catch (\Exception $e) {
// Ignore
}
try {
$this->otherMonitoring->eventOnTheserviceCreation(
$user,
$planName,
TimelineDataConstant::getRandomVariantColor('user'),
);
} catch (\Exception $e) {
// Ignore
}
}
/**
* @param User $user
* @return void
*/
public function sendSuccessMessage(User $user): void
{
$this->publisher->__invoke(
sprintf(
"%s/api/immo/v2/invoice_paid/{$user->getId()}",
$_ENV['HTTP_HOST']
),
json_encode($this->getUserArray($user))
);
}
public function updatePreviousSubscription(PlanSubscription $subscription): void
{
$previousSubscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['user' => $subscription->getUser(), 'status' => SubscriptionStatus::ACTIVE ]);
if ($previousSubscription && $previousSubscription->getPlan()->getIsFree() && !$subscription->getPlan()->getIsFree()) {
// Cancel the current free subscription
$previousSubscription->setStatus(SubscriptionStatus::CANCELED);
$previousSubscription->setCanceledAt(new \DateTime());
$this->em->flush();
}
}
/**
* @throws Exception
*/
public function handleInvoicePaymentFailed(\Stripe\Invoice $invoice)
{
$subscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['stripeSubscriptionId' => $invoice->subscription]);
if (!$subscription) {
throw new Exception('Subscription not found');
}
$subscription->setStatus(SubscriptionStatus::PAST_DUE);
$newInvoice = new Invoice();
$newInvoice->setStripeInvoiceId($invoice->id);
$newInvoice->setSubscription($subscription);
$newInvoice->setAmount($invoice->amount_due / 100);
$newInvoice->setStatus(InvoiceStatus::PAYMENT_FAILED);
$this->em->persist($newInvoice);
$this->em->flush();
$this->notificationService->sendPaymentFailureNotification($subscription->getUser(), $newInvoice);
}
/**
* @throws Exception
*/
public function handleSubscriptionCanceled(Subscription $stripeSubscription)
{
$subscription = $this->em->getRepository(PlanSubscription::class)
->findOneBy(['stripeSubscriptionId' => $stripeSubscription->id]);
if (!$subscription) {
throw new Exception('Subscription not found');
}
$subscription->setStatus(SubscriptionStatus::CANCELED);
$subscription->setCurrentPeriodEnd(new \DateTime('@' . $stripeSubscription->canceled_at));
$subscription->setCanceledAt(new \DateTime('@' . $stripeSubscription->canceled_at));
$this->em->flush();
$this->notificationService->sendSubscriptionCanceledNotification($subscription->getUser(), $subscription);
}
private function mapStripeStatus(string $stripeStatus): string
{
$statusMap = [
'incomplete' => SubscriptionStatus::INCOMPLETE,
'incomplete_expired' => SubscriptionStatus::INCOMPLETE_EXPIRED,
'trialing' => SubscriptionStatus::TRIALING,
'active' => SubscriptionStatus::ACTIVE,
'past_due' => SubscriptionStatus::PAST_DUE,
'canceled' => SubscriptionStatus::CANCELED,
'unpaid' => SubscriptionStatus::UNPAID,
];
return $statusMap[$stripeStatus] ?? SubscriptionStatus::INCOMPLETE;
}
public function getAllPlans(): array
{
return $this->em->createQueryBuilder()
->select('plan')
->from(Plan::class, 'plan')
->where('plan.status = true OR plan.status is null')
->orderBy('plan.displayOrder', 'ASC')
->getQuery()
->getResult();
}
}