commerce_shipping-8.x-2.0-rc2/src/Plugin/Commerce/TaxType/Shipping.php
src/Plugin/Commerce/TaxType/Shipping.php
<?php
namespace Drupal\commerce_shipping\Plugin\Commerce\TaxType;
use Drupal\commerce_tax\Attribute\CommerceTaxType;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\commerce\EntityUuidMapperInterface;
use Drupal\commerce_order\Adjustment;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_price\Calculator;
use Drupal\commerce_price\RounderInterface;
use Drupal\commerce_shipping\Entity\ShipmentInterface;
use Drupal\commerce_shipping\ShippingOrderManagerInterface;
use Drupal\commerce_tax\Plugin\Commerce\TaxType\LocalTaxTypeInterface;
use Drupal\commerce_tax\Plugin\Commerce\TaxType\TaxTypeBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Provides the Shipping tax type.
*/
#[CommerceTaxType(
id: 'shipping',
label: new TranslatableMarkup('Shipping'),
weight: 10,
)]
class Shipping extends TaxTypeBase {
/**
* The entity UUID mapper.
*
* @var \Drupal\commerce\EntityUuidMapperInterface
*/
protected $entityUuidMapper;
/**
* The rounder.
*
* @var \Drupal\commerce_price\RounderInterface
*/
protected $rounder;
/**
* The shipping order manager.
*
* @var \Drupal\commerce_shipping\ShippingOrderManagerInterface
*/
protected $shippingOrderManager;
/**
* Constructs a new Shipping object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
* The event dispatcher.
* @param \Drupal\commerce\EntityUuidMapperInterface $entity_uuid_mapper
* The entity UUID mapper.
* @param \Drupal\commerce_price\RounderInterface $rounder
* The rounder.
* @param \Drupal\commerce_shipping\ShippingOrderManagerInterface $shipping_order_manager
* The shipping order manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher, EntityUuidMapperInterface $entity_uuid_mapper, RounderInterface $rounder, ShippingOrderManagerInterface $shipping_order_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $event_dispatcher);
$this->entityUuidMapper = $entity_uuid_mapper;
$this->rounder = $rounder;
$this->shippingOrderManager = $shipping_order_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('event_dispatcher'),
$container->get('commerce.entity_uuid_mapper'),
$container->get('commerce_price.rounder'),
$container->get('commerce_shipping.order_manager')
);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'strategy' => 'default',
// The store UUIDs.
'store_filter' => 'none',
'stores' => [],
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['strategy'] = [
'#type' => 'radios',
'#title' => $this->t('Strategy'),
'#options' => [
'default' => $this->t("Apply the default (standard) rate of the order's tax type"),
'every' => $this->t("Apply every rate found on the order"),
'highest' => $this->t('Apply the highest rate found on the order'),
'proportional' => $this->t("Apply each order item's rate proportionally"),
],
'#default_value' => $this->configuration['strategy'],
];
$store_ids = NULL;
if ($this->configuration['stores']) {
$store_ids = $this->entityUuidMapper->mapToIds('commerce_store', $this->configuration['stores']);
}
$radio_parents = array_merge($form['#parents'], ['store_filter']);
$radio_path = array_shift($radio_parents);
$radio_path .= '[' . implode('][', $radio_parents) . ']';
$form['store_filter'] = [
'#type' => 'radios',
'#title' => $this->t('Applies to'),
'#default_value' => $this->configuration['store_filter'],
'#options' => [
'none' => $this->t('All stores'),
'include' => $this->t('Only the selected stores'),
'exclude' => $this->t('All except the selected stores'),
],
];
$form['container'] = [
'#type' => 'container',
'#states' => [
'invisible' => [
':input[name="' . $radio_path . '"]' => ['value' => 'none'],
],
],
];
$form['container']['stores'] = [
'#parents' => array_merge($form['#parents'], ['stores']),
'#type' => 'commerce_entity_select',
'#title' => $this->t('Stores'),
'#default_value' => $store_ids,
'#target_type' => 'commerce_store',
'#hide_single_entity' => FALSE,
'#multiple' => TRUE,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
if (!$form_state->getErrors()) {
$values = $form_state->getValue($form['#parents']);
$this->configuration = [];
$this->configuration['strategy'] = $values['strategy'];
$this->configuration['store_filter'] = $values['store_filter'];
$this->configuration['stores'] = [];
if ($values['store_filter'] != 'none') {
$this->configuration['stores'] = $this->entityUuidMapper->mapFromIds('commerce_store', $values['stores']);
}
}
}
/**
* {@inheritdoc}
*/
public function applies(OrderInterface $order) {
if (!$this->shippingOrderManager->isShippable($order)) {
return FALSE;
}
/** @var \Drupal\commerce_shipping\Entity\ShipmentInterface[] $shipments */
$shipments = $order->get('shipments')->referencedEntities();
if (empty($shipments)) {
return FALSE;
}
$store_filter = $this->configuration['store_filter'];
if ($store_filter != 'none') {
$match = in_array($order->getStore()->uuid(), $this->configuration['stores']);
$match = ($store_filter == 'include') ? $match : !$match;
if (!$match) {
return FALSE;
}
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function apply(OrderInterface $order) {
$tax_adjustments = $order->collectAdjustments(['tax']);
// Filter-out adjustments with an unknown percentage or source ID,
// usually indicative of a remote tax type.
$tax_adjustments = array_filter($tax_adjustments, function (Adjustment $adjustment) {
$percentage = $adjustment->getPercentage();
$source_id = $adjustment->getSourceId();
return isset($percentage) && substr_count($source_id, '|') === 2;
});
if (empty($tax_adjustments)) {
return;
}
if ($this->configuration['strategy'] == 'default') {
$this->applyDefault($order, $tax_adjustments);
}
elseif ($this->configuration['strategy'] === 'every') {
$this->applyEvery($order, $tax_adjustments);
}
elseif ($this->configuration['strategy'] == 'highest') {
$this->applyHighest($order, $tax_adjustments);
}
elseif ($this->configuration['strategy'] == 'proportional') {
$this->applyProportional($order, $tax_adjustments);
}
}
/**
* Applies the default tax rate of the order's tax type.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
* @param \Drupal\commerce_order\Adjustment[] $tax_adjustments
* The tax adjustments.
*/
protected function applyDefault(OrderInterface $order, array $tax_adjustments) {
// Assume that all tax adjustments have the same tax type and zone ID.
$tax_adjustment = reset($tax_adjustments);
[$tax_type_id, $zone_id, $rate_id] = explode('|', $tax_adjustment->getSourceId());
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
/** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $tax_type */
$tax_type = $tax_type_storage->load($tax_type_id);
if (!$tax_type) {
return;
}
/** @var \Drupal\commerce_tax\Plugin\Commerce\TaxType\LocalTaxTypeInterface $tax_type_plugin */
$tax_type_plugin = $tax_type->getPlugin();
if (!($tax_type_plugin instanceof LocalTaxTypeInterface)) {
return;
}
$zones = $tax_type_plugin->getZones();
$zone = $zones[$zone_id];
$default_rate = $zone->getDefaultRate();
$percentage = $default_rate->getPercentage($order->getCalculationDate());
foreach ($this->getShipments($order) as $shipment) {
$display_inclusive = $tax_type_plugin->isDisplayInclusive();
$tax_amount = $this->calculateTaxAmount($shipment, $percentage->getNumber(), $display_inclusive);
if ($tax_type_plugin->shouldRound()) {
$tax_amount = $this->rounder->round($tax_amount);
}
$shipment->addAdjustment(new Adjustment([
'type' => 'tax',
'label' => $zone->getDisplayLabel(),
'amount' => $tax_amount,
'percentage' => $percentage->getNumber(),
'source_id' => $tax_type->id() . '|' . $zone->getId() . '|' . $default_rate->getId(),
'included' => $display_inclusive,
]));
}
}
/**
* Applies every tax rate found on the order.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
* @param \Drupal\commerce_order\Adjustment[] $tax_adjustments
* The tax adjustments.
*/
protected function applyEvery(OrderInterface $order, array $tax_adjustments) {
// Group adjustments by source ID.
$grouped_adjustments = [];
foreach ($tax_adjustments as $adjustment) {
if (isset($grouped_adjustments[$adjustment->getSourceId()])) {
continue;
}
$grouped_adjustments[$adjustment->getSourceId()] = $adjustment;
}
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
foreach ($this->getShipments($order) as $shipment) {
foreach ($grouped_adjustments as $source_id => $tax_adjustment) {
[$tax_type_id, $zone_id, $rate_id] = explode('|', $source_id);
/** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $tax_type */
$tax_type = $tax_type_storage->load($tax_type_id);
if (!$tax_type) {
continue;
}
$tax_type_plugin = $tax_type->getPlugin();
if (!($tax_type_plugin instanceof LocalTaxTypeInterface)) {
continue;
}
$zones = $tax_type_plugin->getZones();
$zone = $zones[$zone_id];
$rate = $zone->getRate($rate_id);
$percentage = $rate->getPercentage($order->getCalculationDate());
$display_inclusive = $tax_type_plugin->isDisplayInclusive();
$tax_amount = $this->calculateTaxAmount($shipment, $percentage->getNumber(), $display_inclusive);
if ($tax_type_plugin->shouldRound()) {
$tax_amount = $this->rounder->round($tax_amount);
}
$shipment->addAdjustment(new Adjustment([
'type' => 'tax',
'label' => $zone->getDisplayLabel(),
'amount' => $tax_amount,
'percentage' => $tax_adjustment->getPercentage(),
'source_id' => $source_id,
'included' => $display_inclusive,
]));
}
}
}
/**
* Applies the highest tax rate found on the order.
*
* If an order has one order item taxed using the standard rate (e.g. 20%)
* and one taxed using the intermediate rate (e.g. 15%), then the standard
* rate will be applied, just like with applyDefault().
*
* However, if the order only has an order item taxed using the intermediate
* rate, then the intermediate rate will be applied.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
* @param \Drupal\commerce_order\Adjustment[] $tax_adjustments
* The tax adjustments.
*/
protected function applyHighest(OrderInterface $order, array $tax_adjustments) {
/** @var \Drupal\commerce_order\Adjustment[] $tax_adjustments_by_source */
$tax_adjustments_by_source = [];
foreach ($tax_adjustments as $adjustment) {
$tax_adjustments_by_source[$adjustment->getSourceId()] = $adjustment;
}
// Sort by percentage descending.
uasort($tax_adjustments_by_source, function (Adjustment $a, Adjustment $b) {
return $b->getPercentage() <=> $a->getPercentage();
});
$highest_adjustment = reset($tax_adjustments_by_source);
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
foreach ($this->getShipments($order) as $shipment) {
$display_inclusive = $highest_adjustment->isIncluded();
[$tax_type_id, $zone_id, $rate_id] = explode('|', $highest_adjustment->getSourceId());
/** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $tax_type */
$tax_type = $tax_type_storage->load($tax_type_id);
$percentage = $highest_adjustment->getPercentage();
$tax_amount = $this->calculateTaxAmount($shipment, $percentage, $display_inclusive);
$tax_type_plugin = $tax_type?->getPlugin();
if ($tax_type_plugin instanceof LocalTaxTypeInterface &&
$tax_type_plugin->shouldRound()) {
$tax_amount = $this->rounder->round($tax_amount);
}
$definition = ['amount' => $tax_amount] + $highest_adjustment->toArray();
$shipment->addAdjustment(new Adjustment($definition));
}
}
/**
* Applies each order item's tax rate proportionally.
*
* Logic:
* 1. Order items are grouped by their tax rates and then summed up.
* 2. Each group's ratio of the subtotal is calculated.
* 3. Each group's tax rate is applied to the shipments, multiplied by
* the ratio and then rounded.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
* @param \Drupal\commerce_order\Adjustment[] $tax_adjustments
* The tax adjustments.
*/
protected function applyProportional(OrderInterface $order, array $tax_adjustments) {
if (count($tax_adjustments) === 1) {
$this->applyHighest($order, $tax_adjustments);
return;
}
$tax_type_storage = $this->entityTypeManager->getStorage('commerce_tax_type');
// Group order items by tax percentage.
$groups = [];
foreach ($order->getItems() as $order_item) {
$order_item_total = $order_item->getTotalPrice();
$order_item_tax_adjustments = $order_item->getAdjustments(['tax']);
// This order item is tax exempt, skip it.
if (!$order_item_tax_adjustments) {
continue;
}
$order_item_tax_adjustment = reset($order_item_tax_adjustments);
$percentage = $order_item_tax_adjustment->getPercentage();
if (!isset($groups[$percentage])) {
$groups[$percentage] = [
'order_item_total' => $order_item_total,
'tax_adjustment' => $order_item_tax_adjustment,
];
}
else {
$previous_total = $groups[$percentage]['order_item_total'];
$previous_adjustment = $groups[$percentage]['tax_adjustment'];
$groups[$percentage]['order_item_total'] = $previous_total->add($order_item_total);
$groups[$percentage]['tax_adjustment'] = $previous_adjustment->add($order_item_tax_adjustment);
}
}
// Sort by percentage descending.
krsort($groups, SORT_NUMERIC);
// Calculate the ratio of each group.
$subtotal = $order->getSubtotalPrice()->getNumber();
foreach ($groups as $percentage => $group) {
$order_item_total = $group['order_item_total'];
// If the order item total is zero, the group ratio cannot be
// properly calculated.
if ($order_item_total->isZero()) {
$groups[$percentage]['ratio'] = '0';
continue;
}
$groups[$percentage]['ratio'] = $order_item_total->divide($subtotal)->getNumber();
}
foreach ($this->getShipments($order) as $shipment) {
foreach ($groups as $percentage => $group) {
/** @var \Drupal\commerce_order\Adjustment $existing_adjustment */
$existing_adjustment = $group['tax_adjustment'];
$display_inclusive = $existing_adjustment->isIncluded();
[$tax_type_id, $zone_id, $rate_id] = explode('|', $existing_adjustment->getSourceId());
/** @var \Drupal\commerce_tax\Entity\TaxTypeInterface $tax_type */
$tax_type = $tax_type_storage->load($tax_type_id);
$tax_type_plugin = $tax_type?->getPlugin();
$tax_amount = $this->calculateTaxAmount($shipment, $percentage, $display_inclusive);
$tax_amount = $tax_amount->multiply($group['ratio']);
if ($tax_type_plugin instanceof LocalTaxTypeInterface &&
$tax_type_plugin->shouldRound()) {
$tax_amount = $this->rounder->round($tax_amount);
}
$definition = ['amount' => $tax_amount] + $existing_adjustment->toArray();
$shipment->addAdjustment(new Adjustment($definition));
}
}
}
/**
* Gets the order's shipments.
*
* Filters out shipments which are still incomplete (no rate selected).
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*
* @return \Drupal\commerce_shipping\Entity\ShipmentInterface[]
* The shipments.
*/
protected function getShipments(OrderInterface $order) {
/** @var \Drupal\commerce_shipping\Entity\Shipment[] $shipments */
$shipments = $order->get('shipments')->referencedEntities();
$shipments = array_filter($shipments, function (ShipmentInterface $shipment) {
return $shipment->getShippingMethodId() && $shipment->getAmount();
});
return $shipments;
}
/**
* Calculates the tax amount for the given shipment.
*
* @param \Drupal\commerce_shipping\Entity\ShipmentInterface $shipment
* The shipment.
* @param string $percentage
* The tax rate percentage.
* @param bool $included
* Whether tax is already included in the price.
*
* @return \Drupal\commerce_price\Price
* The unrounded tax amount.
*/
protected function calculateTaxAmount(ShipmentInterface $shipment, $percentage, $included = FALSE) {
$shipment_amount = $shipment->getAdjustedAmount(['shipping_promotion']);
$tax_amount = $shipment_amount->multiply($percentage);
if ($included && !$tax_amount->isZero()) {
$divisor = Calculator::add('1', $percentage);
$tax_amount = $tax_amount->divide($divisor);
}
return $tax_amount;
}
}
