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