From 951dda34f9bbb09fadf6fa660af419d2ba00caff Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Fri, 8 May 2026 15:38:01 +0200 Subject: [PATCH] Split Plan entity into Plan (product) + PlanPrice (variant) 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. --- phpstan-baseline.neon | 177 +++------ src/Bundle/Saas/Config/SaasConfiguration.php | 2 +- .../MigrateSaasPlanStructureCommand.php | 374 ++++++++++++++++++ .../Command/SubscriptionListCommand.php | 2 +- .../Console/Command/SyncSaasPlanCommand.php | 57 ++- .../SolidWorxPlatformSaasExtension.php | 2 +- src/Bundle/Saas/Dto/IntegrationProduct.php | 8 +- .../Saas/Dto/IntegrationProductPrice.php | 26 ++ src/Bundle/Saas/Entity/Plan.php | 63 +-- src/Bundle/Saas/Entity/PlanPrice.php | 143 +++++++ src/Bundle/Saas/Entity/Subscription.php | 26 +- src/Bundle/Saas/Feature/PlanFeatureGate.php | 6 +- .../Saas/Feature/PlanFeatureManager.php | 6 +- src/Bundle/Saas/Feature/PlanFeatureToggle.php | 2 +- src/Bundle/Saas/Integration/LemonSqueezy.php | 48 ++- .../Saas/Repository/PlanPriceRepository.php | 51 +++ .../PlanPriceRepositoryInterface.php | 26 ++ src/Bundle/Saas/Repository/PlanRepository.php | 59 ++- .../Saas/Security/Voter/PlanFeatureVoter.php | 2 +- .../Saas/Subscription/SubscriptionManager.php | 38 +- .../SubscriptionProviderInterface.php | 2 +- .../Feature/NoopFeatureGateTest.php | 2 +- .../Saas/Config/SaasConfigurationTest.php | 2 +- .../Saas/Feature/PlanFeatureGateTest.php | 6 +- .../Saas/Feature/PlanFeatureManagerTest.php | 22 +- .../Security/Voter/PlanFeatureVoterTest.php | 2 +- .../SubscriptionManagerStartTrialTest.php | 4 +- 27 files changed, 914 insertions(+), 244 deletions(-) create mode 100644 src/Bundle/Saas/Console/Command/MigrateSaasPlanStructureCommand.php create mode 100644 src/Bundle/Saas/Dto/IntegrationProductPrice.php create mode 100644 src/Bundle/Saas/Entity/PlanPrice.php create mode 100644 src/Bundle/Saas/Repository/PlanPriceRepository.php create mode 100644 src/Bundle/Saas/Repository/PlanPriceRepositoryInterface.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a7f6b1f..3ef53e5 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,20 +1,10 @@ parameters: ignoreErrors: - - - message: "#^Call to method scalarNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: src/Bundle/Platform/Config/PlatformConfiguration.php - - message: "#^Argument of an invalid type array\\|bool\\|float\\|int\\|string\\|null supplied for foreach, only iterables are supported\\.$#" count: 1 path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - message: "#^Binary operation \"\\+\" between mixed and array\\{remember_me_parameter\\: null, always_remember_me\\: false\\} results in an error\\.$#" - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - message: "#^Cannot access offset 'always_remember_me' on mixed\\.$#" count: 1 @@ -80,6 +70,31 @@ parameters: count: 1 path: src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php + - + message: "#^Call to static method new\\(\\) on an unknown class Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidType\\.$#" + count: 2 + path: src/Bundle/Platform/Doctrine/Type/URLType.php + + - + message: "#^Call to static method new\\(\\) on an unknown class Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidFormat\\.$#" + count: 1 + path: src/Bundle/Platform/Doctrine/Type/UTCDateTimeType.php + + - + message: "#^Call to static method new\\(\\) on an unknown class Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidType\\.$#" + count: 1 + path: src/Bundle/Platform/Doctrine/Type/UTCDateTimeType.php + + - + message: "#^PHPDoc tag @throws with type Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidFormat\\|Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidType is not subtype of Throwable$#" + count: 1 + path: src/Bundle/Platform/Doctrine/Type/UTCDateTimeType.php + + - + message: "#^PHPDoc tag @throws with type Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidType is not subtype of Throwable$#" + count: 1 + path: src/Bundle/Platform/Doctrine/Type/UTCDateTimeType.php + - message: "#^Parameter \\#1 \\$child of method Symfony\\\\Component\\\\Form\\\\FormBuilderInterface\\\\|null\\>\\:\\:add\\(\\) expects string\\|Symfony\\\\Component\\\\Form\\\\FormBuilderInterface, mixed given\\.$#" count: 2 @@ -166,7 +181,7 @@ parameters: path: src/Bundle/Platform/Model/User.php - - message: "#^Method SolidWorx\\\\Platform\\\\PlatformBundle\\\\Model\\\\User\\:\\:getUserIdentifier\\(\\) should return non\\-empty\\-string but returns string\\|null\\.$#" + message: "#^Method SolidWorx\\\\Platform\\\\PlatformBundle\\\\Model\\\\User\\:\\:getUserIdentifier\\(\\) should return string but returns string\\|null\\.$#" count: 1 path: src/Bundle/Platform/Model/User.php @@ -226,42 +241,12 @@ parameters: path: src/Bundle/Platform/Twig/Components/Security/TwoFactor.php - - message: "#^Call to method arrayNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: src/Bundle/Saas/Config/SaasConfiguration.php - - - - message: "#^Call to static method getClass\\(\\) on an unknown class Doctrine\\\\Common\\\\Util\\\\ClassUtils\\.$#" - count: 2 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - message: "#^Cannot call method getEndDate\\(\\) on mixed\\.$#" + message: "#^Access to constant Ascending on an unknown class Doctrine\\\\Common\\\\Collections\\\\Order\\.$#" count: 1 path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - message: "#^Cannot call method getPlan\\(\\) on mixed\\.$#" - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - message: "#^Cannot call method getStartDate\\(\\) on mixed\\.$#" - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - message: "#^Cannot call method getStatus\\(\\) on mixed\\.$#" - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - message: "#^Cannot call method getSubscriber\\(\\) on mixed\\.$#" - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - message: "#^Cannot call method getSubscriptionId\\(\\) on mixed\\.$#" + message: "#^Access to constant Descending on an unknown class Doctrine\\\\Common\\\\Collections\\\\Order\\.$#" count: 1 path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php @@ -306,19 +291,14 @@ parameters: path: src/Bundle/Saas/Dto/LemonSqueezy/Meta.php - - message: "#^Property SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\Plan\\:\\:\\$subscriptions type mapping mismatch\\: property can contain Doctrine\\\\Common\\\\Collections\\\\Collection but database expects Doctrine\\\\Common\\\\Collections\\\\Collection&iterable\\\\.$#" - count: 1 - path: src/Bundle/Saas/Entity/Plan.php - - - - message: "#^Property SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\Plan\\:\\:\\$subscriptions with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#" + message: "#^Cannot access property \\$attributes on SolidWorx\\\\Platform\\\\SaasBundle\\\\Dto\\\\LemonSqueezy\\\\Subscription\\|null\\.$#" count: 1 - path: src/Bundle/Saas/Entity/Plan.php + path: src/Bundle/Saas/Event/TrialStartedEvent.php - - message: "#^Cannot access property \\$attributes on SolidWorx\\\\Platform\\\\SaasBundle\\\\Dto\\\\LemonSqueezy\\\\Subscription\\|null\\.$#" + message: "#^Parameter \\#1 \\$plans of class SolidWorx\\\\Platform\\\\PlatformBundle\\\\Feature\\\\UpgradeOptions constructor expects list\\, array\\ given\\.$#" count: 1 - path: src/Bundle/Saas/Event/TrialStartedEvent.php + path: src/Bundle/Saas/Feature/PlanFeatureGate.php - message: "#^Call to an undefined method SolidWorx\\\\Platform\\\\SaasBundle\\\\Repository\\\\PlanFeatureRepositoryInterface\\:\\:remove\\(\\)\\.$#" @@ -355,16 +335,6 @@ parameters: count: 5 path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - message: "#^Call to function is_a\\(\\) with arguments 'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Event\\\\\\\\SubscriptionPaymentFailedEvent'\\|'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Event\\\\\\\\SubscriptionPaymentPaidEvent'\\|'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Event\\\\\\\\SubscriptionPaymentRecoveredEvent'\\|'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Event\\\\\\\\SubscriptionPaymentRefundedEvent', 'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Event\\\\\\\\PaymentEvent' and true will always evaluate to true\\.$#" - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - message: "#^Match arm comparison between SolidWorx\\\\Platform\\\\SaasBundle\\\\Enum\\\\LemonSqueezy\\\\Event\\:\\:SUBSCRIPTION_PAYMENT_REFUNDED and SolidWorx\\\\Platform\\\\SaasBundle\\\\Enum\\\\LemonSqueezy\\\\Event\\:\\:SUBSCRIPTION_PAYMENT_REFUNDED is always true\\.$#" - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - message: "#^Parameter \\#3 \\$subscription of class SolidWorx\\\\Platform\\\\SaasBundle\\\\Event\\\\SubscriptionCancelledEvent constructor expects SolidWorx\\\\Platform\\\\SaasBundle\\\\Dto\\\\LemonSqueezy\\\\Subscription\\|null, SolidWorx\\\\Platform\\\\SaasBundle\\\\Dto\\\\LemonSqueezy\\\\Subscription\\|SolidWorx\\\\Platform\\\\SaasBundle\\\\Dto\\\\LemonSqueezy\\\\SubscriptionInvoice\\|null given\\.$#" count: 1 @@ -430,6 +400,16 @@ parameters: count: 1 path: src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.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\\\\ORM\\\\EntityRepository\\\\:\\: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\\\\:\\:find\\(\\)$#" + count: 1 + path: src/Bundle/Saas/Repository/PlanPriceRepository.php + - message: "#^Parameter \\#1 \\$id \\(SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\Plan\\|string\\|Symfony\\\\Component\\\\Uid\\\\Ulid\\) of method SolidWorx\\\\Platform\\\\SaasBundle\\\\Repository\\\\PlanRepository\\:\\:find\\(\\) should be contravariant with parameter \\$id \\(mixed\\) of method Doctrine\\\\ORM\\\\EntityRepository\\\\:\\:find\\(\\)$#" count: 1 @@ -441,25 +421,20 @@ parameters: path: src/Bundle/Saas/Repository/PlanRepository.php - - message: "#^Method SolidWorx\\\\Platform\\\\SaasBundle\\\\SolidWorxPlatformSaasBundle\\:\\:createContainerExtension\\(\\) never returns null so it can be removed from the return type\\.$#" + message: "#^Parameter \\$vote of method SolidWorx\\\\Platform\\\\SaasBundle\\\\Security\\\\Voter\\\\PlanFeatureVoter\\:\\:voteOnAttribute\\(\\) has invalid type Symfony\\\\Component\\\\Security\\\\Core\\\\Authorization\\\\Voter\\\\Vote\\.$#" count: 1 - path: src/Bundle/Saas/SolidWorxPlatformSaasBundle.php + path: src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php - - message: "#^Parameter \\#1 \\$plan of class SolidWorx\\\\Platform\\\\SaasBundle\\\\Exception\\\\InvalidPlanException constructor expects string, SolidWorx\\\\Platform\\\\SaasBundle\\\\Entity\\\\Plan\\|string\\|Symfony\\\\Component\\\\Uid\\\\Ulid given\\.$#" + message: "#^Method SolidWorx\\\\Platform\\\\SaasBundle\\\\SolidWorxPlatformSaasBundle\\:\\:createContainerExtension\\(\\) never returns null so it can be removed from the return type\\.$#" count: 1 - path: src/Bundle/Saas/Subscription/SubscriptionManager.php + path: src/Bundle/Saas/SolidWorxPlatformSaasBundle.php - message: "#^Method SolidWorx\\\\Platform\\\\SaasBundle\\\\Webhook\\\\Converter\\\\LemonSqueezyPayloadConverter\\:\\:convert\\(\\) has parameter \\$payload with no value type specified in iterable type array\\.$#" count: 1 path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - message: "#^Call to method scalarNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: src/Bundle/Ui/Config/UiConfiguration.php - - message: "#^Method SolidWorx\\\\Platform\\\\UiBundle\\\\SolidWorxPlatformUiBundle\\:\\:createContainerExtension\\(\\) never returns null so it can be removed from the return type\\.$#" count: 1 @@ -525,41 +500,6 @@ parameters: count: 4 path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - message: "#^Call to method arrayNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 5 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method booleanNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 3 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method enumNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method floatNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method integerNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method scalarNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 9 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - message: "#^Call to method variableNode\\(\\) on an unknown class Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeBuilder\\\\.$#" - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - message: "#^Cannot access offset 'active' on mixed\\.$#" count: 1 @@ -691,14 +631,19 @@ parameters: path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - message: "#^Call to method method\\(\\) on an unknown class PHPUnit\\\\Framework\\\\MockObject\\\\Builder\\\\InvocationMocker\\.$#" - count: 1 - path: tests/Bundle/PlatformBundle/EventSubscriber/ConsoleCommandEventSubscriberTest.php + message: "#^Class Doctrine\\\\DBAL\\\\Types\\\\Exception\\\\InvalidType not found\\.$#" + count: 2 + path: tests/Bundle/PlatformBundle/Doctrine/Type/URLTypeTest.php - - message: "#^Call to function is_a\\(\\) with arguments 'SolidWorx\\\\\\\\Platform\\\\\\\\PlatformBundle\\\\\\\\Response\\\\\\\\RedirectResponse', 'Symfony\\\\\\\\Component\\\\\\\\HttpFoundation\\\\\\\\RedirectResponse' and true will always evaluate to true\\.$#" + message: "#^Parameter \\#1 \\$exception of method PHPUnit\\\\Framework\\\\TestCase\\:\\:expectException\\(\\) expects class\\-string\\, string given\\.$#" + count: 2 + path: tests/Bundle/PlatformBundle/Doctrine/Type/URLTypeTest.php + + - + message: "#^Call to method method\\(\\) on an unknown class PHPUnit\\\\Framework\\\\MockObject\\\\Builder\\\\InvocationMocker\\.$#" count: 1 - path: tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php + path: tests/Bundle/PlatformBundle/EventSubscriber/ConsoleCommandEventSubscriberTest.php - message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\) with 'SolidWorx\\\\\\\\Platform\\\\\\\\SaasBundle\\\\\\\\Config\\\\\\\\Builder\\\\\\\\SaasPaymentConfigBuilder' and SolidWorx\\\\Platform\\\\SaasBundle\\\\Config\\\\Builder\\\\SaasPaymentConfigBuilder will always evaluate to true\\.$#" @@ -790,6 +735,11 @@ parameters: count: 6 path: tests/Bundle/Saas/Doctrine/EventSubscriber/MetadataSubscriberTest.php + - + message: "#^Call to method method\\(\\) on an unknown class PHPUnit\\\\Framework\\\\MockObject\\\\Builder\\\\InvocationMocker\\.$#" + count: 6 + path: tests/Bundle/Saas/Feature/PlanFeatureGateTest.php + - message: "#^Call to method method\\(\\) on an unknown class PHPUnit\\\\Framework\\\\MockObject\\\\Builder\\\\InvocationMocker\\.$#" count: 2 @@ -845,11 +795,6 @@ parameters: count: 8 path: tests/Bundle/Saas/Trial/TrialManagerTest.php - - - message: "#^Call to method method\\(\\) on an unknown class PHPUnit\\\\Framework\\\\MockObject\\\\Builder\\\\InvocationMocker\\.$#" - count: 17 - path: tests/Bundle/Saas/Twig/Runtime/FeatureRuntimeTest.php - - message: "#^Dynamic call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertInstanceOf\\(\\)\\.$#" count: 1 diff --git a/src/Bundle/Saas/Config/SaasConfiguration.php b/src/Bundle/Saas/Config/SaasConfiguration.php index 0b58376..ee0e5c6 100644 --- a/src/Bundle/Saas/Config/SaasConfiguration.php +++ b/src/Bundle/Saas/Config/SaasConfiguration.php @@ -15,12 +15,12 @@ use Override; use SolidWorx\Platform\PlatformBundle\Config\PlatformConfigurationInterface; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Entity\Plan; use SolidWorx\Platform\SaasBundle\Entity\PlanFeature; use SolidWorx\Platform\SaasBundle\Entity\Subscription; use SolidWorx\Platform\SaasBundle\Entity\SubscriptionLog; use SolidWorx\Platform\SaasBundle\Entity\Trial; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Trial\TrialUserInterface; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use function in_array; diff --git a/src/Bundle/Saas/Console/Command/MigrateSaasPlanStructureCommand.php b/src/Bundle/Saas/Console/Command/MigrateSaasPlanStructureCommand.php new file mode 100644 index 0000000..5934c6d --- /dev/null +++ b/src/Bundle/Saas/Console/Command/MigrateSaasPlanStructureCommand.php @@ -0,0 +1,374 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Console\Command; + +use DateInterval; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; +use Doctrine\ORM\EntityManagerInterface; +use Override; +use SolidWorx\Platform\PlatformBundle\Console\Command; +use SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct; +use SolidWorx\Platform\SaasBundle\Dto\IntegrationProductPrice; +use SolidWorx\Platform\SaasBundle\Entity\Plan; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; +use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; +use Throwable; + +/** + * One-shot migration from the legacy "Plan = variant" schema to the new + * "Plan (product) + PlanPrice (variant)" schema. + * + * Workflow: + * 1. Apply the additive schema change manually: create `saas_plan_price`, + * add `plan_price_id` to `saas_subscription`. Keep the legacy + * `saas_plan.price` and `saas_subscription.plan_id` columns in place + * until this command finishes. + * 2. Run this command (use --dry-run first). + * 3. After verification, drop the legacy columns. + */ +#[AsCommand(name: 'saas:migrate-plan-structure', description: 'Backfill Plan/PlanPrice rows from the legacy variant-as-Plan schema')] +final class MigrateSaasPlanStructureCommand extends Command +{ + public function __construct( + private readonly EntityManagerInterface $em, + private readonly Connection $connection, + private readonly PaymentIntegrationInterface $paymentIntegration, + ) { + parent::__construct(); + } + + #[Override] + protected function configure(): void + { + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Print the planned changes without writing anything'); + } + + #[Override] + protected function handle(): int + { + $dryRun = (bool) $this->io->getOption('dry-run'); + + $variantIndex = $this->buildVariantIndex(); + $totalVariants = array_sum(array_map(static fn (array $entry): int => count($entry['prices']), $variantIndex)); + $this->io->writeln(sprintf('Discovered %d billable variant(s) across %d product(s) from the payment provider.', $totalVariants, count($variantIndex))); + + $legacyPlans = $this->fetchLegacyPlans(); + $this->io->writeln(sprintf('Found %d legacy plan row(s) to migrate.', count($legacyPlans))); + + $this->em->beginTransaction(); + + try { + /** @var array $newPlanByProductId */ + $newPlanByProductId = []; + /** @var array $newPriceByVariantId */ + $newPriceByVariantId = []; + /** @var array $oldPlanIdToNewPrice (binary id => PlanPrice) */ + $oldPlanIdToNewPrice = []; + + foreach ($legacyPlans as $legacy) { + $legacyPlanId = $this->asString($legacy['plan_id']); + $legacyPrice = (int) $this->asString($legacy['price']); + $legacyId = $this->asString($legacy['id']); + $legacyName = $this->asString($legacy['name']); + $isFree = $legacyPlanId === '0' && $legacyPrice === 0; + + if ($isFree) { + $plan = $newPlanByProductId['__free__'] + ?? $this->createPlanFromLegacy($legacy, productId: 'free'); + $newPlanByProductId['__free__'] = $plan; + + $price = $newPriceByVariantId['0'] + ?? (new PlanPrice())->setVariantId('0')->setPrice(0)->setInterval(null)->setActive(true); + $price->setPlan($plan); + $plan->addPrice($price); + $newPriceByVariantId['0'] = $price; + + $oldPlanIdToNewPrice[$legacyId] = $price; + + if (! $dryRun) { + $this->em->persist($plan); + $this->em->persist($price); + $this->em->flush(); + } + + $this->moveFeatures($legacyId, $plan); + $this->io->writeln(sprintf(' - free plan "%s" → Plan(free) + PlanPrice(variantId=0)', $legacyName)); + + continue; + } + + $productInfo = $this->resolveProductForVariant($legacyPlanId, $variantIndex); + + if ($productInfo === null) { + $this->io->warning(sprintf('Legacy plan "%s" (variantId=%s) has no matching product in the payment provider; skipping.', $legacyName, $legacyPlanId)); + + continue; + } + + [$productId, $product, $variantPrice] = $productInfo; + + $plan = $newPlanByProductId[$productId] + ?? $this->createPlanFromLegacy($legacy, productId: $productId, productName: $product->name, productDescription: $product->description); + $newPlanByProductId[$productId] = $plan; + + $price = $newPriceByVariantId[$legacyPlanId] + ?? (new PlanPrice()) + ->setVariantId($legacyPlanId) + ->setPrice($variantPrice->price) + ->setInterval($variantPrice->interval) + ->setActive(true); + $price->setPlan($plan); + $plan->addPrice($price); + $newPriceByVariantId[$legacyPlanId] = $price; + + $oldPlanIdToNewPrice[$legacyId] = $price; + + if (! $dryRun) { + $this->em->persist($plan); + $this->em->persist($price); + $this->em->flush(); + } + + $this->moveFeatures($legacyId, $plan); + $this->io->writeln(sprintf(' - "%s" (variantId=%s) → Plan(productId=%s) + PlanPrice', $legacyName, $legacyPlanId, $productId)); + } + + $this->repointSubscriptions($oldPlanIdToNewPrice, $dryRun); + $this->deleteLegacyOrphans(array_keys($oldPlanIdToNewPrice), $dryRun); + + if ($dryRun) { + $this->em->rollback(); + $this->io->note('Dry run — all changes rolled back.'); + } else { + $this->em->commit(); + $this->io->success('Migration complete.'); + } + } catch (Throwable $throwable) { + $this->em->rollback(); + $this->io->error(sprintf('Migration failed: %s', $throwable->getMessage())); + + throw $throwable; + } + + return self::SUCCESS; + } + + /** + * @return array}> + */ + private function buildVariantIndex(): array + { + $index = []; + + foreach ($this->paymentIntegration->getPlans() as $product) { + $prices = []; + foreach ($product->prices as $priceDto) { + $prices[$priceDto->variantId] = $priceDto; + } + + $index[$product->id] = [ + 'product' => $product, + 'prices' => $prices, + ]; + } + + return $index; + } + + /** + * @param array}> $variantIndex + * + * @return array{0: string, 1: IntegrationProduct, 2: IntegrationProductPrice}|null + */ + private function resolveProductForVariant(string $variantId, array $variantIndex): ?array + { + foreach ($variantIndex as $productId => $entry) { + if (isset($entry['prices'][$variantId])) { + return [(string) $productId, $entry['product'], $entry['prices'][$variantId]]; + } + } + + return null; + } + + /** + * @return list> + */ + private function fetchLegacyPlans(): array + { + // Read the legacy schema directly: `price` is no longer mapped on the + // Plan entity but must still exist on disk while this command runs. + $rows = $this->connection->fetchAllAssociative( + 'SELECT id, name, description, plan_id, price, trial_duration, `default`, active FROM saas_plan' + ); + + return array_values($rows); + } + + /** + * @param array $legacy + */ + private function createPlanFromLegacy( + array $legacy, + string $productId, + ?string $productName = null, + ?string $productDescription = null, + ): Plan { + $plan = new Plan(); + $plan->setName($productName ?? $this->asString($legacy['name'])); + $plan->setDescription($productDescription ?? $this->asString($legacy['description'] ?? '')); + $plan->setPlanId($productId); + $plan->setActive((bool) $legacy['active']); + $plan->setDefault((bool) $legacy['default']); + + $trialDuration = $legacy['trial_duration'] ?? null; + if (is_string($trialDuration) && $trialDuration !== '') { + $plan->setTrialDuration(new DateInterval($trialDuration)); + } + + return $plan; + } + + private function moveFeatures(string $legacyPlanId, Plan $newPlan): void + { + // Re-parent existing PlanFeature rows from the legacy variant-row Plan + // onto the new product-row Plan. Deduplicate by featureKey so a plan + // doesn't end up with two copies of the same feature when both + // monthly and yearly variant-rows carried the same feature set. + $existingKeys = []; + $existingFeatures = $this->connection->fetchAllAssociative( + 'SELECT feature_key FROM saas_plan_feature WHERE plan_id = :plan', + [ + 'plan' => $newPlan->getId()->toBinary(), + ] + ); + foreach ($existingFeatures as $row) { + $existingKeys[$this->asString($row['feature_key'])] = true; + } + + $features = $this->connection->fetchAllAssociative( + 'SELECT id, feature_key FROM saas_plan_feature WHERE plan_id = :plan', + [ + 'plan' => $legacyPlanId, + ] + ); + + foreach ($features as $featureRow) { + $featureKey = $this->asString($featureRow['feature_key']); + $featureId = $this->asString($featureRow['id']); + + if (isset($existingKeys[$featureKey])) { + $this->connection->executeStatement( + 'DELETE FROM saas_plan_feature WHERE id = :id', + [ + 'id' => $featureId, + ] + ); + + continue; + } + + $this->connection->executeStatement( + 'UPDATE saas_plan_feature SET plan_id = :new WHERE id = :id', + [ + 'new' => $newPlan->getId()->toBinary(), + 'id' => $featureId, + ] + ); + $existingKeys[$featureKey] = true; + } + } + + /** + * @param array $oldPlanIdToNewPrice + */ + private function repointSubscriptions(array $oldPlanIdToNewPrice, bool $dryRun): void + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT id, plan_id FROM saas_subscription' + ); + + $repointed = 0; + + foreach ($rows as $row) { + $rowPlanId = $this->asString($row['plan_id']); + $rowId = $this->asString($row['id']); + $price = $oldPlanIdToNewPrice[$rowPlanId] ?? null; + + if (! $price instanceof PlanPrice) { + $this->io->warning(sprintf('Subscription %s references unknown plan_id; leaving plan_price_id unset.', bin2hex($rowId))); + + continue; + } + + if (! $dryRun) { + $this->connection->executeStatement( + 'UPDATE saas_subscription SET plan_price_id = :price WHERE id = :id', + [ + 'price' => $price->getId()->toBinary(), + 'id' => $rowId, + ] + ); + } + + ++$repointed; + } + + $this->io->writeln(sprintf('Re-pointed %d subscription row(s) to plan_price_id.', $repointed)); + } + + /** + * @param list $migratedLegacyPlanIds + */ + private function deleteLegacyOrphans(array $migratedLegacyPlanIds, bool $dryRun): void + { + if ($migratedLegacyPlanIds === []) { + return; + } + + if ($dryRun) { + $this->io->writeln(sprintf('Would delete %d legacy variant-row plan(s).', count($migratedLegacyPlanIds))); + + return; + } + + $deleted = $this->connection->executeStatement( + 'DELETE FROM saas_plan WHERE id IN (:ids)', + [ + 'ids' => $migratedLegacyPlanIds, + ], + [ + 'ids' => ArrayParameterType::STRING, + ] + ); + + $this->io->writeln(sprintf('Deleted %d legacy variant-row plan(s).', $deleted)); + } + + private function asString(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + if (is_scalar($value) || $value === null) { + return (string) $value; + } + + return ''; + } +} diff --git a/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php b/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php index 26cfc93..c863160 100644 --- a/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php +++ b/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php @@ -19,9 +19,9 @@ use Doctrine\Common\Util\ClassUtils; use Override; use SolidWorx\Platform\PlatformBundle\Console\Command; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepository; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; diff --git a/src/Bundle/Saas/Console/Command/SyncSaasPlanCommand.php b/src/Bundle/Saas/Console/Command/SyncSaasPlanCommand.php index 68883cc..ddcb4c9 100644 --- a/src/Bundle/Saas/Console/Command/SyncSaasPlanCommand.php +++ b/src/Bundle/Saas/Console/Command/SyncSaasPlanCommand.php @@ -16,7 +16,9 @@ use Override; use SolidWorx\Platform\PlatformBundle\Console\Command; use SolidWorx\Platform\SaasBundle\Entity\Plan; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; +use SolidWorx\Platform\SaasBundle\Repository\PlanPriceRepository; use SolidWorx\Platform\SaasBundle\Repository\PlanRepository; use Symfony\Component\Console\Attribute\AsCommand; @@ -26,6 +28,7 @@ final class SyncSaasPlanCommand extends Command public function __construct( private readonly PaymentIntegrationInterface $paymentIntegration, private readonly PlanRepository $planRepository, + private readonly PlanPriceRepository $planPriceRepository, ) { parent::__construct(); } @@ -33,19 +36,61 @@ public function __construct( #[Override] protected function handle(): int { - foreach ($this->paymentIntegration->getPlans() as $planInfo) { + $seenVariantIds = []; + + foreach ($this->paymentIntegration->getPlans() as $product) { $plan = $this->planRepository->findOneBy([ - 'planId' => $planInfo->id, + 'planId' => $product->id, ]) ?? new Plan(); - $plan->setName($planInfo->name); - $plan->setDescription($planInfo->description); - $plan->setPrice($planInfo->price); - $plan->setPlanId($planInfo->id); + $plan->setName($product->name); + $plan->setDescription($product->description); + $plan->setPlanId($product->id); + + foreach ($product->prices as $priceDto) { + $price = $this->planPriceRepository->findOneBy([ + 'variantId' => $priceDto->variantId, + ]) ?? new PlanPrice(); + + $price->setVariantId($priceDto->variantId); + $price->setPrice($priceDto->price); + $price->setInterval($priceDto->interval); + $price->setActive(true); + + $plan->addPrice($price); + + $seenVariantIds[] = $priceDto->variantId; + } $this->planRepository->save($plan); } + $this->deactivateStalePrices($seenVariantIds); + return self::SUCCESS; } + + /** + * @param list $seenVariantIds + */ + private function deactivateStalePrices(array $seenVariantIds): void + { + $existingPrices = $this->planPriceRepository->findAll(); + + foreach ($existingPrices as $price) { + // Preserve the local-only free price sentinel and any active price + // we just synced. Variant ids no longer in the provider response + // are flipped inactive so historical Subscription FKs stay valid. + if ($price->isFree()) { + continue; + } + if (in_array($price->getVariantId(), $seenVariantIds, true)) { + continue; + } + if ($price->isActive()) { + $price->setActive(false); + $this->planPriceRepository->save($price); + } + } + } } diff --git a/src/Bundle/Saas/DependencyInjection/SolidWorxPlatformSaasExtension.php b/src/Bundle/Saas/DependencyInjection/SolidWorxPlatformSaasExtension.php index c8debf1..2ff75b1 100644 --- a/src/Bundle/Saas/DependencyInjection/SolidWorxPlatformSaasExtension.php +++ b/src/Bundle/Saas/DependencyInjection/SolidWorxPlatformSaasExtension.php @@ -15,6 +15,7 @@ use Override; use RuntimeException; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Config\SaasConfiguration; use SolidWorx\Platform\SaasBundle\Entity\Plan; use SolidWorx\Platform\SaasBundle\Entity\PlanFeature; @@ -25,7 +26,6 @@ use SolidWorx\Platform\SaasBundle\Feature\FeatureConfigRegistry; use SolidWorx\Platform\SaasBundle\Integration\LemonSqueezy; use SolidWorx\Platform\SaasBundle\SolidWorxPlatformSaasBundle; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Trial\TrialUserInterface; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\Config\FileLocator; diff --git a/src/Bundle/Saas/Dto/IntegrationProduct.php b/src/Bundle/Saas/Dto/IntegrationProduct.php index 4481d78..2dfd1f9 100644 --- a/src/Bundle/Saas/Dto/IntegrationProduct.php +++ b/src/Bundle/Saas/Dto/IntegrationProduct.php @@ -13,16 +13,16 @@ namespace SolidWorx\Platform\SaasBundle\Dto; -use DateInterval; - final readonly class IntegrationProduct { + /** + * @param list $prices + */ public function __construct( public string $id, public string $name, public string $description, - public int $price, - public DateInterval $interval, + public array $prices, ) { } } diff --git a/src/Bundle/Saas/Dto/IntegrationProductPrice.php b/src/Bundle/Saas/Dto/IntegrationProductPrice.php new file mode 100644 index 0000000..a63fcd9 --- /dev/null +++ b/src/Bundle/Saas/Dto/IntegrationProductPrice.php @@ -0,0 +1,26 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Dto; + +use DateInterval; + +final readonly class IntegrationProductPrice +{ + public function __construct( + public string $variantId, + public int $price, + public DateInterval $interval, + ) { + } +} diff --git a/src/Bundle/Saas/Entity/Plan.php b/src/Bundle/Saas/Entity/Plan.php index 6e2d7f9..51520eb 100644 --- a/src/Bundle/Saas/Entity/Plan.php +++ b/src/Bundle/Saas/Entity/Plan.php @@ -45,8 +45,9 @@ class Plan implements Stringable private string $name; /** - * Unique value representing the plan id. This can be a unique value set by the user, - * or an external value (E.G variantId from LemonSqueezy) + * Unique value representing the plan / product id. This can be a unique + * value set by the user, or an external value (e.g. product id from + * LemonSqueezy). Variants/prices are tracked separately on PlanPrice. */ #[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: false)] private string $planId; @@ -54,9 +55,6 @@ class Plan implements Stringable #[ORM\Column(type: Types::TEXT)] private string $description = ''; - #[ORM\Column(type: Types::INTEGER)] - private int $price; - #[ORM\Column(type: Types::DATEINTERVAL, nullable: true)] private ?DateInterval $trialDuration = null; @@ -70,8 +68,11 @@ class Plan implements Stringable ])] private bool $active = true; - #[ORM\OneToMany(targetEntity: Subscription::class, mappedBy: 'plan', orphanRemoval: true)] - private Collection $subscriptions; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: PlanPrice::class, mappedBy: 'plan', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $prices; /** * @var Collection @@ -82,7 +83,7 @@ class Plan implements Stringable public function __construct() { $this->id = new NilUlid(); - $this->subscriptions = new ArrayCollection(); + $this->prices = new ArrayCollection(); $this->features = new ArrayCollection(); } @@ -119,17 +120,6 @@ public function setDescription(?string $description): static return $this; } - public function getPrice(): int - { - return $this->price; - } - - public function setPrice(int $price): static - { - $this->price = $price; - return $this; - } - public function getPlanId(): string { return $this->planId; @@ -179,20 +169,43 @@ public function setActive(bool $active): static } /** - * A plan is "free" when it has no external billing identifier and a zero - * price — these subscriptions skip checkout and activate immediately. + * A plan is "free" when any of its prices is free. Free plans skip checkout + * and activate immediately. */ public function isFree(): bool { - return $this->price === 0 && $this->planId === '0'; + foreach ($this->prices as $price) { + if ($price->isFree()) { + return true; + } + } + + return false; } /** - * @return Collection + * @return Collection */ - public function getSubscriptions(): Collection + public function getPrices(): Collection { - return $this->subscriptions; + return $this->prices; + } + + public function addPrice(PlanPrice $price): static + { + if (! $this->prices->contains($price)) { + $this->prices->add($price); + $price->setPlan($this); + } + + return $this; + } + + public function removePrice(PlanPrice $price): static + { + $this->prices->removeElement($price); + + return $this; } /** diff --git a/src/Bundle/Saas/Entity/PlanPrice.php b/src/Bundle/Saas/Entity/PlanPrice.php new file mode 100644 index 0000000..be02844 --- /dev/null +++ b/src/Bundle/Saas/Entity/PlanPrice.php @@ -0,0 +1,143 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Entity; + +use DateInterval; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use SolidWorx\Platform\SaasBundle\Repository\PlanPriceRepository; +use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator; +use Symfony\Bridge\Doctrine\Types\UlidType; +use Symfony\Component\Uid\NilUlid; +use Symfony\Component\Uid\Ulid; + +#[ORM\Entity(repositoryClass: PlanPriceRepository::class)] +#[ORM\Table(name: PlanPrice::TABLE_NAME)] +#[ORM\UniqueConstraint(fields: ['variantId'])] +#[ORM\Index(fields: ['variantId'])] +class PlanPrice +{ + final public const string TABLE_NAME = 'saas_plan_price'; + + #[ORM\Id] + #[ORM\Column(type: UlidType::NAME)] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UlidGenerator::class)] + private Ulid $id; + + #[ORM\ManyToOne(targetEntity: Plan::class, inversedBy: 'prices')] + #[ORM\JoinColumn(name: 'plan_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private Plan $plan; + + /** + * Unique value representing the price/variant id from the payment provider + * (e.g. variant id from LemonSqueezy). The sentinel value '0' represents + * the local-only free price. + */ + #[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: false)] + private string $variantId; + + #[ORM\Column(type: Types::INTEGER)] + private int $price = 0; + + /** + * Billing renewal interval. Null for free prices that never renew. + */ + #[ORM\Column(type: Types::DATEINTERVAL, nullable: true)] + private ?DateInterval $interval = null; + + #[ORM\Column(type: Types::BOOLEAN, options: [ + 'default' => true, + ])] + private bool $active = true; + + public function __construct() + { + $this->id = new NilUlid(); + } + + public function getId(): Ulid + { + return $this->id; + } + + public function getPlan(): Plan + { + return $this->plan; + } + + public function setPlan(Plan $plan): static + { + $this->plan = $plan; + + return $this; + } + + public function getVariantId(): string + { + return $this->variantId; + } + + public function setVariantId(string $variantId): static + { + $this->variantId = $variantId; + + return $this; + } + + public function getPrice(): int + { + return $this->price; + } + + public function setPrice(int $price): static + { + $this->price = $price; + + return $this; + } + + public function getInterval(): ?DateInterval + { + return $this->interval; + } + + public function setInterval(?DateInterval $interval): static + { + $this->interval = $interval; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } + + /** + * A price is "free" when it has the local sentinel variant id and a zero + * price — these subscriptions skip checkout and activate immediately. + */ + public function isFree(): bool + { + return $this->price === 0 && $this->variantId === '0'; + } +} diff --git a/src/Bundle/Saas/Entity/Subscription.php b/src/Bundle/Saas/Entity/Subscription.php index 7950959..78b3b5b 100644 --- a/src/Bundle/Saas/Entity/Subscription.php +++ b/src/Bundle/Saas/Entity/Subscription.php @@ -19,9 +19,9 @@ use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepository; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator; use Symfony\Bridge\Doctrine\Types\UlidType; use Symfony\Component\Uid\NilUlid; @@ -51,9 +51,9 @@ class Subscription #[ORM\JoinColumn(name: 'subscriber_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private SubscribableInterface $subscriber; - #[ORM\ManyToOne(targetEntity: Plan::class, inversedBy: 'subscriptions')] - #[ORM\JoinColumn(name: 'plan_id', referencedColumnName: 'id', nullable: false)] - private Plan $plan; + #[ORM\ManyToOne(targetEntity: PlanPrice::class)] + #[ORM\JoinColumn(name: 'plan_price_id', referencedColumnName: 'id', nullable: false)] + private PlanPrice $planPrice; #[ORM\Column(type: Types::STRING, length: 45, enumType: SubscriptionStatus::class)] private SubscriptionStatus $status = SubscriptionStatus::PENDING; @@ -119,18 +119,28 @@ public function setEndDate(DateTimeInterface $endDate): static return $this; } - public function getPlan(): Plan + public function getPlanPrice(): PlanPrice { - return $this->plan; + return $this->planPrice; } - public function setPlan(Plan $plan): static + public function setPlanPrice(PlanPrice $planPrice): static { - $this->plan = $plan; + $this->planPrice = $planPrice; return $this; } + /** + * Convenience accessor returning the plan that owns this subscription's + * price. Subscriptions are tied to a specific PlanPrice (variant); the + * plan is reachable via that relationship. + */ + public function getPlan(): Plan + { + return $this->planPrice->getPlan(); + } + public function getSubscriber(): SubscribableInterface { return $this->subscriber; diff --git a/src/Bundle/Saas/Feature/PlanFeatureGate.php b/src/Bundle/Saas/Feature/PlanFeatureGate.php index f35ba89..0cfd165 100644 --- a/src/Bundle/Saas/Feature/PlanFeatureGate.php +++ b/src/Bundle/Saas/Feature/PlanFeatureGate.php @@ -35,9 +35,9 @@ public function resolve(string $featureKey, ?SubscribableInterface $for = null): { $for ??= $this->resolver->resolve(); - return $for === null - ? $this->manager->getConfigDefault($featureKey) - : $this->manager->getFeatureForSubscriber($for, $featureKey); + return $for instanceof SubscribableInterface + ? $this->manager->getFeatureForSubscriber($for, $featureKey) + : $this->manager->getConfigDefault($featureKey); } #[Override] diff --git a/src/Bundle/Saas/Feature/PlanFeatureManager.php b/src/Bundle/Saas/Feature/PlanFeatureManager.php index dd10ad6..2a70498 100644 --- a/src/Bundle/Saas/Feature/PlanFeatureManager.php +++ b/src/Bundle/Saas/Feature/PlanFeatureManager.php @@ -15,14 +15,14 @@ use InvalidArgumentException; use Override; +use SolidWorx\Platform\PlatformBundle\Feature\FeatureType; +use SolidWorx\Platform\PlatformBundle\Feature\FeatureValue; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Entity\Plan; use SolidWorx\Platform\SaasBundle\Entity\PlanFeature; use SolidWorx\Platform\SaasBundle\Entity\Subscription; -use SolidWorx\Platform\PlatformBundle\Feature\FeatureType; -use SolidWorx\Platform\PlatformBundle\Feature\FeatureValue; use SolidWorx\Platform\SaasBundle\Exception\UndefinedFeatureException; use SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Subscription\SubscriptionProviderInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Service\ResetInterface; diff --git a/src/Bundle/Saas/Feature/PlanFeatureToggle.php b/src/Bundle/Saas/Feature/PlanFeatureToggle.php index 9225560..a1428de 100644 --- a/src/Bundle/Saas/Feature/PlanFeatureToggle.php +++ b/src/Bundle/Saas/Feature/PlanFeatureToggle.php @@ -14,8 +14,8 @@ namespace SolidWorx\Platform\SaasBundle\Feature; use Override; -use SolidWorx\Platform\SaasBundle\Exception\UndefinedFeatureException; use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; +use SolidWorx\Platform\SaasBundle\Exception\UndefinedFeatureException; /** * Default implementation of FeatureToggleInterface using PlanFeatureManager. diff --git a/src/Bundle/Saas/Integration/LemonSqueezy.php b/src/Bundle/Saas/Integration/LemonSqueezy.php index 558794d..22446bf 100644 --- a/src/Bundle/Saas/Integration/LemonSqueezy.php +++ b/src/Bundle/Saas/Integration/LemonSqueezy.php @@ -17,6 +17,7 @@ use InvalidArgumentException; use Override; use SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct; +use SolidWorx\Platform\SaasBundle\Dto\IntegrationProductPrice; use SolidWorx\Platform\SaasBundle\Entity\Subscription; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpClient\HttpClient; @@ -85,6 +86,8 @@ public function getPlans(): iterable $variantsData = $variants->toArray(); + $prices = []; + foreach ($variantsData['data'] as $variant) { $priceModel = $variant['relationships']['price-model']['links']['related'] ?? null; @@ -99,22 +102,45 @@ public function getPlans(): iterable $priceModelData = $priceModelResponse->toArray(); + $unitPrice = $priceModelData['data']['attributes']['unit_price'] ?? 0; + + // Skip the LS auto-generated default variant: it has no real + // price model / zero unit price and should not surface as a + // billable option. + if ((int) $unitPrice === 0) { + continue; + } + + $intervalQuantity = $priceModelData['data']['attributes']['renewal_interval_quantity'] ?? null; + $intervalUnit = $priceModelData['data']['attributes']['renewal_interval_unit'] ?? null; + if ($intervalQuantity === null) { + continue; + } + if ($intervalUnit === null) { + continue; + } + $interval = CarbonInterval::fromString( - sprintf( - '%s %s', - $priceModelData['data']['attributes']['renewal_interval_quantity'], - $priceModelData['data']['attributes']['renewal_interval_unit'], - ) + sprintf('%s %s', $intervalQuantity, $intervalUnit) ); - yield new IntegrationProduct( - id: $variant['id'], - name: $attributes['name'], - description: $attributes['description'], - price: $priceModelData['data']['attributes']['unit_price'], + $prices[] = new IntegrationProductPrice( + variantId: (string) $variant['id'], + price: (int) $unitPrice, interval: $interval, ); } + + if ($prices === []) { + continue; + } + + yield new IntegrationProduct( + id: (string) $product['id'], + name: $attributes['name'], + description: $attributes['description'] ?? '', + prices: $prices, + ); } } @@ -159,7 +185,7 @@ public function checkout(Subscription $subscription, ?Options $options = null): 'variant' => [ 'data' => [ 'type' => 'variants', - 'id' => $subscription->getPlan()->getPlanId(), + 'id' => $subscription->getPlanPrice()->getVariantId(), ], ], ], diff --git a/src/Bundle/Saas/Repository/PlanPriceRepository.php b/src/Bundle/Saas/Repository/PlanPriceRepository.php new file mode 100644 index 0000000..64176d6 --- /dev/null +++ b/src/Bundle/Saas/Repository/PlanPriceRepository.php @@ -0,0 +1,51 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Repository; + +use Doctrine\DBAL\LockMode; +use Doctrine\Persistence\ManagerRegistry; +use Override; +use SolidWorx\Platform\PlatformBundle\Repository\EntityRepository; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; +use Symfony\Component\Uid\Ulid; + +/** + * @template-extends EntityRepository + */ +class PlanPriceRepository extends EntityRepository implements PlanPriceRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PlanPrice::class); + } + + /** + * @param string|PlanPrice|Ulid $id + */ + #[Override] + public function find(mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): ?PlanPrice + { + if ($id instanceof PlanPrice) { + return $id; + } + + if (is_string($id)) { + return $this->findOneBy([ + 'variantId' => $id, + ]); + } + + return parent::find($id, $lockMode instanceof LockMode ? null : $lockMode, $lockVersion); + } +} diff --git a/src/Bundle/Saas/Repository/PlanPriceRepositoryInterface.php b/src/Bundle/Saas/Repository/PlanPriceRepositoryInterface.php new file mode 100644 index 0000000..f7c6d00 --- /dev/null +++ b/src/Bundle/Saas/Repository/PlanPriceRepositoryInterface.php @@ -0,0 +1,26 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace SolidWorx\Platform\SaasBundle\Repository; + +use Doctrine\DBAL\LockMode; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; +use Symfony\Component\Uid\Ulid; + +interface PlanPriceRepositoryInterface +{ + /** + * @param string|PlanPrice|Ulid $id + */ + public function find(mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): ?PlanPrice; +} diff --git a/src/Bundle/Saas/Repository/PlanRepository.php b/src/Bundle/Saas/Repository/PlanRepository.php index 05deacb..c75bc72 100644 --- a/src/Bundle/Saas/Repository/PlanRepository.php +++ b/src/Bundle/Saas/Repository/PlanRepository.php @@ -13,6 +13,7 @@ namespace SolidWorx\Platform\SaasBundle\Repository; +use Doctrine\DBAL\LockMode; use Doctrine\Persistence\ManagerRegistry; use Override; use SolidWorx\Platform\PlatformBundle\Repository\EntityRepository; @@ -33,15 +34,19 @@ public function __construct(ManagerRegistry $registry) * @param string|Plan|Ulid $id */ #[Override] - public function find(mixed $id, $lockMode = null, $lockVersion = null): ?Plan + public function find(mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): ?Plan { - return match (get_debug_type($id)) { - Plan::class => $id, - 'string' => $this->findOneBy([ + if ($id instanceof Plan) { + return $id; + } + + if (is_string($id)) { + return $this->findOneBy([ 'planId' => $id, - ]), - default => parent::find($id, $lockMode, $lockVersion), - }; + ]); + } + + return parent::find($id, $lockMode instanceof LockMode ? null : $lockMode, $lockVersion); } /** @@ -49,43 +54,35 @@ public function find(mixed $id, $lockMode = null, $lockVersion = null): ?Plan * is explicitly flagged. Used during signup and when more than one plan * is configured to highlight a recommended option. */ + #[Override] public function findDefault(): ?Plan { - $default = $this->createQueryBuilder('p') - ->where('p.default = :default') - ->andWhere('p.active = :active') - ->setParameter('default', true) - ->setParameter('active', true) - ->orderBy('p.price', 'ASC') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); + $default = $this->findOneBy([ + 'default' => true, + 'active' => true, + ]); if ($default instanceof Plan) { return $default; } - return $this->createQueryBuilder('p') - ->where('p.active = :active') - ->setParameter('active', true) - ->orderBy('p.price', 'ASC') - ->addOrderBy('p.name', 'ASC') - ->setMaxResults(1) - ->getQuery() - ->getOneOrNullResult(); + return $this->findOneBy([ + 'active' => true, + ], [ + 'name' => 'ASC', + ]); } /** * @return list */ + #[Override] public function findAllOrdered(): array { - return $this->createQueryBuilder('p') - ->where('p.active = :active') - ->setParameter('active', true) - ->orderBy('p.price', 'ASC') - ->addOrderBy('p.name', 'ASC') - ->getQuery() - ->getResult(); + return array_values($this->findBy([ + 'active' => true, + ], [ + 'name' => 'ASC', + ])); } } diff --git a/src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php b/src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php index 7230ae4..f7d8071 100644 --- a/src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php +++ b/src/Bundle/Saas/Security/Voter/PlanFeatureVoter.php @@ -14,8 +14,8 @@ namespace SolidWorx\Platform\SaasBundle\Security\Voter; use Override; -use SolidWorx\Platform\SaasBundle\Feature\PlanFeatureManager; use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; +use SolidWorx\Platform\SaasBundle\Feature\PlanFeatureManager; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; diff --git a/src/Bundle/Saas/Subscription/SubscriptionManager.php b/src/Bundle/Saas/Subscription/SubscriptionManager.php index 71d61bb..b66fe1d 100644 --- a/src/Bundle/Saas/Subscription/SubscriptionManager.php +++ b/src/Bundle/Saas/Subscription/SubscriptionManager.php @@ -19,7 +19,7 @@ use DateTimeInterface; use Override; use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; -use SolidWorx\Platform\SaasBundle\Entity\Plan; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; use SolidWorx\Platform\SaasBundle\Entity\Subscription; use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; use SolidWorx\Platform\SaasBundle\Exception\ActiveSubscriptionPlanChangeException; @@ -27,16 +27,15 @@ use SolidWorx\Platform\SaasBundle\Exception\TrialConfigurationException; use SolidWorx\Platform\SaasBundle\Integration\Options; use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; -use SolidWorx\Platform\SaasBundle\Repository\PlanRepositoryInterface; +use SolidWorx\Platform\SaasBundle\Repository\PlanPriceRepositoryInterface; use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepositoryInterface; use Symfony\Component\Uid\Ulid; -use function get_debug_type; final readonly class SubscriptionManager implements SubscriptionProviderInterface { public function __construct( private SubscriptionRepositoryInterface $subscriptionRepository, - private PlanRepositoryInterface $planRepository, + private PlanPriceRepositoryInterface $planPriceRepository, private PaymentIntegrationInterface $paymentIntegration, ) { } @@ -51,19 +50,18 @@ public function getSubscriptionFor(SubscribableInterface $subscriber): ?Subscrip public function createSubscription( SubscribableInterface $subscribable, - Plan|Ulid|string $planId, + PlanPrice|Ulid|string $priceId, ): Subscription { - $plan = $this->planRepository->find($planId); - - if (! $plan instanceof Plan) { - $planIdString = match (get_debug_type($planId)) { - 'string' => $planId, - Ulid::class => $planId->toBase58(), - Plan::class => $planId->getPlanId(), - default => (string) $planId, + $planPrice = $this->planPriceRepository->find($priceId); + + 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); } $subscription = new Subscription(); @@ -71,7 +69,7 @@ public function createSubscription( $subscription->setStatus(SubscriptionStatus::PENDING); $subscription->setStartDate(new DateTime('NOW')); $subscription->setEndDate((new DateTime('NOW'))); - $subscription->setPlan($plan); + $subscription->setPlanPrice($planPrice); $this->subscriptionRepository->save($subscription); @@ -157,19 +155,19 @@ public function markAsUnpaid(Subscription $subscription): void } /** - * Swap the plan on a subscription that has not yet been activated. Plan - * changes for ACTIVE subscriptions go through the payment integration and - * are intentionally not handled here. + * Swap the price (variant) on a subscription that has not yet been + * activated. Plan changes for ACTIVE subscriptions go through the payment + * 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 { if ($subscription->getStatus() === SubscriptionStatus::ACTIVE) { throw new ActiveSubscriptionPlanChangeException($subscription); } - $subscription->setPlan($plan); + $subscription->setPlanPrice($planPrice); $this->subscriptionRepository->save($subscription); } diff --git a/src/Bundle/Saas/Subscription/SubscriptionProviderInterface.php b/src/Bundle/Saas/Subscription/SubscriptionProviderInterface.php index 0ebf460..38ca5e2 100644 --- a/src/Bundle/Saas/Subscription/SubscriptionProviderInterface.php +++ b/src/Bundle/Saas/Subscription/SubscriptionProviderInterface.php @@ -13,8 +13,8 @@ namespace SolidWorx\Platform\SaasBundle\Subscription; -use SolidWorx\Platform\SaasBundle\Entity\Subscription; use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; +use SolidWorx\Platform\SaasBundle\Entity\Subscription; /** * Interface for retrieving subscriptions. diff --git a/tests/Bundle/PlatformBundle/Feature/NoopFeatureGateTest.php b/tests/Bundle/PlatformBundle/Feature/NoopFeatureGateTest.php index 4d95868..cd72b88 100644 --- a/tests/Bundle/PlatformBundle/Feature/NoopFeatureGateTest.php +++ b/tests/Bundle/PlatformBundle/Feature/NoopFeatureGateTest.php @@ -71,6 +71,6 @@ public function testUpgradeOptionsAlwaysEmpty(): void private function subscriber(): SubscribableInterface { - return new class implements SubscribableInterface {}; + return new class() implements SubscribableInterface {}; } } diff --git a/tests/Bundle/Saas/Config/SaasConfigurationTest.php b/tests/Bundle/Saas/Config/SaasConfigurationTest.php index 673fe3b..67d0047 100644 --- a/tests/Bundle/Saas/Config/SaasConfigurationTest.php +++ b/tests/Bundle/Saas/Config/SaasConfigurationTest.php @@ -16,13 +16,13 @@ use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Config\SaasConfiguration; use SolidWorx\Platform\SaasBundle\Entity\Plan; use SolidWorx\Platform\SaasBundle\Entity\PlanFeature; use SolidWorx\Platform\SaasBundle\Entity\Subscription; use SolidWorx\Platform\SaasBundle\Entity\SubscriptionLog; use SolidWorx\Platform\SaasBundle\Entity\Trial; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Trial\TrialUserInterface; use Symfony\Component\Config\Definition\ArrayNode; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; diff --git a/tests/Bundle/Saas/Feature/PlanFeatureGateTest.php b/tests/Bundle/Saas/Feature/PlanFeatureGateTest.php index e1ebcea..fdda7de 100644 --- a/tests/Bundle/Saas/Feature/PlanFeatureGateTest.php +++ b/tests/Bundle/Saas/Feature/PlanFeatureGateTest.php @@ -30,7 +30,9 @@ #[CoversClass(PlanFeatureGate::class)] final class PlanFeatureGateTest extends TestCase { - /** @var PlanFeatureManager&MockObject */ + /** + * @var PlanFeatureManager&MockObject + */ private PlanFeatureManager $manager; protected function setUp(): void @@ -147,6 +149,6 @@ public function testUpgradeOptionsMapsPlansToReferences(): void private function subscriber(): SubscribableInterface { - return new class implements SubscribableInterface {}; + return new class() implements SubscribableInterface {}; } } diff --git a/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php b/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php index f67206f..4342b02 100644 --- a/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php +++ b/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php @@ -13,19 +13,21 @@ namespace SolidWorx\Platform\Tests\Bundle\Saas\Feature; +use DateInterval; use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use ReflectionClass; +use SolidWorx\Platform\PlatformBundle\Feature\FeatureType; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Entity\Plan; use SolidWorx\Platform\SaasBundle\Entity\PlanFeature; +use SolidWorx\Platform\SaasBundle\Entity\PlanPrice; use SolidWorx\Platform\SaasBundle\Entity\Subscription; -use SolidWorx\Platform\PlatformBundle\Feature\FeatureType; use SolidWorx\Platform\SaasBundle\Exception\UndefinedFeatureException; use SolidWorx\Platform\SaasBundle\Feature\FeatureConfigRegistry; use SolidWorx\Platform\SaasBundle\Feature\PlanFeatureManager; use SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Subscription\SubscriptionProviderInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Uid\Ulid; @@ -282,19 +284,31 @@ private function createPlan(): Plan $plan = new Plan(); $plan->setName('Test Plan'); $plan->setPlanId('test-plan'); - $plan->setPrice(1000); $reflection = new ReflectionClass($plan); $property = $reflection->getProperty('id'); $property->setValue($plan, new Ulid()); + $price = new PlanPrice(); + $price->setVariantId('test-variant'); + $price->setPrice(1000); + $price->setInterval(new DateInterval('P1M')); + $plan->addPrice($price); + + $priceReflection = new ReflectionClass($price); + $priceIdProperty = $priceReflection->getProperty('id'); + $priceIdProperty->setValue($price, new Ulid()); + return $plan; } private function createSubscription(Plan $plan): Subscription { + $price = $plan->getPrices()->first(); + assert($price instanceof PlanPrice); + $subscription = new Subscription(); - $subscription->setPlan($plan); + $subscription->setPlanPrice($price); return $subscription; } diff --git a/tests/Bundle/Saas/Security/Voter/PlanFeatureVoterTest.php b/tests/Bundle/Saas/Security/Voter/PlanFeatureVoterTest.php index 487edbe..9af2540 100644 --- a/tests/Bundle/Saas/Security/Voter/PlanFeatureVoterTest.php +++ b/tests/Bundle/Saas/Security/Voter/PlanFeatureVoterTest.php @@ -18,9 +18,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use SolidWorx\Platform\SaasBundle\Feature\PlanFeatureManager; use SolidWorx\Platform\SaasBundle\Security\Voter\PlanFeatureVoter; -use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface; diff --git a/tests/Bundle/Saas/Subscription/SubscriptionManagerStartTrialTest.php b/tests/Bundle/Saas/Subscription/SubscriptionManagerStartTrialTest.php index cf918d8..35cb0e0 100644 --- a/tests/Bundle/Saas/Subscription/SubscriptionManagerStartTrialTest.php +++ b/tests/Bundle/Saas/Subscription/SubscriptionManagerStartTrialTest.php @@ -23,7 +23,7 @@ use SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus; use SolidWorx\Platform\SaasBundle\Exception\TrialConfigurationException; use SolidWorx\Platform\SaasBundle\Integration\PaymentIntegrationInterface; -use SolidWorx\Platform\SaasBundle\Repository\PlanRepositoryInterface; +use SolidWorx\Platform\SaasBundle\Repository\PlanPriceRepositoryInterface; use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepositoryInterface; use SolidWorx\Platform\SaasBundle\Subscription\SubscriptionManager; use Symfony\Component\Uid\Ulid; @@ -41,7 +41,7 @@ protected function setUp(): void $this->manager = new SubscriptionManager( $this->subscriptionRepository, - $this->createMock(PlanRepositoryInterface::class), + $this->createMock(PlanPriceRepositoryInterface::class), $this->createMock(PaymentIntegrationInterface::class), ); }