Skip to content

Split SaaS Plan into Plan (product) + PlanPrice (variant)#107

Open
pierredup wants to merge 1 commit into
mainfrom
saas-plan-update
Open

Split SaaS Plan into Plan (product) + PlanPrice (variant)#107
pierredup wants to merge 1 commit into
mainfrom
saas-plan-update

Conversation

@pierredup
Copy link
Copy Markdown
Member

Summary

  • Mirrors Lemon Squeezy's product/variant hierarchy: Plan now represents the product (name, description, features, trial); new PlanPrice represents a variant (variantId, price, interval, active).
  • Subscription FKs to PlanPrice (getPlan() delegates), so monthly/yearly variants of the same offering no longer create duplicate plan rows or duplicated PlanFeature entries.
  • 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-plan upserts product Plans + variant PlanPrices and deactivates stale prices instead of deleting (preserves historical Subscription FKs).
  • Adds saas:migrate-plan-structure (--dry-run supported) 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

  1. Apply additive schema: create saas_plan_price, add plan_price_id to saas_subscription. Keep legacy saas_plan.price and saas_subscription.plan_id columns in place.
  2. Run bin/console saas:migrate-plan-structure --dry-run, inspect the output.
  3. Run bin/console saas:migrate-plan-structure for real.
  4. Drop the legacy columns.

Test plan

  • vendor/bin/phpstan clean
  • vendor/bin/ecs check clean
  • vendor/bin/rector clean
  • vendor/bin/phpunit tests/Bundle/Saas — 101 tests pass
  • Run dry-run migration against staging Lemon Squeezy data and verify counts
  • Run real migration on staging; confirm one Plan per LS product, one PlanPrice per real variant, default variants skipped, all Subscription rows repointed
  • Re-run saas:sync-plan and confirm idempotency (no new rows)
  • Walk monthly + yearly checkout end-to-end; confirm LS payload variant.id matches selected PlanPrice.variantId

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.
Copilot AI review requested due to automatic review settings May 8, 2026 13:38
Copy link
Copy Markdown

Copilot AI left a comment

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 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 PlanPrice as the variant/price entity and repoints Subscription to reference PlanPrice (with Subscription::getPlan() delegating to the owning plan).
  • Updates Lemon Squeezy plan discovery + checkout payloads, and updates saas:sync-plan to upsert plans with nested prices and deactivate stale variants.
  • Adds saas:migrate-plan-structure to 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 thread phpstan-baseline.neon
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

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

2 participants