Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions UPGRADE-2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
<service
id="sylius_invoicing.provider.unit_net_price"
class="Sylius\InvoicingPlugin\Provider\UnitNetPriceProvider"
/>
>
<argument type="service" id="sylius.tax_calculator.default" />
</service>
<service id="Sylius\InvoicingPlugin\Provider\UnitNetPriceProviderInterface" alias="sylius_invoicing.provider.unit_net_price" />
</services>
</container>
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 33 additions & 1 deletion src/Provider/UnitNetPriceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,52 @@

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();
$unitPrice = $orderItem->getUnitPrice();
/** @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)));
}
}
104 changes: 102 additions & 2 deletions tests/Unit/Provider/UnitNetPriceProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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]
Expand All @@ -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
{
Expand Down
Loading