This commit is contained in:
Mateusz 2026-06-27 23:01:42 +02:00 committed by GitHub
commit 0793ccca9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 370 additions and 0 deletions

View file

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

View file

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

View file

@ -0,0 +1,3 @@
<div class="col-12">
{{ form_row(hookable_metadata.context.form.showPricesIncludingTax) }}
</div>

View file

@ -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,

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractMigration;
final class Version20260618100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add show_prices_including_tax column to sylius_channel table';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View file

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractPostgreSQLMigration;
final class Version20260618100001 extends AbstractPostgreSQLMigration
{
public function getDescription(): string
{
return 'Add show_prices_including_tax column to sylius_channel table (PostgreSQL)';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View file

@ -29,6 +29,11 @@
<option name="default">0</option>
</options>
</field>
<field name="showPricesIncludingTax" column="show_prices_including_tax" type="boolean">
<options>
<option name="default">0</option>
</options>
</field>
<many-to-one field="defaultLocale" target-entity="Sylius\Component\Locale\Model\LocaleInterface" fetch="EAGER">
<join-column name="default_locale_id" referenced-column-name="id" nullable="false" />

View file

@ -178,6 +178,16 @@
</service>
<service id="Sylius\Component\Core\Calculator\ProductVariantPricesCalculatorInterface" alias="sylius.calculator.product_variant_price" />
<service
id="sylius.calculator.product_variant_price.tax_inclusive"
class="Sylius\Component\Core\Calculator\TaxInclusivePricesCalculator"
decorates="sylius.calculator.product_variant_price"
>
<argument type="service" id="sylius.calculator.product_variant_price.tax_inclusive.inner" />
<argument type="service" id="sylius.resolver.tax_rate" />
<argument type="service" id="sylius.tax_calculator" />
</service>
<service id="sylius.section_resolver.uri_based" class="Sylius\Bundle\CoreBundle\SectionResolver\UriBasedSectionProvider">
<argument type="service" id="request_stack" />
<argument type="collection" />

View file

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

View file

@ -0,0 +1,80 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Calculator;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Taxation\Calculator\CalculatorInterface;
use Sylius\Component\Taxation\Resolver\TaxRateResolverInterface;
final class TaxInclusivePricesCalculator implements ProductVariantPricesCalculatorInterface
{
public function __construct(
private readonly ProductVariantPricesCalculatorInterface $inner,
private readonly TaxRateResolverInterface $taxRateResolver,
private readonly CalculatorInterface $taxCalculator,
) {
}
public function calculate(ProductVariantInterface $productVariant, array $context): int
{
return $this->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<string, mixed> $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));
}
}

View file

@ -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());

View file

@ -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;

View file

@ -0,0 +1,175 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Component\Core\Calculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Component\Addressing\Model\ZoneInterface;
use Sylius\Component\Core\Calculator\ProductVariantPricesCalculatorInterface;
use Sylius\Component\Core\Calculator\TaxInclusivePricesCalculator;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Taxation\Calculator\CalculatorInterface;
use Sylius\Component\Taxation\Model\TaxRateInterface;
use Sylius\Component\Taxation\Resolver\TaxRateResolverInterface;
#[CoversClass(TaxInclusivePricesCalculator::class)]
final class TaxInclusivePricesCalculatorTest extends TestCase
{
private MockObject&ProductVariantPricesCalculatorInterface $inner;
private MockObject&TaxRateResolverInterface $taxRateResolver;
private CalculatorInterface&MockObject $taxCalculator;
private MockObject&ProductVariantInterface $productVariant;
private ChannelInterface&MockObject $channel;
private TaxInclusivePricesCalculator $calculator;
protected function setUp(): void
{
$this->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);
}
}