Skip to content

feat(saas): free-plan lookup, expired-trial scan, and downgradeToFree()#141

Open
pierredup wants to merge 4 commits into
mainfrom
feature/auto-downgrade-expired-trials
Open

feat(saas): free-plan lookup, expired-trial scan, and downgradeToFree()#141
pierredup wants to merge 4 commits into
mainfrom
feature/auto-downgrade-expired-trials

Conversation

@pierredup

Copy link
Copy Markdown
Member

Summary

Adds the model-level building blocks for SolidInvoice's "auto-downgrade expired trials" feature. All changes are additive — no behavior change to existing methods.

What's included (src/Bundle/Saas)

  • PlanRepository::findFree() (+ interface) — resolves the Free plan by shape (price === 0 && planId === '0' && active) rather than the default flag, so a downgrade always lands on the actual free tier.
  • SubscriptionRepository::findExpiredTrials(DateTimeImmutable $now) (+ interface) — returns TRIAL subscriptions past their endDate, excluding externally-billed ones (subscriptionId null/empty). Provider-managed (on_trial-webhook) trials are therefore never auto-downgraded, so external billing is never silently bypassed.
  • SubscriptionManager::downgradeToFree(Subscription) — the single semantic operation behind the feature: resolve the free plan → changePlan (only when not already free) → activate. Throws NoFreePlanConfiguredException when no free plan exists (never downgrades onto a paid plan). Mirrors the existing "choose Free" flow and never invokes the payment integration.
  • NoFreePlanConfiguredException.

Testing

  • Unit (SubscriptionManagerDowngradeTest): changes plan + activates when not already free; activates without a plan change when already free; throws when no free plan is configured. 3/3 passing.
  • ECS clean; PHPStan Level Max clean on the changed files.

Consumed by

The SolidInvoice DowngradeExpiredTrialsCommand (hourly scheduled task) — see the SolidInvoice/SolidInvoice PR for feature/auto-downgrade-expired-trials. Merge this and composer update solidworx/platform there.

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.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 29517737406

Warning

No base build found for commit 12768fb on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 19.927%

Details

  • Patch coverage: 31 uncovered changes across 4 files (0 of 31 lines covered, 0.0%).

Uncovered Changes

File Changed Covered %
src/Bundle/Saas/Repository/PlanRepository.php 11 0 0.0%
src/Bundle/Saas/Repository/SubscriptionRepository.php 10 0 0.0%
src/Bundle/Saas/Subscription/SubscriptionManager.php 6 0 0.0%
src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php 4 0 0.0%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 3307
Covered Lines: 659
Line Coverage: 19.93%
Coverage Strength: 2.38 hits per line

💛 - Coveralls

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds the core SaasBundle building blocks to support an “auto-downgrade expired trials” flow: resolving the configured free plan, querying for expired trials (excluding externally billed subscriptions), and performing a single semantic downgrade-to-free operation via SubscriptionManager.

Changes:

  • Add PlanRepository::findFree() (+ interface) to locate the active free tier by “free-plan shape” (price 0, planId "0").
  • Add SubscriptionRepository::findExpiredTrials(DateTimeImmutable $now) (+ interface) to list TRIAL subscriptions past endDate, excluding externally billed ones.
  • Add SubscriptionManager::downgradeToFree() and NoFreePlanConfiguredException, plus a unit test covering downgrade behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Bundle/Saas/Subscription/SubscriptionManagerDowngradeTest.php Adds unit coverage for the new downgradeToFree() operation.
src/Bundle/Saas/Subscription/SubscriptionManager.php Introduces downgradeToFree() to swap to free (if needed) and activate.
src/Bundle/Saas/Repository/PlanRepositoryInterface.php Defines the new findFree() repository contract.
src/Bundle/Saas/Repository/PlanRepository.php Implements findFree() query for the active free plan.
src/Bundle/Saas/Repository/SubscriptionRepositoryInterface.php Defines findExpiredTrials() contract for expired-trial lookup.
src/Bundle/Saas/Repository/SubscriptionRepository.php Implements findExpiredTrials() query excluding externally billed trials.
src/Bundle/Saas/Exception/NoFreePlanConfiguredException.php Adds a dedicated exception for “no free plan configured” downgrade attempts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +210 to +214
if ($subscription->getPlan()->getPlanId() !== $freePlan->getPlanId()) {
$this->changePlan($subscription, $freePlan);
}

$this->activate($subscription);
Comment on lines +16 to +27
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.';
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants