src/Service/Api/Subscription/SubscriptionService.php line 549

Open in your IDE?
  1. <?php
  2. namespace App\Service\Api\Subscription;
  3. use App\Constant\InvoiceStatus;
  4. use App\Constant\SubscriptionStatus;
  5. use App\Constant\TimelineDataConstant;
  6. use App\Entity\Plan\Invoice;
  7. use App\Entity\Plan\Plan;
  8. use App\Entity\Plan\PlanSubscription;
  9. use App\Entity\User;
  10. use App\Service\Api\ReferralService;
  11. use App\Service\Api\Timeline\OtherMonitoring;
  12. use App\Service\Realtime\PublisherInterface;
  13. use Doctrine\ORM\EntityManagerInterface;
  14. use Exception;
  15. use Stripe\Exception\ApiErrorException;
  16. use Stripe\StripeClient;
  17. use Stripe\Subscription;
  18. use Symfony\Component\Mercure\Update;
  19. use Symfony\Component\Serializer\Exception\ExceptionInterface;
  20. use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
  21. class SubscriptionService
  22. {
  23.     private $em;
  24.     private $stripe;
  25.     private $notificationService;
  26.     /**
  27.      * @var NormalizerInterface
  28.      */
  29.     private $normalizer;
  30.     /**
  31.      * @var PublisherInterface
  32.      */
  33.     private $publisher;
  34.     /**
  35.      * @var OtherMonitoring
  36.      */
  37.     private $otherMonitoring;
  38.     /**
  39.      * @var ReferralService
  40.      */
  41.     private $referralService;
  42.     /**
  43.      * @var FeatureService
  44.      */
  45.     private $featureService;
  46.     public function __construct(
  47.         EntityManagerInterface $em,
  48.         StripeClient $stripe,
  49.         NotificationService $notificationService,
  50.         NormalizerInterface $normalizer,
  51.         PublisherInterface $publisher,
  52.         OtherMonitoring $otherMonitoring,
  53.         ReferralService $referralService,
  54.         FeatureService $featureService
  55.     ) {
  56.         $this->em $em;
  57.         $this->stripe $stripe;
  58.         $this->notificationService $notificationService;
  59.         $this->normalizer $normalizer;
  60.         $this->publisher $publisher;
  61.         $this->otherMonitoring $otherMonitoring;
  62.         $this->referralService $referralService;
  63.         $this->featureService $featureService;
  64.     }
  65.     /**
  66.      * Cancel user's subscription
  67.      *
  68.      * @param User $user
  69.      * @param bool $cancelAtPeriodEnd
  70.      * @param string|null $reason
  71.      * @return array
  72.      * @throws Exception|ExceptionInterface
  73.      */
  74.     public function cancelSubscription(User $userbool $cancelAtPeriodEnd true, ?string $reason null): array
  75.     {
  76.         // Get current subscription
  77.         $subscription $this->em->getRepository(PlanSubscription::class)
  78.             ->findOneBy(['user' => $user'status' => SubscriptionStatus::ACTIVE]);
  79.         if (!$subscription) {
  80.             throw new Exception('No active subscription found');
  81.         }
  82.         try {
  83.             if (!$subscription->getPlan()->getIsFree()) {
  84.                 // Cancel Stripe subscription
  85.                 $this->stripe->subscriptions->update(
  86.                     $subscription->getStripeSubscriptionId(),
  87.                     [
  88.                         'cancel_at_period_end' => $cancelAtPeriodEnd,
  89.                         'metadata' => [
  90.                             'cancellation_reason' => $reason
  91.                         ]
  92.                     ]
  93.                 );
  94.             } else {
  95.                 // For free subscriptions, cancel immediately
  96.                 $subscription->setStatus(SubscriptionStatus::CANCELED);
  97.                 $subscription->setCurrentPeriodEnd(new \DateTime());
  98.                 $subscription->setCanceledAt(new \DateTime());
  99.             }
  100.             // Store cancellation reason if provided
  101.             if ($reason) {
  102.                 $subscription->setCancellationReason($reason);
  103.             }
  104.             $subscription->setCancelAtPeriodEnd($cancelAtPeriodEnd);
  105.             $this->em->flush();
  106.             $this->em->refresh($user);
  107.             $normalizedUser $this->normalizer
  108.                 ->normalize($user'json', ['groups' => ['user:read''society:read''societyApproval:read']]);
  109.             if (isset($normalizedUser['activeSubscription'])) {
  110.                 $normalizedUser['activeSubscription']['featureUsage'] = $this->featureService->getFormattedFeatureUsage($user);
  111.             }
  112.             return [
  113.                 'status' => 'success',
  114.                 'message' => $cancelAtPeriodEnd ?
  115.                     'Subscription will be canceled at the end of the billing period' :
  116.                     'Subscription cancellation has been removed',
  117.                 'subscription_id' => $subscription->getId(),
  118.                 'end_date' => $subscription->getCurrentPeriodEnd()->format('Y-m-d H:i:s'),
  119.                 'cancellation_reason' => $subscription->getCancellationReason(),
  120.                 'user' => $normalizedUser
  121.             ];
  122.         } catch (Exception $e) {
  123.             throw new Exception('Failed to cancel subscription: ' $e->getMessage());
  124.         }
  125.     }
  126.     /**
  127.      * Change user's subscription plan
  128.      *
  129.      * @param User $user
  130.      * @param Plan $newPlan
  131.      * @param bool $prorate
  132.      * @return array
  133.      * @throws Exception
  134.      * @throws ExceptionInterface
  135.      */
  136.     public function changePlan(User $userPlan $newPlanbool $prorate true): array
  137.     {
  138.         // Get current subscription
  139.         $currentSubscription $this->em->getRepository(PlanSubscription::class)
  140.             ->findOneBy(['user' => $user'status' => SubscriptionStatus::ACTIVE]);
  141.         $currentPlan $currentSubscription $currentSubscription->getPlan() : null;
  142.         // Handle different scenarios
  143.         if ($currentPlan && $currentPlan->getId() === $newPlan->getId()) {
  144.             return [
  145.                 'status' => 'error',
  146.                 'message' => 'You are already subscribed to this plan. To select a different plan, please choose another option.',
  147.             ];
  148.         } elseif (!$currentPlan || ($currentPlan->getIsFree() && !$newPlan->getIsFree())) {
  149.             // Upgrading from free to paid
  150.             return $this->upgradeFromFreeToPaid($user$currentSubscription$newPlan);
  151.         } elseif (!$currentPlan->getIsFree() && $newPlan->getIsFree()) {
  152.             // Downgrading from paid to free
  153.             return $this->downgradeFromPaidToFree($currentSubscription$newPlan);
  154.         } elseif (!$currentPlan->getIsFree() && !$newPlan->getIsFree()) {
  155.             // Changing between paid plans
  156.             return $this->changeBetweenPaidPlans($currentSubscription$newPlan$prorate);
  157.         } else {
  158.             // Changing between free plans
  159.             return $this->changeBetweenFreePlans($currentSubscription$newPlan);
  160.         }
  161.     }
  162.     /**
  163.      * @param User $user
  164.      * @param string|int $planId
  165.      * @return array|string[]
  166.      * @throws Exception
  167.      */
  168.     public function createSubscription(User $user$planId): array
  169.     {
  170.         $plan $this->em->getRepository(Plan::class)->find($planId);
  171.         if (!$plan) {
  172.             throw new Exception('Plan not found');
  173.         }
  174.         if ($plan->getIsFree()) {
  175.             return $this->createFreeSubscription($user$plan);
  176.         }
  177.         return $this->createPaidSubscription($user$plan);
  178.     }
  179.     private function createFreeSubscription(User $userPlan $plan): array
  180.     {
  181.         $subscription = new PlanSubscription();
  182.         $subscription->setUser($user);
  183.         $subscription->setPlan($plan);
  184.         $subscription->setStatus(SubscriptionStatus::ACTIVE);
  185.         $subscription->setCurrentPeriodStart(new \DateTime());
  186.         $this->em->persist($subscription);
  187.         $this->em->flush();
  188.         return [
  189.             'status' => 'success',
  190.             'message' => 'Free subscription created successfully',
  191.             'clientSecret' => null,
  192.             'subscriptionId' => $subscription->getId(),
  193.         ];
  194.     }
  195.     /**
  196.      * @throws ApiErrorException
  197.      * @throws Exception
  198.      */
  199.     private function createPaidSubscription(User $userPlan $plan): array
  200.     {
  201.         // Reuse the existing Stripe customer if the user already has one,
  202.         // so that previously attached payment methods (cards) are preserved.
  203.         $existingCustomerId $user->getStripeCustomerId();
  204.         if ($existingCustomerId) {
  205.             // Verify the customer still exists on Stripe side
  206.             try {
  207.                 $stripeCustomer $this->stripe->customers->retrieve($existingCustomerId);
  208.                 if ($stripeCustomer->isDeleted()) {
  209.                     $existingCustomerId null;
  210.                 }
  211.             } catch (\Exception $e) {
  212.                 $existingCustomerId null;
  213.             }
  214.         }
  215.         if (!$existingCustomerId) {
  216.             // Create a new Stripe customer only when the user has none
  217.             $stripeCustomer $this->stripe->customers->create([
  218.                 'email' => $user->getEmail(),
  219.                 'name'  => $user->getFullName(),
  220.             ]);
  221.             $user->setStripeCustomerId($stripeCustomer->id);
  222.             $this->em->persist($user);
  223.         }
  224.         $customerId $user->getStripeCustomerId();
  225.         $stripeSubscription $this->stripe->subscriptions->create([
  226.             'customer'         => $customerId,
  227.             'items'            => [['price' => $plan->getStripePriceId()]],
  228.             'payment_behavior' => 'default_incomplete',
  229.             'expand'           => ['latest_invoice.payment_intent'],
  230.         ]);
  231.         $subscription = new PlanSubscription();
  232.         $subscription->setUser($user);
  233.         $subscription->setPlan($plan);
  234.         $subscription->setStripeSubscriptionId($stripeSubscription->id);
  235.         $subscription->setStatus(SubscriptionStatus::INCOMPLETE);
  236.         $subscription->setCurrentPeriodStart(new \DateTime('@' $stripeSubscription->current_period_start));
  237.         $subscription->setCurrentPeriodEnd(new \DateTime('@' $stripeSubscription->current_period_end));
  238.         $this->em->persist($subscription);
  239.         $this->em->flush();
  240.         return [
  241.             'status'         => 'success',
  242.             'message'        => 'Paid subscription created successfully',
  243.             'clientSecret'   => $stripeSubscription->latest_invoice->payment_intent->client_secret,
  244.             'subscriptionId' => $subscription->getId(),
  245.         ];
  246.     }
  247.     /**
  248.      * @throws ApiErrorException
  249.      */
  250.     private function upgradeFromFreeToPaid(User $user, ?PlanSubscription $currentSubscriptionPlan $newPlan): array
  251.     {
  252.         // Create new paid subscription
  253.         return $this->createPaidSubscription($user$newPlan);
  254.     }
  255.     /**
  256.      * @throws ApiErrorException
  257.      */
  258.     private function downgradeFromPaidToFree(PlanSubscription $currentSubscriptionPlan $newPlan): array
  259.     {
  260.         // Cancel the Stripe subscription
  261.         if ($currentSubscription->getStripeSubscriptionId()) {
  262.             $this->stripe->subscriptions->cancel($currentSubscription->getStripeSubscriptionId());
  263.         }
  264.         // Create new free subscription
  265.         $user $currentSubscription->getUser();
  266.         $newSubscription = new PlanSubscription();
  267.         $newSubscription->setUser($user);
  268.         $newSubscription->setPlan($newPlan);
  269.         $newSubscription->setStatus(SubscriptionStatus::ACTIVE);
  270.         $newSubscription->setCurrentPeriodStart(new \DateTime());
  271.         // Cancel the old subscription
  272.         $currentSubscription->setStatus(SubscriptionStatus::CANCELED);
  273.         $currentSubscription->setCanceledAt(new \DateTime());
  274.         $this->em->persist($newSubscription);
  275.         $this->em->flush();
  276.         return [
  277.             'status' => 'success',
  278.             'message' => 'Successfully downgraded to free plan',
  279.             'subscriptionId' => $newSubscription->getId(),
  280.             'user' => $this->getUserArray($user),
  281.         ];
  282.     }
  283.     public function getUserArray(User $user)
  284.     {
  285.         try {
  286.             $this->em->refresh($user);
  287.             return $this->normalizer->normalize($usernull, ['groups' => "user:read"]);
  288.         } catch (ExceptionInterface $e) {
  289.             return null;
  290.         }
  291.     }
  292.     /**
  293.      * @throws ApiErrorException
  294.      */
  295.     private function changeBetweenPaidPlans(PlanSubscription $currentSubscriptionPlan $newPlanbool $prorate): array
  296.     {
  297.         // Update the Stripe subscription
  298.         $stripeSubscription $this->stripe->subscriptions->retrieve($currentSubscription->getStripeSubscriptionId());
  299.         $updateParams = [
  300.             'proration_behavior' => $prorate 'create_prorations' 'none',
  301.             'items' => [
  302.                 [
  303.                     'id' => $stripeSubscription->items->data[0]->id,
  304.                     'price' => $newPlan->getStripePriceId(),
  305.                 ],
  306.             ],
  307.         ];
  308.         $updatedSubscription $this->stripe->subscriptions->update(
  309.             $currentSubscription->getStripeSubscriptionId(),
  310.             $updateParams
  311.         );
  312.         // Update the local subscription
  313.         $currentSubscription->setPlan($newPlan);
  314.         $currentSubscription->setCurrentPeriodEnd(new \DateTime('@' $updatedSubscription->current_period_end));
  315.         $this->em->flush();
  316.         return [
  317.             'status' => 'success',
  318.             'message' => 'Successfully changed plan',
  319.             'subscriptionId' => $currentSubscription->getId(),
  320.             'user' => $this->getUserArray($currentSubscription->getUser())
  321.         ];
  322.     }
  323.     /**
  324.      */
  325.     private function changeBetweenFreePlans(PlanSubscription $currentSubscriptionPlan $newPlan): array
  326.     {
  327.         // Simply update the plan reference
  328.         $currentSubscription->setPlan($newPlan);
  329.         $this->em->flush();
  330.         return [
  331.             'status' => 'success',
  332.             'message' => 'Successfully changed free plan',
  333.             'subscriptionId' => $currentSubscription->getId(),
  334.             'user' => $this->getUserArray($currentSubscription->getUser()),
  335.         ];
  336.     }
  337.     /**
  338.      * @throws Exception
  339.      */
  340.     public function handleSubscriptionUpdated(Subscription $stripeSubscription): void
  341.     {
  342.         $subscription $this->em->getRepository(PlanSubscription::class)
  343.             ->findOneBy(['stripeSubscriptionId' => $stripeSubscription->id]);
  344.         if (!$subscription) {
  345.             throw new Exception('Subscription not found');
  346.         }
  347.         $planName $subscription->getPlan()->getName();
  348.         $user $subscription->getUser();
  349.         $newStatus $this->mapStripeStatus($stripeSubscription->status);
  350.         $subscription->setStatus($newStatus);
  351.         $subscriptionItem $stripeSubscription->items->data[0];
  352.         $subscription->setCurrentPeriodStart(new \DateTime('@' $subscriptionItem->current_period_start));
  353.         $subscription->setCurrentPeriodEnd(new \DateTime('@' $subscriptionItem->current_period_end));
  354.         $subscription->setCancelAtPeriodEnd($stripeSubscription->cancel_at_period_end);
  355.         if ($newStatus === SubscriptionStatus::ACTIVE) {
  356.             try {
  357.                 $this->referralService->markReferralAsPaid($user);
  358.             } catch (\Exception $e) {
  359.                 // Ignore
  360.             }
  361.         }
  362.         $this->em->flush();
  363.         try {
  364.             $this->otherMonitoring->eventOnTheserviceCreation(
  365.                 $user,
  366.                 $planName,
  367.                 TimelineDataConstant::getRandomVariantColor('user'),
  368.             );
  369.         } catch (\Exception $e) {
  370.             // Ignore
  371.         }
  372.     }
  373.     /**
  374.      * @throws Exception
  375.      */
  376.     public function handleInvoicePaid(\Stripe\Invoice $invoice): void
  377.     {
  378.         $subscription $this->em->getRepository(PlanSubscription::class)
  379.             ->findOneBy(['stripeSubscriptionId' => $invoice->subscription]);
  380.         if (!$subscription) {
  381.             throw new Exception('Subscription not found');
  382.         }
  383.         $planName $subscription->getPlan()->getName();
  384.         $this->updatePreviousSubscription($subscription);
  385.         $subscription->setStatus(SubscriptionStatus::ACTIVE);
  386.         $newInvoice = new Invoice();
  387.         $newInvoice->setStripeInvoiceId($invoice->id);
  388.         $newInvoice->setSubscription($subscription);
  389.         $newInvoice->setAmount($invoice->amount_paid 100);
  390.         $newInvoice->setStatus(InvoiceStatus::PAID);
  391.         $newInvoice->setPaidAt(new \DateTime('@' $invoice->status_transitions->paid_at));
  392.         $user $subscription->getUser();
  393.         $user->setStatus(true);
  394.         $this->em->persist($newInvoice);
  395.         $this->em->flush();
  396.         // Mark referral as paid (if user was a referee)
  397.         try {
  398.             $this->referralService->markReferralAsPaid($user);
  399.         } catch (\Exception $e) {
  400.             // Log or ignore to prevent breaking the webhook
  401.         }
  402.         try {
  403.             $this->sendSuccessMessage($user);
  404.         } catch (\Exception $e) {
  405.             // Ignore
  406.         }
  407.         try {
  408.             $this->notificationService->sendPaymentSuccessNotification($subscription->getUser(), $newInvoice);
  409.         } catch (\Exception $e) {
  410.             // Ignore
  411.         }
  412.         try {
  413.             $this->otherMonitoring->eventOnTheserviceCreation(
  414.                 $user,
  415.                 $planName,
  416.                 TimelineDataConstant::getRandomVariantColor('user'),
  417.             );
  418.         } catch (\Exception $e) {
  419.             // Ignore
  420.         }
  421.     }
  422.     /**
  423.      * @param User $user
  424.      * @return void
  425.      */
  426.     public function sendSuccessMessage(User $user): void
  427.     {
  428.         $this->publisher->__invoke(
  429.             sprintf(
  430.                 "%s/api/immo/v2/invoice_paid/{$user->getId()}",
  431.                 $_ENV['HTTP_HOST']
  432.             ),
  433.             json_encode($this->getUserArray($user))
  434.         );
  435.     }
  436.     public function updatePreviousSubscription(PlanSubscription $subscription): void
  437.     {
  438.         $previousSubscription $this->em->getRepository(PlanSubscription::class)
  439.             ->findOneBy(['user' => $subscription->getUser(), 'status' => SubscriptionStatus::ACTIVE ]);
  440.         if ($previousSubscription && $previousSubscription->getPlan()->getIsFree() && !$subscription->getPlan()->getIsFree()) {
  441.             // Cancel the current free subscription
  442.             $previousSubscription->setStatus(SubscriptionStatus::CANCELED);
  443.             $previousSubscription->setCanceledAt(new \DateTime());
  444.             $this->em->flush();
  445.         }
  446.     }
  447.     /**
  448.      * @throws Exception
  449.      */
  450.     public function handleInvoicePaymentFailed(\Stripe\Invoice $invoice)
  451.     {
  452.         $subscription $this->em->getRepository(PlanSubscription::class)
  453.             ->findOneBy(['stripeSubscriptionId' => $invoice->subscription]);
  454.         if (!$subscription) {
  455.             throw new Exception('Subscription not found');
  456.         }
  457.         $subscription->setStatus(SubscriptionStatus::PAST_DUE);
  458.         $newInvoice = new Invoice();
  459.         $newInvoice->setStripeInvoiceId($invoice->id);
  460.         $newInvoice->setSubscription($subscription);
  461.         $newInvoice->setAmount($invoice->amount_due 100);
  462.         $newInvoice->setStatus(InvoiceStatus::PAYMENT_FAILED);
  463.         $this->em->persist($newInvoice);
  464.         $this->em->flush();
  465.         $this->notificationService->sendPaymentFailureNotification($subscription->getUser(), $newInvoice);
  466.     }
  467.     /**
  468.      * @throws Exception
  469.      */
  470.     public function handleSubscriptionCanceled(Subscription $stripeSubscription)
  471.     {
  472.         $subscription $this->em->getRepository(PlanSubscription::class)
  473.             ->findOneBy(['stripeSubscriptionId' => $stripeSubscription->id]);
  474.         if (!$subscription) {
  475.             throw new Exception('Subscription not found');
  476.         }
  477.         $subscription->setStatus(SubscriptionStatus::CANCELED);
  478.         $subscription->setCurrentPeriodEnd(new \DateTime('@' $stripeSubscription->canceled_at));
  479.         $subscription->setCanceledAt(new \DateTime('@' $stripeSubscription->canceled_at));
  480.         $this->em->flush();
  481.         $this->notificationService->sendSubscriptionCanceledNotification($subscription->getUser(), $subscription);
  482.     }
  483.     private function mapStripeStatus(string $stripeStatus): string
  484.     {
  485.         $statusMap = [
  486.             'incomplete' => SubscriptionStatus::INCOMPLETE,
  487.             'incomplete_expired' => SubscriptionStatus::INCOMPLETE_EXPIRED,
  488.             'trialing' => SubscriptionStatus::TRIALING,
  489.             'active' => SubscriptionStatus::ACTIVE,
  490.             'past_due' => SubscriptionStatus::PAST_DUE,
  491.             'canceled' => SubscriptionStatus::CANCELED,
  492.             'unpaid' => SubscriptionStatus::UNPAID,
  493.         ];
  494.         return $statusMap[$stripeStatus] ?? SubscriptionStatus::INCOMPLETE;
  495.     }
  496.     public function getAllPlans(): array
  497.     {
  498.         return $this->em->createQueryBuilder()
  499.             ->select('plan')
  500.             ->from(Plan::class, 'plan')
  501.             ->where('plan.status = true OR plan.status is null')
  502.             ->orderBy('plan.displayOrder''ASC')
  503.             ->getQuery()
  504.             ->getResult();
  505.     }
  506. }