diff --git a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/create.yaml b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/create.yaml index a9b633faef..79bce3076c 100644 --- a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/create.yaml +++ b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/create.yaml @@ -81,6 +81,9 @@ sylius_twig_hooks: tax_calculation_strategy: template: '@SyliusAdmin/channel/form/sections/money/tax_calculation_strategy.html.twig' priority: 200 + show_prices_including_tax: + template: '@SyliusAdmin/channel/form/sections/money/show_prices_including_tax.html.twig' + priority: 150 currencies: template: '@SyliusAdmin/channel/form/sections/money/currencies.html.twig' priority: 100 diff --git a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/update.yaml b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/update.yaml index 99d42ccc58..c99cce7fc4 100644 --- a/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/update.yaml +++ b/src/Sylius/Bundle/AdminBundle/Resources/config/app/twig_hooks/channel/update.yaml @@ -83,6 +83,9 @@ sylius_twig_hooks: tax_calculation_strategy: template: '@SyliusAdmin/channel/form/sections/money/tax_calculation_strategy.html.twig' priority: 200 + show_prices_including_tax: + template: '@SyliusAdmin/channel/form/sections/money/show_prices_including_tax.html.twig' + priority: 150 currencies: template: '@SyliusAdmin/channel/form/sections/money/currencies.html.twig' priority: 100 diff --git a/src/Sylius/Bundle/AdminBundle/templates/channel/form/sections/money/show_prices_including_tax.html.twig b/src/Sylius/Bundle/AdminBundle/templates/channel/form/sections/money/show_prices_including_tax.html.twig new file mode 100644 index 0000000000..322ae06486 --- /dev/null +++ b/src/Sylius/Bundle/AdminBundle/templates/channel/form/sections/money/show_prices_including_tax.html.twig @@ -0,0 +1,3 @@ +
+ {{ form_row(hookable_metadata.context.form.showPricesIncludingTax) }} +
diff --git a/src/Sylius/Bundle/CoreBundle/Form/Extension/ChannelTypeExtension.php b/src/Sylius/Bundle/CoreBundle/Form/Extension/ChannelTypeExtension.php index e9c2eb3291..a4c656ef23 100644 --- a/src/Sylius/Bundle/CoreBundle/Form/Extension/ChannelTypeExtension.php +++ b/src/Sylius/Bundle/CoreBundle/Form/Extension/ChannelTypeExtension.php @@ -68,6 +68,10 @@ final class ChannelTypeExtension extends AbstractTypeExtension ->add('taxCalculationStrategy', TaxCalculationStrategyChoiceType::class, [ 'label' => 'sylius.form.channel.tax_calculation_strategy', ]) + ->add('showPricesIncludingTax', CheckboxType::class, [ + 'label' => 'sylius.form.channel.show_prices_including_tax', + 'required' => false, + ]) ->add('contactEmail', EmailType::class, [ 'label' => 'sylius.form.channel.contact_email', 'required' => false, diff --git a/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100000.php b/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100000.php new file mode 100644 index 0000000000..a8ff798a7e --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100000.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE sylius_channel ADD show_prices_including_tax TINYINT(1) NOT NULL DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_channel DROP show_prices_including_tax'); + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100001.php b/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100001.php new file mode 100644 index 0000000000..003e1037cb --- /dev/null +++ b/src/Sylius/Bundle/CoreBundle/Migrations/Version20260618100001.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE sylius_channel ADD show_prices_including_tax BOOLEAN NOT NULL DEFAULT false'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_channel DROP COLUMN show_prices_including_tax'); + } +} diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Channel.orm.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Channel.orm.xml index 83eba4b347..61bb0c2435 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Channel.orm.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/doctrine/model/Channel.orm.xml @@ -29,6 +29,11 @@ + + + + + diff --git a/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml b/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml index 6899394a2a..2abd73078d 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml +++ b/src/Sylius/Bundle/CoreBundle/Resources/config/services.xml @@ -178,6 +178,16 @@ + + + + + + diff --git a/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml b/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml index 4c97e99907..c9129f7f85 100644 --- a/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml +++ b/src/Sylius/Bundle/CoreBundle/Resources/translations/messages.en.yml @@ -105,6 +105,7 @@ sylius: shop_billing_data: Shop billing data skipping_shipping_step_allowed: Skip shipping step if only one shipping method is available? skipping_payment_step_allowed: Skip payment step if only one payment method is available? + show_prices_including_tax: Show prices including tax in catalog tax_calculation_strategy: Tax calculation strategy tax_zone_default: Default tax zone taxonomies: Taxonomies diff --git a/src/Sylius/Component/Core/Calculator/TaxInclusivePricesCalculator.php b/src/Sylius/Component/Core/Calculator/TaxInclusivePricesCalculator.php new file mode 100644 index 0000000000..f8ad9510ce --- /dev/null +++ b/src/Sylius/Component/Core/Calculator/TaxInclusivePricesCalculator.php @@ -0,0 +1,80 @@ +applyTax( + $this->inner->calculate($productVariant, $context), + $productVariant, + $context, + ); + } + + public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int + { + return $this->applyTax( + $this->inner->calculateOriginal($productVariant, $context), + $productVariant, + $context, + ); + } + + public function calculateLowestPriceBeforeDiscount(ProductVariantInterface $productVariant, array $context): ?int + { + $price = $this->inner->calculateLowestPriceBeforeDiscount($productVariant, $context); + if (null === $price) { + return null; + } + + return $this->applyTax($price, $productVariant, $context); + } + + /** @param array $context */ + private function applyTax(int $price, ProductVariantInterface $productVariant, array $context): int + { + /** @var ChannelInterface $channel */ + $channel = $context['channel']; + + if (!$channel->isShowPricesIncludingTax()) { + return $price; + } + + $zone = $channel->getDefaultTaxZone(); + if (null === $zone) { + return $price; + } + + $taxRate = $this->taxRateResolver->resolve($productVariant, ['zone' => $zone]); + if (null === $taxRate || $taxRate->isIncludedInPrice()) { + return $price; + } + + return $price + (int) round($this->taxCalculator->calculate((float) $price, $taxRate)); + } +} diff --git a/src/Sylius/Component/Core/Model/Channel.php b/src/Sylius/Component/Core/Model/Channel.php index 00fa43bb7b..fe09f95f82 100644 --- a/src/Sylius/Component/Core/Model/Channel.php +++ b/src/Sylius/Component/Core/Model/Channel.php @@ -73,6 +73,8 @@ class Channel extends BaseChannel implements ChannelInterface protected ?ChannelPriceHistoryConfigInterface $channelPriceHistoryConfig = null; + protected bool $showPricesIncludingTax = false; + public function __construct() { parent::__construct(); @@ -293,6 +295,16 @@ class Channel extends BaseChannel implements ChannelInterface $this->channelPriceHistoryConfig = $channelPriceHistoryConfig; } + public function isShowPricesIncludingTax(): bool + { + return $this->showPricesIncludingTax; + } + + public function setShowPricesIncludingTax(bool $showPricesIncludingTax): void + { + $this->showPricesIncludingTax = $showPricesIncludingTax; + } + public function getEnabledCountries(): Collection { return $this->getCountries()->filter(fn (CountryInterface $country): bool => true === $country->isEnabled()); diff --git a/src/Sylius/Component/Core/Model/ChannelInterface.php b/src/Sylius/Component/Core/Model/ChannelInterface.php index 8f6e34f138..52c641c16a 100644 --- a/src/Sylius/Component/Core/Model/ChannelInterface.php +++ b/src/Sylius/Component/Core/Model/ChannelInterface.php @@ -91,6 +91,10 @@ interface ChannelInterface extends public function hasCountry(CountryInterface $country): bool; + public function isShowPricesIncludingTax(): bool; + + public function setShowPricesIncludingTax(bool $showPricesIncludingTax): void; + public function setChannelPriceHistoryConfig(ChannelPriceHistoryConfigInterface $channelPriceHistoryConfig): void; public function getChannelPriceHistoryConfig(): ?ChannelPriceHistoryConfigInterface; diff --git a/src/Sylius/Component/Core/tests/Calculator/TaxInclusivePricesCalculatorTest.php b/src/Sylius/Component/Core/tests/Calculator/TaxInclusivePricesCalculatorTest.php new file mode 100644 index 0000000000..ee6fe054d6 --- /dev/null +++ b/src/Sylius/Component/Core/tests/Calculator/TaxInclusivePricesCalculatorTest.php @@ -0,0 +1,175 @@ +inner = $this->createMock(ProductVariantPricesCalculatorInterface::class); + $this->taxRateResolver = $this->createMock(TaxRateResolverInterface::class); + $this->taxCalculator = $this->createMock(CalculatorInterface::class); + $this->productVariant = $this->createMock(ProductVariantInterface::class); + $this->channel = $this->createMock(ChannelInterface::class); + $this->calculator = new TaxInclusivePricesCalculator( + $this->inner, + $this->taxRateResolver, + $this->taxCalculator, + ); + } + + public function testItImplementsInterface(): void + { + $this->assertInstanceOf(ProductVariantPricesCalculatorInterface::class, $this->calculator); + } + + public function testItReturnsPriceUnchangedWhenFlagIsDisabled(): void + { + $this->channel->method('isShowPricesIncludingTax')->willReturn(false); + $this->inner->method('calculate')->willReturn(10000); + $this->taxRateResolver->expects($this->never())->method('resolve'); + + $result = $this->calculator->calculate($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(10000, $result); + } + + public function testItReturnsPriceUnchangedWhenNoDefaultTaxZone(): void + { + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn(null); + $this->inner->method('calculate')->willReturn(10000); + $this->taxRateResolver->expects($this->never())->method('resolve'); + + $result = $this->calculator->calculate($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(10000, $result); + } + + public function testItReturnsPriceUnchangedWhenNoTaxRateResolved(): void + { + $zone = $this->createMock(ZoneInterface::class); + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn($zone); + $this->inner->method('calculate')->willReturn(10000); + $this->taxRateResolver->method('resolve')->willReturn(null); + + $result = $this->calculator->calculate($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(10000, $result); + } + + public function testItReturnsPriceUnchangedWhenTaxRateIsIncludedInPrice(): void + { + $zone = $this->createMock(ZoneInterface::class); + $taxRate = $this->createMock(TaxRateInterface::class); + $taxRate->method('isIncludedInPrice')->willReturn(true); + + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn($zone); + $this->inner->method('calculate')->willReturn(10000); + $this->taxRateResolver->method('resolve')->willReturn($taxRate); + $this->taxCalculator->expects($this->never())->method('calculate'); + + $result = $this->calculator->calculate($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(10000, $result); + } + + public function testItAddsTaxToPriceWhenFlagIsEnabled(): void + { + $zone = $this->createMock(ZoneInterface::class); + $taxRate = $this->createMock(TaxRateInterface::class); + $taxRate->method('isIncludedInPrice')->willReturn(false); + + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn($zone); + $this->inner->method('calculate')->willReturn(10000); + $this->taxRateResolver->method('resolve')->willReturn($taxRate); + $this->taxCalculator->method('calculate')->with(10000.0, $taxRate)->willReturn(2300.0); + + $result = $this->calculator->calculate($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(12300, $result); + } + + public function testItAddsTaxToOriginalPrice(): void + { + $zone = $this->createMock(ZoneInterface::class); + $taxRate = $this->createMock(TaxRateInterface::class); + $taxRate->method('isIncludedInPrice')->willReturn(false); + + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn($zone); + $this->inner->method('calculateOriginal')->willReturn(12000); + $this->taxRateResolver->method('resolve')->willReturn($taxRate); + $this->taxCalculator->method('calculate')->with(12000.0, $taxRate)->willReturn(2760.0); + + $result = $this->calculator->calculateOriginal($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(14760, $result); + } + + public function testItReturnsNullForLowestPriceWhenInnerReturnsNull(): void + { + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->inner->method('calculateLowestPriceBeforeDiscount')->willReturn(null); + + $result = $this->calculator->calculateLowestPriceBeforeDiscount($this->productVariant, ['channel' => $this->channel]); + + $this->assertNull($result); + } + + public function testItAddsTaxToLowestPrice(): void + { + $zone = $this->createMock(ZoneInterface::class); + $taxRate = $this->createMock(TaxRateInterface::class); + $taxRate->method('isIncludedInPrice')->willReturn(false); + + $this->channel->method('isShowPricesIncludingTax')->willReturn(true); + $this->channel->method('getDefaultTaxZone')->willReturn($zone); + $this->inner->method('calculateLowestPriceBeforeDiscount')->willReturn(9000); + $this->taxRateResolver->method('resolve')->willReturn($taxRate); + $this->taxCalculator->method('calculate')->with(9000.0, $taxRate)->willReturn(2070.0); + + $result = $this->calculator->calculateLowestPriceBeforeDiscount($this->productVariant, ['channel' => $this->channel]); + + $this->assertSame(11070, $result); + } +}