Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

/*
* This file is part of SolidWorx Platform project.
*
* (c) Pierre du Plessis <open-source@solidworx.co>
*
* 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.';
Comment on lines +16 to +27

parent::__construct($message, $code, $previous);
}
}
16 changes: 16 additions & 0 deletions src/Bundle/Saas/Repository/PlanRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Plan>
*/
Expand Down
7 changes: 7 additions & 0 deletions src/Bundle/Saas/Repository/PlanRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Plan>
*/
Expand Down
28 changes: 28 additions & 0 deletions src/Bundle/Saas/Repository/SubscriptionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subscription>
Expand All @@ -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<Subscription>
*/
public function findExpiredTrials(DateTimeImmutable $now): array
{
/** @var list<Subscription> $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;
}
}
10 changes: 10 additions & 0 deletions src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace SolidWorx\Platform\SaasBundle\Repository;

use DateTimeImmutable;
use SolidWorx\Platform\SaasBundle\Entity\Subscription;

interface SubscriptionRepositoryInterface
Expand All @@ -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<Subscription>
*/
public function findExpiredTrials(DateTimeImmutable $now): array;
}
24 changes: 24 additions & 0 deletions src/Bundle/Saas/Subscription/SubscriptionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment on lines +210 to +214
}

/**
* Switch the plan on an already-active, externally-billed subscription
* via the payment integration. Persists the new plan and the renew date
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

/*
* This file is part of SolidWorx Platform project.
*
* (c) Pierre du Plessis <open-source@solidworx.co>
*
* 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));
}
}
Loading