feat(saas): free-plan lookup, expired-trial scan, and downgradeToFree()#141
feat(saas): free-plan lookup, expired-trial scan, and downgradeToFree()#141pierredup wants to merge 4 commits into
Conversation
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.
Coverage Report for CI Build 29517737406Warning No base build found for commit Coverage: 19.927%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
There was a problem hiding this comment.
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 pastendDate, excluding externally billed ones. - Add
SubscriptionManager::downgradeToFree()andNoFreePlanConfiguredException, 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.
| if ($subscription->getPlan()->getPlanId() !== $freePlan->getPlanId()) { | ||
| $this->changePlan($subscription, $freePlan); | ||
| } | ||
|
|
||
| $this->activate($subscription); |
| 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.'; |
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 thedefaultflag, so a downgrade always lands on the actual free tier.SubscriptionRepository::findExpiredTrials(DateTimeImmutable $now)(+ interface) — returnsTRIALsubscriptions past theirendDate, excluding externally-billed ones (subscriptionIdnull/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. ThrowsNoFreePlanConfiguredExceptionwhen no free plan exists (never downgrades onto a paid plan). Mirrors the existing "choose Free" flow and never invokes the payment integration.NoFreePlanConfiguredException.Testing
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.Consumed by
The SolidInvoice
DowngradeExpiredTrialsCommand(hourly scheduled task) — see the SolidInvoice/SolidInvoice PR forfeature/auto-downgrade-expired-trials. Merge this andcomposer update solidworx/platformthere.