Skip to content

Commit e87b4ac

Browse files
committed
Issue #2981432 by bojanz: Extract the tax-exempt price logic from LocalTaxTypeBase, into TaxOrderProcessor
1 parent 2acf4bc commit e87b4ac

6 files changed

Lines changed: 177 additions & 141 deletions

File tree

modules/tax/commerce_tax.info.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ package: Commerce
55
core: 8.x
66
dependencies:
77
- commerce:commerce
8+
- commerce:commerce_price
89
- commerce:commerce_order

modules/tax/commerce_tax.services.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@ services:
1515

1616
commerce_tax.tax_order_processor:
1717
class: Drupal\commerce_tax\TaxOrderProcessor
18-
arguments: ['@entity_type.manager']
18+
arguments: ['@entity_type.manager', '@commerce_price.rounder', '@commerce_tax.chain_tax_rate_resolver']
1919
tags:
2020
- { name: commerce_order.order_processor, priority: 50, adjustment_type: tax }

modules/tax/src/Plugin/Commerce/TaxType/LocalTaxTypeBase.php

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -102,47 +102,14 @@ public function applies(OrderInterface $order) {
102102
public function apply(OrderInterface $order) {
103103
$store = $order->getStore();
104104
$prices_include_tax = $store->get('prices_include_tax')->value;
105-
$matches_store_address = $this->matchesAddress($store);
106105
$zones = $this->getZones();
107106
foreach ($order->getItems() as $order_item) {
108107
$customer_profile = $this->resolveCustomerProfile($order_item);
109108
if (!$customer_profile) {
110109
continue;
111110
}
112111

113-
$adjustments = $order_item->getAdjustments();
114112
$rates = $this->resolveRates($order_item, $customer_profile);
115-
// Don't overcharge a tax-exempt customer if the price is tax-inclusive.
116-
// A negative adjustment is added with the difference, and optionally
117-
// applied to the unit price in the TaxOrderProcessor.
118-
$negate = FALSE;
119-
if (!$rates && $prices_include_tax && $matches_store_address) {
120-
// The price difference is calculated using the store's default tax
121-
// type, but only if no other tax type added its own tax.
122-
// For example, a 12 EUR price with 20% EU VAT gets a -2 EUR
123-
// adjustment if the customer is from Japan, but only if no
124-
// Japanese tax was added due to a JP store registration.
125-
$positive_tax_adjustments = array_filter($adjustments, function ($adjustment) {
126-
/** @var \Drupal\commerce_order\Adjustment $adjustment */
127-
return $adjustment->getType() == 'tax' && $adjustment->isPositive();
128-
});
129-
if (empty($positive_tax_adjustments)) {
130-
$store_profile = $this->buildStoreProfile($store);
131-
$rates = $this->resolveRates($order_item, $store_profile);
132-
$negate = TRUE;
133-
}
134-
}
135-
else {
136-
// A different tax type added a negative adjustment, but this tax type
137-
// has its own tax to add, removing the need for a negative adjustment.
138-
$negative_tax_adjustments = array_filter($adjustments, function ($adjustment) {
139-
/** @var \Drupal\commerce_order\Adjustment $adjustment */
140-
return $adjustment->getType() == 'tax' && $adjustment->isNegative();
141-
});
142-
$adjustments = array_diff_key($adjustments, $negative_tax_adjustments);
143-
$order_item->setAdjustments($adjustments);
144-
}
145-
146113
foreach ($rates as $zone_id => $rate) {
147114
$zone = $zones[$zone_id];
148115
$percentage = $rate->getPercentage();
@@ -153,7 +120,7 @@ public function apply(OrderInterface $order) {
153120
if ($this->shouldRound()) {
154121
$tax_amount = $this->rounder->round($tax_amount);
155122
}
156-
if ($prices_include_tax && !$this->isDisplayInclusive() && !$negate) {
123+
if ($prices_include_tax && !$this->isDisplayInclusive()) {
157124
$unit_price = $unit_price->subtract($tax_amount);
158125
$order_item->setUnitPrice($unit_price);
159126
}
@@ -173,10 +140,10 @@ public function apply(OrderInterface $order) {
173140
$order_item->addAdjustment(new Adjustment([
174141
'type' => 'tax',
175142
'label' => $zone->getDisplayLabel(),
176-
'amount' => $negate ? $tax_amount->multiply('-1') : $tax_amount,
143+
'amount' => $tax_amount,
177144
'percentage' => $percentage->getNumber(),
178145
'source_id' => $this->entityId . '|' . $zone->getId() . '|' . $rate->getId(),
179-
'included' => !$negate && $this->isDisplayInclusive(),
146+
'included' => $this->isDisplayInclusive(),
180147
]));
181148
}
182149
}

modules/tax/src/TaxOrderProcessor.php

Lines changed: 169 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
namespace Drupal\commerce_tax;
44

55
use Drupal\commerce_order\Entity\OrderInterface;
6+
use Drupal\commerce_order\Entity\OrderItemInterface;
67
use Drupal\commerce_order\OrderProcessorInterface;
8+
use Drupal\commerce_price\RounderInterface;
9+
use Drupal\commerce_store\Entity\StoreInterface;
10+
use Drupal\commerce_tax\Entity\TaxTypeInterface;
11+
use Drupal\commerce_tax\Plugin\Commerce\TaxType\LocalTaxTypeInterface;
12+
use Drupal\commerce_tax\Resolver\ChainTaxRateResolverInterface;
713
use Drupal\Core\Entity\EntityTypeManagerInterface;
814

915
/**
@@ -18,49 +24,192 @@ class TaxOrderProcessor implements OrderProcessorInterface {
1824
*/
1925
protected $entityTypeManager;
2026

27+
/**
28+
* The rounder.
29+
*
30+
* @var \Drupal\commerce_price\RounderInterface
31+
*/
32+
protected $rounder;
33+
34+
/**
35+
* The chain tax rate resolver.
36+
*
37+
* @var \Drupal\commerce_tax\Resolver\ChainTaxRateResolverInterface
38+
*/
39+
protected $chainRateResolver;
40+
41+
/**
42+
* The store's tax zones, keyed by store ID.
43+
*
44+
* @var array
45+
*/
46+
protected $storeZones = [];
47+
48+
/**
49+
* The loaded tax types.
50+
*
51+
* @var \Drupal\commerce_tax\Entity\TaxTypeInterface[]
52+
*/
53+
protected $taxTypes = [];
54+
55+
/**
56+
* A cache of instantiated store profiles.
57+
*
58+
* @var \Drupal\profile\Entity\ProfileInterface
59+
*/
60+
protected $storeProfiles = [];
61+
2162
/**
2263
* Constructs a new TaxOrderProcessor object.
2364
*
2465
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
2566
* The entity type manager.
67+
* @param \Drupal\commerce_price\RounderInterface $rounder
68+
* The rounder.
69+
* @param \Drupal\commerce_tax\Resolver\ChainTaxRateResolverInterface $chain_rate_resolver
70+
* The chain tax rate resolver.
2671
*/
27-
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
72+
public function __construct(EntityTypeManagerInterface $entity_type_manager, RounderInterface $rounder, ChainTaxRateResolverInterface $chain_rate_resolver) {
2873
$this->entityTypeManager = $entity_type_manager;
74+
$this->rounder = $rounder;
75+
$this->chainRateResolver = $chain_rate_resolver;
2976
}
3077

3178
/**
3279
* {@inheritdoc}
3380
*/
3481
public function process(OrderInterface $order) {
35-
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
36-
/** @var \Drupal\commerce_tax\Entity\TaxTypeInterface[] $tax_types */
37-
$tax_types = $tax_type_storage->loadMultiple();
82+
$tax_types = $this->getTaxTypes();
3883
foreach ($tax_types as $tax_type) {
39-
if ($tax_type->status() && $tax_type->getPlugin()->applies($order)) {
84+
if ($tax_type->getPlugin()->applies($order)) {
4085
$tax_type->getPlugin()->apply($order);
4186
}
4287
}
43-
// Tax types can create a negative adjustment when a price includes
44-
// tax, but the customer is tax-exempt. These negative adjustments
45-
// are removed and applied directly to the unit price, so that the
46-
// customer always sees the actual price they are being charged.
47-
// @todo Figure out if this conversion should be optional/configurable.
48-
if ($order->getStore()->get('prices_include_tax')->value) {
88+
// Don't overcharge a tax-exempt customer if the price is tax-inclusive.
89+
// For example, a 12 EUR price with 20% EU VAT gets reduced to 10 EUR
90+
// when selling to customers outside the EU, but only if no other tax
91+
// was applied (e.g. a Japanese customer paying Japanese tax due to the
92+
// store being registered to collect tax there).
93+
$store = $order->getStore();
94+
if ($store->get('prices_include_tax')->value) {
4995
foreach ($order->getItems() as $order_item) {
50-
$adjustments = $order_item->getAdjustments();
51-
$negative_tax_adjustments = array_filter($adjustments, function ($adjustment) {
96+
$tax_adjustments = array_filter($order_item->getAdjustments(), function ($adjustment) {
5297
/** @var \Drupal\commerce_order\Adjustment $adjustment */
53-
return $adjustment->getType() == 'tax' && $adjustment->isNegative();
98+
return $adjustment->getType() == 'tax';
5499
});
55-
$adjustments = array_diff_key($adjustments, $negative_tax_adjustments);
56-
$unit_price = $order_item->getUnitPrice();
57-
foreach ($negative_tax_adjustments as $adjustment) {
58-
$unit_price = $unit_price->add($adjustment->getAmount());
100+
if (empty($tax_adjustments)) {
101+
$unit_price = $order_item->getUnitPrice();
102+
$rates = $this->getDefaultRates($order_item, $store);
103+
foreach ($rates as $rate) {
104+
$percentage = $rate->getPercentage();
105+
$tax_amount = $percentage->calculateTaxAmount($order_item->getUnitPrice(), TRUE);
106+
$tax_amount = $this->rounder->round($tax_amount);
107+
$unit_price = $unit_price->subtract($tax_amount);
108+
}
109+
$order_item->setUnitPrice($unit_price);
110+
}
111+
}
112+
}
113+
}
114+
115+
/**
116+
* Gets the default tax rates for the given order item and store.
117+
*
118+
* @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
119+
* The order item.
120+
* @param \Drupal\commerce_store\Entity\StoreInterface $store
121+
* The store.
122+
*
123+
* @return \Drupal\commerce_tax\TaxRate[]
124+
* The tax rates, keyed by tax zone ID.
125+
*/
126+
protected function getDefaultRates(OrderItemInterface $order_item, StoreInterface $store) {
127+
$store_profile = $this->buildStoreProfile($store);
128+
$rates = [];
129+
foreach ($this->getStoreZones($store) as $zone) {
130+
$rate = $this->chainRateResolver->resolve($zone, $order_item, $store_profile);
131+
if (is_object($rate)) {
132+
$rates[$zone->getId()] = $rate;
133+
}
134+
}
135+
136+
return $rates;
137+
}
138+
139+
/**
140+
* Gets the tax zones for the given store.
141+
*
142+
* @param \Drupal\commerce_store\Entity\StoreInterface $store
143+
* The store.
144+
*
145+
* @return \Drupal\commerce_tax\TaxZone[]
146+
* The tax zones.
147+
*/
148+
protected function getStoreZones(StoreInterface $store) {
149+
$store_id = $store->id();
150+
if (!isset($this->storeZones[$store_id])) {
151+
$tax_types = $this->getTaxTypes();
152+
$tax_types = array_filter($tax_types, function (TaxTypeInterface $tax_type) {
153+
$tax_type_plugin = $tax_type->getPlugin();
154+
return ($tax_type_plugin instanceof LocalTaxTypeInterface) && $tax_type_plugin->isDisplayInclusive();
155+
});
156+
157+
$this->storeZones[$store_id] = [];
158+
$store_address = $store->getAddress();
159+
foreach ($tax_types as $tax_type) {
160+
/** @var \Drupal\commerce_tax\Plugin\Commerce\TaxType\LocalTaxTypeInterface $tax_type_plugin */
161+
$tax_type_plugin = $tax_type->getPlugin();
162+
foreach ($tax_type_plugin->getZones() as $zone) {
163+
if ($zone->match($store_address)) {
164+
$this->storeZones[$store_id][] = $zone;
165+
}
166+
}
167+
// Assume that only a single tax type's zones will match.
168+
if (count($this->storeZones[$store_id]) > 0) {
169+
break;
59170
}
60-
$order_item->setUnitPrice($unit_price);
61-
$order_item->setAdjustments($adjustments);
62171
}
63172
}
173+
174+
return $this->storeZones[$store_id];
175+
}
176+
177+
/**
178+
* Builds a customer profile for the given store.
179+
*
180+
* @param \Drupal\commerce_store\Entity\StoreInterface $store
181+
* The store.
182+
*
183+
* @return \Drupal\profile\Entity\ProfileInterface
184+
* The customer profile.
185+
*/
186+
protected function buildStoreProfile(StoreInterface $store) {
187+
$store_id = $store->id();
188+
if (!isset($this->storeProfiles[$store_id])) {
189+
$profile_storage = $this->entityTypeManager->getStorage('profile');
190+
$this->storeProfiles[$store_id] = $profile_storage->create([
191+
'type' => 'customer',
192+
'uid' => $store->getOwnerId(),
193+
'address' => $store->getAddress(),
194+
]);
195+
}
196+
197+
return $this->storeProfiles[$store_id];
198+
}
199+
200+
/**
201+
* Gets the available tax types.
202+
*
203+
* @return \Drupal\commerce_tax\Entity\TaxTypeInterface[]
204+
* The tax types.
205+
*/
206+
protected function getTaxTypes() {
207+
if (empty($this->taxTypes)) {
208+
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
209+
$this->taxTypes = $tax_type_storage->loadByProperties(['status' => TRUE]);
210+
}
211+
212+
return $this->taxTypes;
64213
}
65214

66215
}

modules/tax/tests/src/Kernel/OrderIntegrationTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,9 @@ public function testBillingProfile() {
139139
}
140140

141141
/**
142-
* Tests the conversion of negative adjustments.
142+
* Tests the handling of tax-exempt customers with tax-inclusive prices.
143143
*/
144-
public function testNegativeAdjustmentConversion() {
144+
public function testTaxExemptPrices() {
145145
$profile = Profile::create([
146146
'type' => 'customer',
147147
'address' => [
@@ -158,6 +158,7 @@ public function testNegativeAdjustmentConversion() {
158158
$this->order->addItem($order_item);
159159
$this->order->setBillingProfile($profile);
160160
$this->order->save();
161+
161162
$this->assertCount(0, $this->order->collectAdjustments());
162163
$order_items = $this->order->getItems();
163164
$order_item = reset($order_items);

0 commit comments

Comments
 (0)