From 414104c5a22cda43f9ea49ba083ce6644ad509a2 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:07:06 +0200 Subject: [PATCH 1/6] 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 cb462269..8ca6659b 100644 --- a/src/Bundle/Saas/Repository/PlanRepository.php +++ b/src/Bundle/Saas/Repository/PlanRepository.php @@ -83,6 +83,22 @@ public function findDefault(): ?Plan return $fallback instanceof Plan ? $fallback : null; } + 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 f921d86e..5d2b0162 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 | nu */ 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 633ca885d73da0fa8856da41698edfd714d2b441 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:13:24 +0200 Subject: [PATCH 2/6] 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 3d7972b4..694d846a 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; use Webmozart\Assert\Assert; /** @@ -42,4 +44,22 @@ public function findOneBy(array $criteria, array | null $orderBy = null): ?Subsc 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 52f0aeb2..d15450c9 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 21ada705f3307b291c41ea3200c9b63d99fba687 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 15:33:00 +0200 Subject: [PATCH 3/6] 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 00000000..86fc8b1d --- /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 0ce58efb..02f307a0 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; @@ -188,6 +189,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 00000000..57eb1011 --- /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 29a8ae7b275c3ce340d69525caa0ac7e0d5c8013 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 16 Jul 2026 18:04:58 +0200 Subject: [PATCH 4/6] 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 694d846a..a49c9162 100644 --- a/src/Bundle/Saas/Repository/SubscriptionRepository.php +++ b/src/Bundle/Saas/Repository/SubscriptionRepository.php @@ -46,6 +46,13 @@ public function findOneBy(array $criteria, array | null $orderBy = null): ?Subsc } /** + * 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 @@ -54,6 +61,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') From 32a9b1bb957cd235aecedb57a2163643eb243640 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Tue, 15 Sep 2026 10:10:15 +0200 Subject: [PATCH 5/6] Add check for subscription status before activation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Bundle/Saas/Subscription/SubscriptionManager.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Bundle/Saas/Subscription/SubscriptionManager.php b/src/Bundle/Saas/Subscription/SubscriptionManager.php index 02f307a0..46323b5f 100644 --- a/src/Bundle/Saas/Subscription/SubscriptionManager.php +++ b/src/Bundle/Saas/Subscription/SubscriptionManager.php @@ -209,7 +209,9 @@ public function downgradeToFree(Subscription $subscription): void $this->changePlan($subscription, $freePlan); } - $this->activate($subscription); + if ($subscription->getStatus() !== SubscriptionStatus::ACTIVE) { + $this->activate($subscription); + } } /** From 24acaca85e80943dc4b8b7c8eb10aff00decc8e0 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Tue, 15 Sep 2026 10:14:18 +0200 Subject: [PATCH 6/6] Fix CS --- src/Bundle/Saas/Repository/PlanRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/Saas/Repository/PlanRepository.php b/src/Bundle/Saas/Repository/PlanRepository.php index 8ca6659b..c53eb9d0 100644 --- a/src/Bundle/Saas/Repository/PlanRepository.php +++ b/src/Bundle/Saas/Repository/PlanRepository.php @@ -91,7 +91,7 @@ public function findFree(): ?Plan ->andWhere('p.active = :active') ->setParameter('price', 0) ->setParameter('planId', '0') - ->setParameter('active', true) + ->setParameter('active', value: true) ->setMaxResults(1) ->getQuery() ->getOneOrNullResult();