diff --git a/src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php b/src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php new file mode 100644 index 0000000..86fc8b1 --- /dev/null +++ b/src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php @@ -0,0 +1,31 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Exception; + +use RuntimeException; +use SolidWorx\Platform\SaasBundle\Entity\Subscription; +use Throwable; +use function sprintf; + +class NoFreePlanConfiguredException extends RuntimeException +{ + public function __construct(?Subscription $subscription = null, int $code = 0, ?Throwable $previous = null) + { + $message = $subscription instanceof Subscription + ? sprintf('Cannot downgrade subscription "%s": no active free plan is configured.', $subscription->getId()->toBase58()) + : 'Cannot downgrade to the free plan: no active free plan is configured.'; + + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Bundle/Saas/Repository/PlanRepository.php b/src/Bundle/Saas/Repository/PlanRepository.php index 05deacb..38ac3c2 100644 --- a/src/Bundle/Saas/Repository/PlanRepository.php +++ b/src/Bundle/Saas/Repository/PlanRepository.php @@ -75,6 +75,22 @@ public function findDefault(): ?Plan ->getOneOrNullResult(); } + public function findFree(): ?Plan + { + $result = $this->createQueryBuilder('p') + ->where('p.price = :price') + ->andWhere('p.planId = :planId') + ->andWhere('p.active = :active') + ->setParameter('price', 0) + ->setParameter('planId', '0') + ->setParameter('active', true) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + return $result instanceof Plan ? $result : null; + } + /** * @return list */ diff --git a/src/Bundle/Saas/Repository/PlanRepositoryInterface.php b/src/Bundle/Saas/Repository/PlanRepositoryInterface.php index 5f2718e..1dfe610 100644 --- a/src/Bundle/Saas/Repository/PlanRepositoryInterface.php +++ b/src/Bundle/Saas/Repository/PlanRepositoryInterface.php @@ -30,6 +30,13 @@ public function find(mixed $id, LockMode|int|null $lockMode = null, int|null $lo */ public function findDefault(): ?Plan; + /** + * Returns the active free plan (price 0, planId "0"), or null when no + * free plan is configured. Identifies the plan by its free-tier shape + * rather than the "default" flag. + */ + public function findFree(): ?Plan; + /** * @return list */ diff --git a/src/Bundle/Saas/Repository/SubscriptionRepository.php b/src/Bundle/Saas/Repository/SubscriptionRepository.php index 7291c8a..8cc9523 100644 --- a/src/Bundle/Saas/Repository/SubscriptionRepository.php +++ b/src/Bundle/Saas/Repository/SubscriptionRepository.php @@ -13,10 +13,12 @@ namespace SolidWorx\Platform\SaasBundle\Repository; +use DateTimeImmutable; use Doctrine\Persistence\ManagerRegistry; use Override; use SolidWorx\Platform\PlatformBundle\Repository\EntityRepository; use SolidWorx\Platform\SaasBundle\Entity\Subscription; +use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; /** * @template-extends EntityRepository @@ -40,4 +42,30 @@ public function findOneBy(array $criteria, array|null $orderBy = null): ?Subscri return $result; } + + /** + * Returns expired trial subscriptions that are not externally billed. + * + * Externally-billed trials (those with a non-null/non-empty + * `subscriptionId`, see {@see Subscription::isExternallyBilled()}) are + * excluded: they are governed by the payment provider's own lifecycle + * and must never be auto-downgraded here. + * + * @return list + */ + public function findExpiredTrials(DateTimeImmutable $now): array + { + /** @var list $subscriptions */ + $subscriptions = $this->createQueryBuilder('s') + ->where('s.status = :status') + ->andWhere('s.endDate <= :now') + ->andWhere("(s.subscriptionId IS NULL OR s.subscriptionId = '')") + ->setParameter('status', SubscriptionStatus::TRIAL) + ->setParameter('now', $now) + ->orderBy('s.endDate', 'ASC') + ->getQuery() + ->getResult(); + + return $subscriptions; + } } diff --git a/src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php b/src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php index f72ec0f..59c972e 100644 --- a/src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php +++ b/src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php @@ -13,6 +13,7 @@ namespace SolidWorx\Platform\SaasBundle\Repository; +use DateTimeImmutable; use SolidWorx\Platform\SaasBundle\Entity\Subscription; interface SubscriptionRepositoryInterface @@ -24,4 +25,13 @@ interface SubscriptionRepositoryInterface public function findOneBy(array $criteria, array|null $orderBy = null): ?Subscription; public function save(object $entity, bool $flush = true): void; + + /** + * Subscriptions still flagged TRIAL whose trial period has elapsed + * (endDate at or before $now). A lapsed trial's status is never flipped, + * so this date comparison is the source of truth. + * + * @return list + */ + public function findExpiredTrials(DateTimeImmutable $now): array; } diff --git a/src/Bundle/Saas/Subscription/SubscriptionManager.php b/src/Bundle/Saas/Subscription/SubscriptionManager.php index d4eb26a..e86744d 100644 --- a/src/Bundle/Saas/Subscription/SubscriptionManager.php +++ b/src/Bundle/Saas/Subscription/SubscriptionManager.php @@ -25,6 +25,7 @@ use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; use SolidWorx\Platform\SaasBundle\Exception\ActiveSubscriptionPlanChangeException; use SolidWorx\Platform\SaasBundle\Exception\InvalidPlanException; +use SolidWorx\Platform\SaasBundle\Exception\NoFreePlanConfiguredException; use SolidWorx\Platform\SaasBundle\Exception\TrialConfigurationException; use SolidWorx\Platform\SaasBundle\Integration\Options; use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; @@ -190,6 +191,29 @@ public function activate(Subscription $subscription, ?DateTimeInterface $endDate $this->subscriptionRepository->save($subscription); } + /** + * Auto-downgrade a subscription onto the free plan. Resolves the free + * plan itself, swaps it in (unless already free), and activates. This is + * the single semantic operation behind both the manual "choose free" + * flow and the expired-trial scheduler. + * + * @throws NoFreePlanConfiguredException + */ + public function downgradeToFree(Subscription $subscription): void + { + $freePlan = $this->planRepository->findFree(); + + if (! $freePlan instanceof Plan) { + throw new NoFreePlanConfiguredException($subscription); + } + + if ($subscription->getPlan()->getPlanId() !== $freePlan->getPlanId()) { + $this->changePlan($subscription, $freePlan); + } + + $this->activate($subscription); + } + /** * Switch the plan on an already-active, externally-billed subscription * via the payment integration. Persists the new plan and the renew date diff --git a/tests/Bundle/Saas/Subscription/SubscriptionManagerDowngradeTest.php b/tests/Bundle/Saas/Subscription/SubscriptionManagerDowngradeTest.php new file mode 100644 index 0000000..57eb101 --- /dev/null +++ b/tests/Bundle/Saas/Subscription/SubscriptionManagerDowngradeTest.php @@ -0,0 +1,89 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\Tests\Bundle\Saas\Subscription; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use SolidWorx\Platform\SaasBundle\Entity\Plan; +use SolidWorx\Platform\SaasBundle\Entity\Subscription; +use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; +use SolidWorx\Platform\SaasBundle\Exception\NoFreePlanConfiguredException; +use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; +use SolidWorx\Platform\SaasBundle\Repository\PlanRepositoryInterface; +use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepositoryInterface; +use SolidWorx\Platform\SaasBundle\Subscription\SubscriptionManager; + +#[CoversClass(SubscriptionManager::class)] +final class SubscriptionManagerDowngradeTest extends TestCase +{ + public function testDowngradeChangesPlanAndActivatesWhenNotAlreadyFree(): void + { + $free = $this->freePlan(); + $plans = self::createStub(PlanRepositoryInterface::class); + $plans->method('findFree')->willReturn($free); + + $subs = self::createMock(SubscriptionRepositoryInterface::class); + $subs->expects(self::atLeastOnce())->method('save'); + + $subscription = (new Subscription())->setPlan($this->paidPlan())->setStatus(SubscriptionStatus::TRIAL); + + $this->manager($plans, $subs)->downgradeToFree($subscription); + + self::assertSame($free, $subscription->getPlan()); + self::assertSame(SubscriptionStatus::ACTIVE, $subscription->getStatus()); + } + + public function testDowngradeActivatesWithoutChangingPlanWhenAlreadyFree(): void + { + $free = $this->freePlan(); + $plans = self::createStub(PlanRepositoryInterface::class); + $plans->method('findFree')->willReturn($free); + + $subs = self::createStub(SubscriptionRepositoryInterface::class); + + $subscription = (new Subscription())->setPlan($free)->setStatus(SubscriptionStatus::TRIAL); + + $this->manager($plans, $subs)->downgradeToFree($subscription); + + self::assertSame($free, $subscription->getPlan()); + self::assertSame(SubscriptionStatus::ACTIVE, $subscription->getStatus()); + } + + public function testDowngradeThrowsWhenNoFreePlanConfigured(): void + { + $plans = self::createStub(PlanRepositoryInterface::class); + $plans->method('findFree')->willReturn(null); + + $subscription = (new Subscription())->setPlan($this->paidPlan())->setStatus(SubscriptionStatus::TRIAL); + + $this->expectException(NoFreePlanConfiguredException::class); + + $this->manager($plans, self::createStub(SubscriptionRepositoryInterface::class))->downgradeToFree($subscription); + } + + private function freePlan(): Plan + { + return (new Plan())->setName('Free')->setPlanId('0')->setPrice(0)->setActive(true); + } + + private function paidPlan(): Plan + { + return (new Plan())->setName('Pro')->setPlanId('pro')->setPrice(1900)->setActive(true); + } + + private function manager(PlanRepositoryInterface $plans, SubscriptionRepositoryInterface $subs): SubscriptionManager + { + return new SubscriptionManager($subs, $plans, self::createStub(PaymentIntegrationInterface::class)); + } +}