From f58bed1702c275be232303516bc74b990a0f7c79 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:07:06 +0200 Subject: [PATCH 1/4] feat(saas): add PlanRepository::findFree() to resolve the free plan by shape --- src/Bundle/Saas/Repository/PlanRepository.php | 16 ++++++++++++++++ .../Saas/Repository/PlanRepositoryInterface.php | 7 +++++++ 2 files changed, 23 insertions(+) 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 */ From 2b5d50d8311bfece7c16a40ccd4bcd0c9f9c0860 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:13:24 +0200 Subject: [PATCH 2/4] feat(saas): add SubscriptionRepository::findExpiredTrials() --- .../Repository/SubscriptionRepository.php | 20 +++++++++++++++++++ .../SubscriptionRepositoryInterface.php | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/Bundle/Saas/Repository/SubscriptionRepository.php b/src/Bundle/Saas/Repository/SubscriptionRepository.php index 7291c8a..fd911c2 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,22 @@ public function findOneBy(array $criteria, array|null $orderBy = null): ?Subscri return $result; } + + /** + * @return list + */ + public function findExpiredTrials(DateTimeImmutable $now): array + { + /** @var list $subscriptions */ + $subscriptions = $this->createQueryBuilder('s') + ->where('s.status = :status') + ->andWhere('s.endDate <= :now') + ->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; } From 2037fb04bbd12f5594233fa50e9638f80b21fff4 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:33:00 +0200 Subject: [PATCH 3/4] feat(saas): add SubscriptionManager::downgradeToFree() Auto-downgrade a subscription onto the free plan by resolving the free plan, swapping it in (unless already free), and activating. Throws NoFreePlanConfiguredException when no active free plan exists. This is the single semantic operation behind both the manual "choose free" flow and the upcoming expired-trial scheduler command. --- .../NoFreePlanConfiguredException.php | 31 +++++++ .../Saas/Subscription/SubscriptionManager.php | 24 +++++ .../SubscriptionManagerDowngradeTest.php | 89 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php create mode 100644 tests/Bundle/Saas/Subscription/SubscriptionManagerDowngradeTest.php 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/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)); + } +} From 83cef1d9849eca64db69bf5040b88283a80a68be Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 18:04:58 +0200 Subject: [PATCH 4/4] fix(saas): exclude externally-billed trials from findExpiredTrials scan --- src/Bundle/Saas/Repository/SubscriptionRepository.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Bundle/Saas/Repository/SubscriptionRepository.php b/src/Bundle/Saas/Repository/SubscriptionRepository.php index fd911c2..8cc9523 100644 --- a/src/Bundle/Saas/Repository/SubscriptionRepository.php +++ b/src/Bundle/Saas/Repository/SubscriptionRepository.php @@ -44,6 +44,13 @@ public function findOneBy(array $criteria, array|null $orderBy = null): ?Subscri } /** + * 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 @@ -52,6 +59,7 @@ public function findExpiredTrials(DateTimeImmutable $now): array $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')