From 11758c662ea7d595de37b13444e906e3f51d4716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 22 Jun 2026 14:43:00 +0200 Subject: [PATCH 1/2] Refactor `UnitNetPriceProvider` to handle taxes included in prices and ensure compatibility with tax calculators. Updated tests and added fallback for cases without a calculator. --- config/services.xml | 4 +- ...ed_in_price_and_promotions_applied.feature | 2 +- src/Provider/UnitNetPriceProvider.php | 34 +++++- .../Provider/UnitNetPriceProviderTest.php | 104 +++++++++++++++++- 4 files changed, 139 insertions(+), 5 deletions(-) diff --git a/config/services.xml b/config/services.xml index 47ea616a..f66b2c67 100644 --- a/config/services.xml +++ b/config/services.xml @@ -71,7 +71,9 @@ + > + + diff --git a/features/managing_invoices/seeing_invoice_with_taxes_included_in_price_and_promotions_applied.feature b/features/managing_invoices/seeing_invoice_with_taxes_included_in_price_and_promotions_applied.feature index 4662f1c9..6eb153ac 100644 --- a/features/managing_invoices/seeing_invoice_with_taxes_included_in_price_and_promotions_applied.feature +++ b/features/managing_invoices/seeing_invoice_with_taxes_included_in_price_and_promotions_applied.feature @@ -22,6 +22,6 @@ Feature: Seeing included in price taxes and promotions applied on an invoice Scenario: Seeing proper taxes and promotions on an invoice When I view the summary of the invoice for order "#00000666" - Then it should have 2 "PHP T-Shirt" items with unit net price "50.65", discounted unit net price "40.65", net value "81.30", tax total "18.70" and total "100.00" in "USD" currency + Then it should have 2 "PHP T-Shirt" items with unit net price "48.78", discounted unit net price "40.65", net value "81.30", tax total "18.70" and total "100.00" in "USD" currency And it should have a tax item "23%" with amount "18.70" in "USD" currency And its total should be "110.00" in "USD" currency diff --git a/src/Provider/UnitNetPriceProvider.php b/src/Provider/UnitNetPriceProvider.php index 4af6cf24..e841a8df 100644 --- a/src/Provider/UnitNetPriceProvider.php +++ b/src/Provider/UnitNetPriceProvider.php @@ -15,9 +15,25 @@ use Sylius\Component\Core\Model\AdjustmentInterface; use Sylius\Component\Core\Model\OrderItemUnitInterface; +use Sylius\Component\Taxation\Calculator\CalculatorInterface; +use Sylius\Component\Taxation\Model\TaxRate; final class UnitNetPriceProvider implements UnitNetPriceProviderInterface { + public function __construct( + private ?CalculatorInterface $taxCalculator = null, + ) { + if (null === $taxCalculator) { + trigger_deprecation( + 'sylius/invoicing-plugin', + '2.2', + 'Not passing a "%s" instance to "%s" is deprecated and it will be required in 3.0.', + CalculatorInterface::class, + self::class, + ); + } + } + public function getUnitNetPrice(OrderItemUnitInterface $orderItemUnit): int { $orderItem = $orderItemUnit->getOrderItem(); @@ -25,10 +41,26 @@ public function getUnitNetPrice(OrderItemUnitInterface $orderItemUnit): int /** @var AdjustmentInterface $adjustment */ foreach ($orderItemUnit->getAdjustments(AdjustmentInterface::TAX_ADJUSTMENT) as $adjustment) { if ($adjustment->isNeutral()) { - $unitPrice -= $adjustment->getAmount(); + /** @var float $taxRateAmount */ + $taxRateAmount = $adjustment->getDetails()['taxRateAmount']; + + $unitPrice -= $this->calculateIncludedTax($unitPrice, $taxRateAmount); } } return $unitPrice; } + + private function calculateIncludedTax(int $unitPrice, float $taxRateAmount): int + { + if (null !== $this->taxCalculator) { + $taxRate = new TaxRate(); + $taxRate->setAmount($taxRateAmount); + $taxRate->setIncludedInPrice(true); + + return (int) round($this->taxCalculator->calculate($unitPrice, $taxRate)); + } + + return (int) round($unitPrice - ($unitPrice / (1 + $taxRateAmount))); + } } diff --git a/tests/Unit/Provider/UnitNetPriceProviderTest.php b/tests/Unit/Provider/UnitNetPriceProviderTest.php index 12369610..d53fee3f 100644 --- a/tests/Unit/Provider/UnitNetPriceProviderTest.php +++ b/tests/Unit/Provider/UnitNetPriceProviderTest.php @@ -19,6 +19,7 @@ use Sylius\Component\Core\Model\AdjustmentInterface; use Sylius\Component\Core\Model\OrderItemInterface; use Sylius\Component\Core\Model\OrderItemUnitInterface; +use Sylius\Component\Taxation\Calculator\DefaultCalculator; use Sylius\InvoicingPlugin\Provider\UnitNetPriceProvider; use Sylius\InvoicingPlugin\Provider\UnitNetPriceProviderInterface; @@ -29,7 +30,7 @@ final class UnitNetPriceProviderTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->provider = new UnitNetPriceProvider(); + $this->provider = new UnitNetPriceProvider(new DefaultCalculator()); } #[Test] @@ -54,13 +55,112 @@ public function it_provides_net_price_for_unit_with_taxes_included_in_price(): v ->willReturn(new ArrayCollection([$taxAdjustment])); $taxAdjustment->method('isNeutral')->willReturn(true); - $taxAdjustment->method('getAmount')->willReturn(200); + $taxAdjustment->method('getDetails')->willReturn(['taxRateAmount' => 0.25]); $result = $this->provider->getUnitNetPrice($unit); self::assertSame(800, $result); } + #[Test] + public function it_provides_net_price_for_unit_with_taxes_included_in_price_and_promotion_applied(): void + { + $unit = $this->createMock(OrderItemUnitInterface::class); + $orderItem = $this->createMock(OrderItemInterface::class); + $taxAdjustment = $this->createMock(AdjustmentInterface::class); + + $unit->method('getOrderItem')->willReturn($orderItem); + $orderItem->method('getUnitPrice')->willReturn(12000); + + $unit + ->method('getAdjustments') + ->with(AdjustmentInterface::TAX_ADJUSTMENT) + ->willReturn(new ArrayCollection([$taxAdjustment])); + + // Neutral tax adjustment: amount is calculated on discounted price (post-promotion), + // so using getAmount() directly would give a wrong result. + $taxAdjustment->method('isNeutral')->willReturn(true); + $taxAdjustment->method('getDetails')->willReturn(['taxRateAmount' => 0.20]); + + $result = $this->provider->getUnitNetPrice($unit); + + self::assertSame(10000, $result); + } + + #[Test] + public function it_triggers_a_deprecation_and_calculates_the_tax_manually_when_no_calculator_is_passed(): void + { + $deprecation = null; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecation): bool { + $deprecation = $errstr; + + return true; + }, \E_USER_DEPRECATED); + + try { + $provider = new UnitNetPriceProvider(); + } finally { + restore_error_handler(); + } + + self::assertNotNull($deprecation); + self::assertStringContainsString('will be required in 3.0', $deprecation); + + $unit = $this->createMock(OrderItemUnitInterface::class); + $orderItem = $this->createMock(OrderItemInterface::class); + $taxAdjustment = $this->createMock(AdjustmentInterface::class); + + $unit->method('getOrderItem')->willReturn($orderItem); + $orderItem->method('getUnitPrice')->willReturn(1000); + + $unit + ->method('getAdjustments') + ->with(AdjustmentInterface::TAX_ADJUSTMENT) + ->willReturn(new ArrayCollection([$taxAdjustment])); + + $taxAdjustment->method('isNeutral')->willReturn(true); + $taxAdjustment->method('getDetails')->willReturn(['taxRateAmount' => 0.25]); + + self::assertSame(800, $provider->getUnitNetPrice($unit)); + } + + #[Test] + public function it_calculates_the_tax_manually_on_the_full_unit_price_when_no_calculator_is_passed_and_a_promotion_is_applied(): void + { + $deprecation = null; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecation): bool { + $deprecation = $errstr; + + return true; + }, \E_USER_DEPRECATED); + + try { + $provider = new UnitNetPriceProvider(); + } finally { + restore_error_handler(); + } + + $unit = $this->createMock(OrderItemUnitInterface::class); + $orderItem = $this->createMock(OrderItemInterface::class); + $taxAdjustment = $this->createMock(AdjustmentInterface::class); + + $unit->method('getOrderItem')->willReturn($orderItem); + $orderItem->method('getUnitPrice')->willReturn(12000); + + $unit + ->method('getAdjustments') + ->with(AdjustmentInterface::TAX_ADJUSTMENT) + ->willReturn(new ArrayCollection([$taxAdjustment])); + + // The neutral tax adjustment amount is calculated on the discounted (post-promotion) + // price, so subtracting getAmount() directly would diverge from the real net price. + // The tax must be recalculated on the full unit price instead. + $taxAdjustment->method('isNeutral')->willReturn(true); + $taxAdjustment->method('getDetails')->willReturn(['taxRateAmount' => 0.20]); + + self::assertSame(10000, $provider->getUnitNetPrice($unit)); + } + #[Test] public function it_provides_net_price_for_unit_with_taxes_excluded_of_price(): void { From 130a1455d8b04e894d8d45ba0de9a579d3b4afe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Mon, 22 Jun 2026 14:51:34 +0200 Subject: [PATCH 2/2] Update UPGRADE-2.2.md to document `UnitNetPriceProvider` changes and deprecations --- UPGRADE-2.2.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/UPGRADE-2.2.md b/UPGRADE-2.2.md index 8c888be1..129dd29a 100644 --- a/UPGRADE-2.2.md +++ b/UPGRADE-2.2.md @@ -72,3 +72,16 @@ | `Sylius\InvoicingPlugin\Manager\InvoiceFileManager` | `Sylius\PdfGenerationBundle\Core\Filesystem\Manager\PdfFileManager` | The corresponding services (`sylius_invoicing.generator.twig_to_pdf` and `sylius_invoicing.generator.pdf_options`) are also deprecated. + +1. `Sylius\InvoicingPlugin\Provider\UnitNetPriceProvider` now accepts a `CalculatorInterface` argument used to compute the + tax included in the unit price. Not passing it is deprecated and it will be required in 3.0: + + ```diff + public function __construct( + + private ?CalculatorInterface $taxCalculator = null, + ) + ``` + + When no calculator is passed, the provider falls back to recalculating the included tax from the full unit price. + This replaces the previous behaviour of subtracting the neutral tax adjustment amount directly, which was computed on + the discounted (post-promotion) price and therefore produced a net price that diverged from the actual one.