diff --git a/CHANGELOG-2.0.md b/CHANGELOG-2.0.md
index c68dbd6f..ebe388c4 100644
--- a/CHANGELOG-2.0.md
+++ b/CHANGELOG-2.0.md
@@ -1,5 +1,9 @@
# CHANGELOG
+### v2.0.3 (2025-10-31)
+
+- [#400](https://github.com/Sylius/InvoicingPlugin/pull/400) Fix InvoiceLineItem net price calculator ([@tomkalon](https://github.com/tomkalon))
+
### v2.0.2 (2025-07-03)
- [#373](https://github.com/Sylius/InvoicingPlugin/pull/373) Add sylius/test-application ([@Wojdylak](https://github.com/Wojdylak))
diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md
index 052c0a91..a980437a 100644
--- a/UPGRADE-2.0.md
+++ b/UPGRADE-2.0.md
@@ -1,3 +1,31 @@
+# UPGRADE FROM 2.0.2 TO 2.0.3
+
+### Deprecations
+- `Sylius\InvoicingPlugin\Provider\UnitNetPriceProvider` — **deprecated since 2.0** and will be removed in 3.0.
+- The `orderItemUnitsToLineItemsConverter` argument and property in `Sylius\InvoicingPlugin\Generator\InvoiceGenerator` — **deprecated since 2.0.3**, to be removed in 3.0.
+- Using `InvoiceGenerator` without providing `orderItemsToLineItemsConverter` is **deprecated since 2.0** and will become an error in 3.0.
+
+```diff
+
+
+
+
+
+
+
+
+
++
+
+```
+
+### Changed
+- `InvoiceGenerator` now prefers `orderItemsToLineItemsConverter`; if it is not provided, it falls back to the (deprecated) `orderItemUnitsToLineItemsConverter` and emits deprecation warnings.
+
+### Removed (since 3.0)
+- `UnitNetPriceProvider`
+- `orderItemUnitsToLineItemsConverter` from `InvoiceGenerator` (argument and property)
+
# UPGRADE FROM 1.X TO 2.0
1. Support for Sylius 2.0 has been added, it is now the recommended Sylius version to use with InvoicingPlugin.
diff --git a/config/services.xml b/config/services.xml
index 47ea616a..ae39c664 100644
--- a/config/services.xml
+++ b/config/services.xml
@@ -73,5 +73,10 @@
class="Sylius\InvoicingPlugin\Provider\UnitNetPriceProvider"
/>
+
+
+
+
+
diff --git a/config/services/converters.xml b/config/services/converters.xml
index e28df52e..9fb60ab2 100644
--- a/config/services/converters.xml
+++ b/config/services/converters.xml
@@ -23,6 +23,12 @@
+
+
+
+
+
+
diff --git a/config/services/generators.xml b/config/services/generators.xml
index cfca0642..533baa54 100644
--- a/config/services/generators.xml
+++ b/config/services/generators.xml
@@ -35,6 +35,7 @@
+
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/Converter/OrderItemsToLineItemsConverter.php b/src/Converter/OrderItemsToLineItemsConverter.php
new file mode 100644
index 00000000..e5e700f9
--- /dev/null
+++ b/src/Converter/OrderItemsToLineItemsConverter.php
@@ -0,0 +1,110 @@
+getItems() as $item) {
+ foreach ($this->convertOrderItemToLineItems($item) as $lineItem) {
+ $lineItems = $this->addLineItem($lineItem, $lineItems);
+ }
+ }
+
+ return $lineItems;
+ }
+
+ private function convertOrderItemToLineItems(OrderItemInterface $item): array
+ {
+ $lineItems = [];
+ $units = $item->getUnits()->getValues();
+ $unitNetPrices = $this->unitNetPriceProvider->getItemNetPrices($item);
+
+ /** @var OrderItemUnitInterface $unit */
+ foreach ($units as $index => $unit) {
+ $lineItems = $this->addLineItem($this->convertOrderItemUnitToLineItem($unit, (int) $unitNetPrices[$index]), $lineItems);
+ }
+
+ return $lineItems;
+ }
+
+ private function convertOrderItemUnitToLineItem(OrderItemUnitInterface $unit, int $unitNetPrice): LineItemInterface
+ {
+ /** @var OrderItemInterface $item */
+ $item = $unit->getOrderItem();
+
+ $grossValue = $unit->getTotal();
+ $taxAmount = $unit->getTaxTotal();
+ $discountedUnitNetPrice = $grossValue - $taxAmount;
+
+ /** @var string|null $productName */
+ $productName = $item->getProductName();
+ Assert::notNull($productName);
+
+ $variant = $item->getVariant();
+
+ return $this->lineItemFactory->createWithData(
+ $productName,
+ 1,
+ $unitNetPrice,
+ $discountedUnitNetPrice,
+ $discountedUnitNetPrice,
+ $taxAmount,
+ $grossValue,
+ $item->getVariantName(),
+ $variant !== null ? $variant->getCode() : null,
+ $this->taxRatePercentageProvider->provideFromAdjustable($unit),
+ );
+ }
+
+ /**
+ * @param LineItemInterface[] $lineItems
+ *
+ * @return LineItemInterface[]
+ */
+ private function addLineItem(LineItemInterface $newLineItem, array $lineItems): array
+ {
+ foreach ($lineItems as $lineItem) {
+ if ($lineItem->compare($newLineItem)) {
+ $lineItem->merge($newLineItem);
+
+ return $lineItems;
+ }
+ }
+
+ $lineItems[] = $newLineItem;
+
+ return $lineItems;
+ }
+}
diff --git a/src/Generator/InvoiceGenerator.php b/src/Generator/InvoiceGenerator.php
index 8241ca4e..c425265a 100644
--- a/src/Generator/InvoiceGenerator.php
+++ b/src/Generator/InvoiceGenerator.php
@@ -36,7 +36,23 @@ public function __construct(
private readonly LineItemsConverterInterface $orderItemUnitsToLineItemsConverter,
private readonly LineItemsConverterInterface $shippingAdjustmentsToLineItemsConverter,
private readonly TaxItemsConverterInterface $taxItemsConverter,
+ private readonly ?LineItemsConverterInterface $orderItemsToLineItemsConverter = null,
) {
+ if (null === $this->orderItemsToLineItemsConverter) {
+ trigger_deprecation(
+ 'sylius/invoicing-plugin',
+ '2.0',
+ 'Not passing a "%s" to "%s" is deprecated and will be required in Sylius Invoicing Plugin 3.0.',
+ LineItemsConverterInterface::class,
+ self::class,
+ );
+ trigger_deprecation(
+ 'sylius/invoicing-plugin',
+ '2.0',
+ 'Deprecated constructor argument "$orderItemUnitsToLineItemsConverter" passed to %s. Use "$orderItemsToLineItemsConverter" instead.',
+ self::class,
+ );
+ }
}
public function generateForOrder(OrderInterface $order, \DateTimeInterface $date): InvoiceInterface
@@ -50,6 +66,10 @@ public function generateForOrder(OrderInterface $order, \DateTimeInterface $date
$paymentState = $order->getPaymentState() === OrderPaymentStates::STATE_PAID ?
InvoiceInterface::PAYMENT_STATE_COMPLETED : InvoiceInterface::PAYMENT_STATE_PENDING;
+ $lineItemsFromOrder = $this->orderItemsToLineItemsConverter !== null
+ ? $this->orderItemsToLineItemsConverter->convert($order)
+ : $this->orderItemUnitsToLineItemsConverter->convert($order);
+
return $this->invoiceFactory->createForData(
$this->uuidInvoiceIdentifierGenerator->generate(),
$this->sequentialInvoiceNumberGenerator->generate(),
@@ -60,7 +80,7 @@ public function generateForOrder(OrderInterface $order, \DateTimeInterface $date
$order->getLocaleCode(),
$order->getTotal(),
new ArrayCollection(array_merge(
- $this->orderItemUnitsToLineItemsConverter->convert($order),
+ $lineItemsFromOrder,
$this->shippingAdjustmentsToLineItemsConverter->convert($order),
)),
$this->taxItemsConverter->convert($order),
diff --git a/src/Provider/ItemNetPricesProvider.php b/src/Provider/ItemNetPricesProvider.php
new file mode 100644
index 00000000..52213ebf
--- /dev/null
+++ b/src/Provider/ItemNetPricesProvider.php
@@ -0,0 +1,67 @@
+getUnits()->first();
+
+ if (null === $orderItemUnit) {
+ return [];
+ }
+
+ $taxRate = $this->getTaxRate($orderItemUnit);
+ $grossTotal = $orderItem->getQuantity() * $orderItem->getUnitPrice();
+
+ $itemNetPrice = ($grossTotal / (100 + ($taxRate))) * 100;
+
+ return array_reverse($this->distributor->distribute(round($itemNetPrice, 1), $orderItem->getQuantity()));
+ }
+
+ private function getTaxRate(OrderItemUnitInterface $orderItemUnit): int
+ {
+ $taxRate = 0;
+
+ /** @var AdjustmentInterface $adjustment */
+ foreach ($orderItemUnit->getAdjustments(AdjustmentInterface::TAX_ADJUSTMENT) as $adjustment) {
+ if (!$adjustment->isNeutral()) {
+ continue;
+ }
+
+ try {
+ $details = $adjustment->getDetails();
+ if (is_array($details) && array_key_exists('taxRateAmount', $details) && is_numeric($details['taxRateAmount'])) {
+ $taxRate = $details['taxRateAmount'] * 100;
+ }
+ } catch (\Throwable $e) {
+ throw new \RuntimeException('Tax rate amount is not valid', 0, $e);
+ }
+ }
+
+ return (int) round($taxRate);
+ }
+}
diff --git a/src/Provider/ItemNetPricesProviderInterface.php b/src/Provider/ItemNetPricesProviderInterface.php
new file mode 100644
index 00000000..0f26e10c
--- /dev/null
+++ b/src/Provider/ItemNetPricesProviderInterface.php
@@ -0,0 +1,21 @@
+