Split SaaS Plan into Plan (product) + PlanPrice (variant)#107
Open
pierredup wants to merge 1 commit into
Open
Conversation
Mirrors the Lemon Squeezy product/variant hierarchy so a single offering no longer surfaces as multiple local rows. Plan owns name, description, features and trial config; PlanPrice owns variantId, price and billing interval. Subscription FKs to PlanPrice; getPlan() delegates. Adds saas:migrate-plan-structure to backfill the new shape from the legacy variant-as-Plan rows; sync now skips the LS auto-generated default variant and deactivates stale prices instead of deleting them.
There was a problem hiding this comment.
Pull request overview
This PR refactors the SaaS billing model to separate “products” (Plan) from “price variants” (PlanPrice), aligning local entities and sync logic with Lemon Squeezy’s product/variant structure and adding a one-shot migration command to backfill the new schema from legacy data.
Changes:
- Introduces
PlanPriceas the variant/price entity and repointsSubscriptionto referencePlanPrice(withSubscription::getPlan()delegating to the owning plan). - Updates Lemon Squeezy plan discovery + checkout payloads, and updates
saas:sync-planto upsert plans with nested prices and deactivate stale variants. - Adds
saas:migrate-plan-structureto migrate legacy “Plan=variant” rows into the new Plan/PlanPrice shape and repoint subscriptions.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Bundle/Saas/Subscription/SubscriptionManagerStartTrialTest.php | Updates mocks to use the new PlanPrice repository dependency. |
| tests/Bundle/Saas/Security/Voter/PlanFeatureVoterTest.php | Import ordering adjustments. |
| tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php | Updates test fixtures to create PlanPrice variants and attach subscriptions to a price. |
| tests/Bundle/Saas/Feature/PlanFeatureGateTest.php | Minor formatting + anonymous class instantiation tweak. |
| tests/Bundle/Saas/Config/SaasConfigurationTest.php | Import ordering adjustments. |
| tests/Bundle/PlatformBundle/Feature/NoopFeatureGateTest.php | Anonymous class instantiation tweak. |
| src/Bundle/Saas/Subscription/SubscriptionProviderInterface.php | Import ordering adjustments. |
| src/Bundle/Saas/Subscription/SubscriptionManager.php | Switches subscription creation/change logic to work with PlanPrice instead of Plan. |
| src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php | Import ordering adjustments. |
| src/Bundle/Saas/Repository/PlanRepository.php | Updates find() signature and changes default/ordered selection logic after removing plan-level price. |
| src/Bundle/Saas/Repository/PlanPriceRepositoryInterface.php | New repository interface for looking up PlanPrice by ULID or provider variant id. |
| src/Bundle/Saas/Repository/PlanPriceRepository.php | New repository implementation (variant-id aware find()). |
| src/Bundle/Saas/Integration/LemonSqueezy.php | Changes getPlans() to yield products with nested prices; checkout now uses PlanPrice.variantId. |
| src/Bundle/Saas/Feature/PlanFeatureToggle.php | Import ordering adjustments. |
| src/Bundle/Saas/Feature/PlanFeatureManager.php | Import ordering adjustments. |
| src/Bundle/Saas/Feature/PlanFeatureGate.php | Minor refactor of subscriber resolution ternary. |
| src/Bundle/Saas/Entity/Subscription.php | Repoints subscription FK from Plan to PlanPrice and adds convenience getPlan() delegator. |
| src/Bundle/Saas/Entity/PlanPrice.php | New entity representing a billable variant (price, interval, active flag, provider variant id). |
| src/Bundle/Saas/Entity/Plan.php | Removes plan-level price, adds one-to-many prices relationship, updates free-plan semantics. |
| src/Bundle/Saas/Dto/IntegrationProductPrice.php | New DTO for variant pricing returned by integrations. |
| src/Bundle/Saas/Dto/IntegrationProduct.php | Changes DTO to carry a list of IntegrationProductPrice instead of a single price/interval. |
| src/Bundle/Saas/DependencyInjection/SolidWorxPlatformSaasExtension.php | Import ordering adjustments. |
| src/Bundle/Saas/Console/Command/SyncSaasPlanCommand.php | Syncs products + variants; deactivates stale variants instead of deleting. |
| src/Bundle/Saas/Console/Command/SubscriptionListCommand.php | Import ordering adjustments. |
| src/Bundle/Saas/Console/Command/MigrateSaasPlanStructureCommand.php | New command to backfill Plan/PlanPrice and repoint subscriptions from legacy schema. |
| src/Bundle/Saas/Config/SaasConfiguration.php | Import ordering adjustments. |
| phpstan-baseline.neon | Updates baseline entries (adds new ignores related to the new repository + other changes). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+55
to
+56
| #[ORM\JoinColumn(name: 'plan_price_id', referencedColumnName: 'id', nullable: false)] | ||
| private PlanPrice $planPrice; |
Comment on lines
52
to
56
| /** | ||
| * Returns the default active plan, or the cheapest active plan when none | ||
| * is explicitly flagged. Used during signup and when more than one plan | ||
| * is configured to highlight a recommended option. | ||
| */ |
|
|
||
| public function removePrice(PlanPrice $price): static | ||
| { | ||
| $this->prices->removeElement($price); |
Comment on lines
+313
to
+315
| $this->io->warning(sprintf('Subscription %s references unknown plan_id; leaving plan_price_id unset.', bin2hex($rowId))); | ||
|
|
||
| continue; |
Comment on lines
+160
to
+164
| * integration and are intentionally not handled here. | ||
| * | ||
| * @throws ActiveSubscriptionPlanChangeException | ||
| */ | ||
| public function changePlan(Subscription $subscription, Plan $plan): void | ||
| public function changePlan(Subscription $subscription, PlanPrice $planPrice): void |
Comment on lines
+57
to
65
| if (! $planPrice instanceof PlanPrice) { | ||
| $priceIdString = match (true) { | ||
| $priceId instanceof Ulid => $priceId->toBase58(), | ||
| $priceId instanceof PlanPrice => $priceId->getVariantId(), | ||
| default => $priceId, | ||
| }; | ||
|
|
||
| throw new InvalidPlanException($planIdString); | ||
| throw new InvalidPlanException($priceIdString); | ||
| } |
Comment on lines
+88
to
+93
| continue; | ||
| } | ||
| if ($price->isActive()) { | ||
| $price->setActive(false); | ||
| $this->planPriceRepository->save($price); | ||
| } |
Comment on lines
+404
to
413
| message: "#^Parameter \\#1 \\$id \\(SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\PlanPrice\\|string\\|Symfony\\\\Component\\\\Uid\\\\Ulid\\) of method SolidWorx\\\\Platform\\\\SaasBundle\\\\Repository\\\\PlanPriceRepository\\:\\:find\\(\\) should be contravariant with parameter \\$id \\(mixed\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\PlanPrice\\>\\:\\:find\\(\\)$#" | ||
| count: 1 | ||
| path: src/Bundle/Saas/Repository/PlanPriceRepository.php | ||
|
|
||
| - | ||
| message: "#^Parameter \\#1 \\$id \\(SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\PlanPrice\\|string\\|Symfony\\\\Component\\\\Uid\\\\Ulid\\) of method SolidWorx\\\\Platform\\\\SaasBundle\\\\Repository\\\\PlanPriceRepository\\:\\:find\\(\\) should be contravariant with parameter \\$id \\(mixed\\) of method Doctrine\\\\Persistence\\\\ObjectRepository\\<SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\PlanPrice\\>\\:\\:find\\(\\)$#" | ||
| count: 1 | ||
| path: src/Bundle/Saas/Repository/PlanPriceRepository.php | ||
|
|
||
| - |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Plannow represents the product (name, description, features, trial); newPlanPricerepresents a variant (variantId, price, interval, active).SubscriptionFKs toPlanPrice(getPlan()delegates), so monthly/yearly variants of the same offering no longer create duplicate plan rows or duplicatedPlanFeatureentries.LemonSqueezy::getPlans()yields one product with nested prices and skips LS's auto-generated default variant (zero unit price / no real price model).saas:sync-planupserts product Plans + variant PlanPrices and deactivates stale prices instead of deleting (preserves historical Subscription FKs).saas:migrate-plan-structure(--dry-runsupported) to backfill the new shape from existing variant-as-Plan rows: dedups features, creates PlanPrices, repoints Subscriptions, deletes orphaned legacy plan rows, handles the free-plan sentinel.Migration workflow
saas_plan_price, addplan_price_idtosaas_subscription. Keep legacysaas_plan.priceandsaas_subscription.plan_idcolumns in place.bin/console saas:migrate-plan-structure --dry-run, inspect the output.bin/console saas:migrate-plan-structurefor real.Test plan
vendor/bin/phpstancleanvendor/bin/ecs checkcleanvendor/bin/rectorcleanvendor/bin/phpunit tests/Bundle/Saas— 101 tests passsaas:sync-planand confirm idempotency (no new rows)variant.idmatches selectedPlanPrice.variantId