diff --git a/admin/themes/default/template/controllers/normal_products/prices.tpl b/admin/themes/default/template/controllers/normal_products/prices.tpl
index bae5d1211..33ec72dfa 100644
--- a/admin/themes/default/template/controllers/normal_products/prices.tpl
+++ b/admin/themes/default/template/controllers/normal_products/prices.tpl
@@ -187,6 +187,7 @@ $(document).ready(function () {
{/if}
+ {* Code For eco-tax working
+ *}
+ {include file="controllers/products/_tourism_tax_rule_field.tpl"}
+
{*
-{/if}
\ No newline at end of file
+{/if}
+
+{include file='controllers/orders/_tourism_tax_actions.tpl'}
\ No newline at end of file
diff --git a/admin/themes/default/template/controllers/orders/_standalone_service_products_table.tpl b/admin/themes/default/template/controllers/orders/_standalone_service_products_table.tpl
index 27ac3882f..cdf901924 100644
--- a/admin/themes/default/template/controllers/orders/_standalone_service_products_table.tpl
+++ b/admin/themes/default/template/controllers/orders/_standalone_service_products_table.tpl
@@ -69,6 +69,23 @@
{displayPrice price=($product.total_price_tax_incl - $product.total_price_tax_excl) currency=$currency->id}
+ {if isset($use_tourism_tax) && $use_tourism_tax && isset($product.tourism_tax_status)}
+
+
+
+
+
+ {l s='Service Tax:'}
+ {displayPrice price=$product.total_service_tax_only currency=$currency->id}
+
+ {if $product.tourism_tax_total > 0}
+
+ {l s='Tourism Tax:'}
+ {displayPrice price=$product.tourism_tax_total currency=$currency->id}
+
+ {/if}
+
+ {/if}
{displayPrice price=$product.total_price_tax_incl currency=$currency->id}
@@ -136,6 +153,23 @@
{l s='Delete'}
+ {if isset($use_tourism_tax) && $use_tourism_tax && isset($product.tourism_tax_status) && $product.tourism_tax_status != $tourism_tax_status_not_applicable && !$product.is_cancelled && !$product.is_refunded}
+ {if $product.tourism_tax_status == $tourism_tax_status_applied}
+
+
+
+ {l s='Exempt Tourism Tax'}
+
+
+ {else}
+
+
+
+ {l s='Apply Tourism Tax'}
+
+
+ {/if}
+ {/if}
{/if}
diff --git a/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl b/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl
new file mode 100644
index 000000000..95a69f587
--- /dev/null
+++ b/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl
@@ -0,0 +1,179 @@
+{*
+* NOTICE OF LICENSE
+*
+* This source file is subject to the Open Software License version 3.0
+* that is bundled with this package in the file LICENSE.md
+* It is also available through the world-wide-web at this URL:
+* https://opensource.org/license/osl-3-0-php
+* If you did not receive a copy of the license and are unable to
+* obtain it through the world-wide-web, please send an email
+* to support@qloapps.com so we can send you a copy immediately.
+*
+* DISCLAIMER
+*
+* Do not edit or add to this file if you wish to upgrade this module to a newer
+* versions in the future. If you wish to customize this module for your needs
+* please refer to https://store.webkul.com/customisation-guidelines for more information.
+*
+* @author Webkul IN
+* @copyright Since 2010 Webkul
+* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
+*}
+
+{* Shared tourism tax price-breakdown tooltip + apply/exempt modal & handlers — included wherever a
+ room table (_rooms_informaion_table.tpl) and/or a products table (_hotel_service_products_table.tpl /
+ _standalone_service_products_table.tpl) with tourism tax columns is rendered. *}
+
+
+
+
+
+
+
+
+
+
+ {l s='Reason for Exemption'}
+
+
+
+
+
+
+
+
+
diff --git a/admin/themes/default/template/controllers/orders/form.tpl b/admin/themes/default/template/controllers/orders/form.tpl
index 43c4af1a4..e18a07406 100644
--- a/admin/themes/default/template/controllers/orders/form.tpl
+++ b/admin/themes/default/template/controllers/orders/form.tpl
@@ -22,6 +22,11 @@
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
* International Registered Trademark & Property of PrestaShop SA
*}
+
+{/if}
+{/if}
diff --git a/admin/themes/default/template/controllers/products/prices.tpl b/admin/themes/default/template/controllers/products/prices.tpl
index c27d0906c..815f27602 100644
--- a/admin/themes/default/template/controllers/products/prices.tpl
+++ b/admin/themes/default/template/controllers/products/prices.tpl
@@ -183,6 +183,7 @@ $(document).ready(function () {
{/if}
+ {* Code For eco-tax working
+ *}
+ {include file="controllers/products/_tourism_tax_rule_field.tpl"}
+
{if isset($specificPriceModificationForm)}
{l s='Specific prices'}
diff --git a/classes/Cart.php b/classes/Cart.php
index c421935df..6e7e282fd 100644
--- a/classes/Cart.php
+++ b/classes/Cart.php
@@ -186,7 +186,8 @@ class CartCore extends ObjectModel
const ONLY_ROOM_SERVICES_WITH_AUTO_ADD_WITHOUT_CONVENIENCE_FEE = 17;
const ONLY_CONVENIENCE_FEE = 18;
- const ONLY_PRODUCTS_WITH_ADDITIONAL_SERVICE = 19;
+ const ONLY_TOURISM_TAX = 19;
+ const ONLY_PRODUCTS_WITH_ADDITIONAL_SERVICE = 20;
public function __construct($id = null, $id_lang = null)
{
@@ -1548,7 +1549,7 @@ class CartCore extends ObjectModel
$compute_precision = $configuration->get('_PS_PRICE_COMPUTE_PRECISION_');
if (!$this->id) {
- return 0;
+ return $type == Cart::ONLY_TOURISM_TAX ? array('tourism_tax' => 0.0, 'tourism_tax_room' => 0.0, 'tourism_tax_service' => 0.0, 'tourism_tax_standalone_products' => 0.0, 'tourism_tax_convenience_fee' => 0.0) : 0;
}
$type = (int)$type;
@@ -1570,7 +1571,8 @@ class CartCore extends ObjectModel
Cart::ONLY_PHYSICAL_PRODUCTS_WITHOUT_SHIPPING,
Cart::ADVANCE_PAYMENT,
Cart::ADVANCE_PAYMENT_ONLY_PRODUCTS,
- Cart::ONLY_PRODUCTS_WITH_ADDITIONAL_SERVICE,
+ Cart::ONLY_TOURISM_TAX,
+ Cart::ONLY_PRODUCTS_WITH_ADDITIONAL_SERVICE,
);
// Define virtual context to prevent case where the cart is not the in the global context
@@ -1637,6 +1639,11 @@ class CartCore extends ObjectModel
}
$products_total = array();
$ecotax_total = 0;
+ $tourism_tax_total = 0.0;
+ $tourism_tax_room = 0.0;
+ $tourism_tax_service = 0.0;
+ $tourism_tax_standalone_products = 0.0;
+ $tourism_tax_convenience_fee = 0.0;
$objCartBookingData = new HotelCartBookingData();
$objServiceProductCartDetail = new ServiceProductCartDetail();
$objAdvPayment = new HotelAdvancedPayment();
@@ -1772,6 +1779,11 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $lineTotal;
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $servicePorduct['tourism_tax'];
+ $tourism_tax_standalone_products += $servicePorduct['tourism_tax'];
+ }
}
}
@@ -1825,6 +1837,11 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $priceAdd;
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $servicePorduct['tourism_tax'];
+ $tourism_tax_standalone_products += $servicePorduct['tourism_tax'];
+ }
}
}
} else if (Product::SELLING_PREFERENCE_WITH_ROOM_TYPE == $product['selling_preference_type']) {
@@ -1854,6 +1871,17 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $servicePrice;
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $service['tourism_tax'];
+ if ($product['auto_add_to_cart'] && Product::PRICE_ADDITION_TYPE_INDEPENDENT == $product['price_addition_type']) {
+ $tourism_tax_convenience_fee += $service['tourism_tax'];
+ } elseif ($product['auto_add_to_cart'] && Product::PRICE_ADDITION_TYPE_WITH_ROOM == $product['price_addition_type']) {
+ $tourism_tax_room += $service['tourism_tax'];
+ } else {
+ $tourism_tax_service += $service['tourism_tax'];
+ }
+ }
}
}
}
@@ -1863,8 +1891,6 @@ class CartCore extends ObjectModel
$priceDisplay = Group::getPriceDisplayMethod(Group::getCurrent()->id);
if ($type == Cart::ADVANCE_PAYMENT || $type == Cart::ADVANCE_PAYMENT_ONLY_PRODUCTS) {
- // getProductMinAdvPaymentAmountByIdCart already aggregates all rooms for this product internally,
- // so call it once and add once — not inside the per-room loop.
$advProductPrice = $objAdvPayment->getProductMinAdvPaymentAmountByIdCart(
$this->id,
$product['id_product'],
@@ -1910,10 +1936,44 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += Tools::processPriceRounding($totalPriceByProduct);
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ if ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $cartRoomInfo['id_product'])) {
+ $roomAddress = new Address((int) HotelRoomType::getHotelIdAddressByIdProduct($cartRoomInfo['id_product']));
+ $hotelBranch = new HotelBranchInformation((int) $roomAddress->id_hotel);
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($cartRoomInfo['date_from'], $cartRoomInfo['date_to']));
+ $unitPriceTe = (float) $roomTotalPrice['total_price_tax_excl'] / $numNights;
+ $childAges = !empty($occupancy[0]['child_ages']) ? (array) $occupancy[0]['child_ages'] : array();
+ $roomTaxCalculator = TaxManagerFactory::getManager($roomAddress, $idTourismTaxRulesGroup)->getTaxCalculator();
+ $roomTourismTax = $roomTaxCalculator->getTaxesTotalAmount(
+ $unitPriceTe,
+ $cartRoomInfo['date_from'],
+ $numNights,
+ $occupancy[0]['adults'],
+ $childAges,
+ (int) $hotelBranch->tourism_tax_collection_type,
+ 1,
+ (int) $this->id_currency
+ );
+ $tourism_tax_total += $roomTourismTax;
+ $tourism_tax_room += $roomTourismTax;
+ }
+ }
}
}
}
}
+
+ if ($type == Cart::ONLY_TOURISM_TAX) {
+ return array(
+ 'tourism_tax' => $tourism_tax_total,
+ 'tourism_tax_room' => $tourism_tax_room,
+ 'tourism_tax_service' => $tourism_tax_service,
+ 'tourism_tax_standalone_products' => $tourism_tax_standalone_products,
+ 'tourism_tax_convenience_fee' => $tourism_tax_convenience_fee,
+ );
+ }
+
foreach ($products_total as $key => $price) {
$order_total += $price;
}
@@ -2039,6 +2099,10 @@ class CartCore extends ObjectModel
$order_total += $shipping_fees + $wrapping_fees;
}
+ if ($tourism_tax_total > 0) {
+ $order_total += $tourism_tax_total;
+ }
+
if ($order_total < 0 && $type != Cart::ONLY_DISCOUNTS) {
return 0;
}
@@ -3150,8 +3214,17 @@ class CartCore extends ObjectModel
}
}
} elseif ($sellingPreferenceType == Product::SELLING_PREFERENCE_STANDALONE) {
- if (isset($id_customer)) {
- $id_address = (int)Address::getFirstCustomerAddressId($id_customer);
+ $addressPreferenceType = Configuration::get('PS_STANDARD_PRODUCT_ORDER_ADDRESS_PREFRENCE');
+ if ($addressPreferenceType == Product::STANDARD_PRODUCT_ADDRESS_PREFERENCE_CUSTOM) {
+ $id_address = (int) Configuration::get('PS_STANDARD_PRODUCT_ORDER_ADDRESS_ID');
+ } elseif ($addressPreferenceType != Product::STANDARD_PRODUCT_ADDRESS_PREFERENCE_HOTEL) {
+ $customer = Context::getContext()->customer;
+ if (Validate::isLoadedObject($customer) && ($idCustomerAddress = Address::getFirstCustomerAddressId($customer->id))) {
+ $id_address = $idCustomerAddress;
+ }
+ }
+ if (!$id_address && ($htlAddress = HotelBranchInformation::getAddress(Configuration::get('WK_PRIMARY_HOTEL')))) {
+ $id_address = $htlAddress['id_address'];
}
}
}
@@ -3942,6 +4015,23 @@ class CartCore extends ObjectModel
$total_tax_without_discount = 0;
}
+ $tourismTaxTotals = $this->getOrderTotal(true, Cart::ONLY_TOURISM_TAX);
+ $tourism_tax = $tourismTaxTotals['tourism_tax'];
+ $tourism_tax_convenience_fee = $tourismTaxTotals['tourism_tax_convenience_fee'];
+ $tourism_tax_service = $tourismTaxTotals['tourism_tax_service'];
+ $tourism_tax_room = $tourismTaxTotals['tourism_tax_room'];
+ $tourism_tax_standalone_products = $tourismTaxTotals['tourism_tax_standalone_products'];
+
+ $roomGrossUpAmount = TaxConfiguration::isGrossedUp($tourism_tax_room) ? $tourism_tax_room : 0.0;
+ $serviceGrossUpAmount = TaxConfiguration::isGrossedUp($tourism_tax_service) ? $tourism_tax_service : 0.0;
+ $standaloneProductsGrossUpAmount = TaxConfiguration::isGrossedUp($tourism_tax_standalone_products) ? $tourism_tax_standalone_products : 0.0;
+ $convenienceFeeGrossUpAmount = TaxConfiguration::isGrossedUp($tourism_tax_convenience_fee) ? $tourism_tax_convenience_fee : 0.0;
+ $total_rooms_wt += $roomGrossUpAmount;
+ $total_additional_services_wt += $serviceGrossUpAmount;
+ $total_standalone_service_products_wt += $standaloneProductsGrossUpAmount;
+ $convenience_fee_wt += $convenienceFeeGrossUpAmount;
+ $total_rooms_with_services_without_discount_ti += $roomGrossUpAmount + $serviceGrossUpAmount + $standaloneProductsGrossUpAmount + $convenienceFeeGrossUpAmount;
+
$summary = array(
'delivery' => $delivery,
'delivery_state' => State::getNameById($delivery->id_state),
@@ -3986,6 +4076,9 @@ class CartCore extends ObjectModel
'cart_total_without_discount_te' => $cart_total_without_discount_te,
'cart_total_without_discount_ti' => $cart_total_without_discount_ti,
'total_tax_without_discount' => $total_tax_without_discount,
+ 'tourism_tax' => max(0.0, (float) $tourism_tax),
+ 'total_tourism_tax' => max(0.0, (float) $tourism_tax),
+ 'tourism_tax_grossed_up' => TaxConfiguration::isGrossedUp($tourism_tax),
'total_products_in_cart' => $totalRoomsCount + $nbTotalProducts,
);
$hook = Hook::exec('actionCartSummary', $summary, null, true);
diff --git a/classes/PaymentModule.php b/classes/PaymentModule.php
index a0981865b..bd9da789c 100644
--- a/classes/PaymentModule.php
+++ b/classes/PaymentModule.php
@@ -517,6 +517,7 @@ abstract class PaymentModuleCore extends Module
$objMail = new Mail();
$objServiceProductCartDetail = new ServiceProductCartDetail();
$cart_rules = $this->context->cart->getCartRules();
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
foreach ($order_detail_list as $key => $order_detail) {
/** @var OrderDetail $order_detail */
@@ -924,6 +925,34 @@ abstract class PaymentModuleCore extends Module
PrestaShopLogger::addLog('PaymentModule::validateOrder - Cannot save Booking Detail', 3, null, 'Cart', (int)$id_cart, true);
throw new PrestaShopException('Can\'t save Booking Detail');
}
+
+ OrderTaxDetail::updateVatScoping((int) $id_order_detail, (int) $objBookingDetail->id, 0);
+
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $hotelTaxContext = TaxConfiguration::resolveHotelAddressAndCollectionType($objCartBookingData->id_hotel, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ $numNights = max(1, HotelHelper::getNumberOfDays($objBookingDetail->date_from, $objBookingDetail->date_to));
+ $numAdults = (int) $objCartBookingData->adults;
+ $childAges = !empty($objCartBookingData->child_ages) ? (array) json_decode($objCartBookingData->child_ages, true) : array();
+ $unitPriceTaxExcl = (float) $total_price['total_price_tax_excl'] / $numNights;
+
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $hotelTaxContext['address'],
+ $unitPriceTaxExcl,
+ $objBookingDetail->date_from,
+ $numNights,
+ $numAdults,
+ $childAges,
+ (int) $this->context->cart->id_currency,
+ $hotelTaxContext['collectionType'],
+ (int) $idLang,
+ 1,
+ $order->id,
+ (int) $id_order_detail,
+ $objBookingDetail->id,
+ 0
+ );
+ }
}
} else {
$normalProducts[] = $product;
@@ -965,7 +994,30 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->name = $product['name'];
$objServiceProductOrderDetail->quantity = $roomTypeService['quantity'];
$objServiceProductOrderDetail->auto_added = $product['auto_add_to_cart'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext($roomBookingDetail['id_hotel'], $roomBookingDetail['id'], new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
}
}
} elseif (Product::SELLING_PREFERENCE_HOTEL_STANDALONE == $product['selling_preference_type']) {
@@ -998,7 +1050,30 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->hotel_name = $objHotelBranch->hotel_name;
}
$objServiceProductOrderDetail->quantity = $hotelProduct['quantity'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext($product['id_hotel'], 0, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
}
}
@@ -1026,7 +1101,30 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->name = $standaloneProduct['name'];
$objServiceProductOrderDetail->option_name = $standaloneProduct['option_name'];
$objServiceProductOrderDetail->quantity = $standaloneProduct['quantity'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext(0, 0, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
}
}
@@ -1083,7 +1181,32 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->id_htl_booking_detail = $roomBookingDetail['id'];
$objServiceProductOrderDetail->auto_added = $product['auto_add_to_cart'];
}
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ $idHotel = $serviceProduct['id_hotel'] ? $serviceProduct['id_hotel'] : (isset($roomBookingDetail['id_hotel']) ? (int) $roomBookingDetail['id_hotel'] : 0);
+ $idHtlBookingDetail = $serviceProduct['id_hotel'] ? 0 : (isset($roomBookingDetail['id']) ? (int) $roomBookingDetail['id'] : 0);
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext($idHotel, $idHtlBookingDetail, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
}
}
}
@@ -1219,7 +1342,8 @@ abstract class PaymentModuleCore extends Module
$service_products_tax = ($standalone_products_price_tax_incl + $hotel_standalone_products_price_tax_incl) - ($standalone_products_price_tax_excl + $hotel_standalone_products_price_tax_excl);
- $total_order_tax = $room_tax + $additional_service_tax + $total_convenience_fee_tax + $service_products_tax;
+ $totalTourismTax = OrderTaxDetail::getOrderTourismTaxTotal((int) $order->id);
+ $total_order_tax = max(0, $room_tax + $additional_service_tax + $total_convenience_fee_tax + $service_products_tax - $totalTourismTax);
$total_products = Tools::displayPrice(Product::getTaxCalculationMethod() == PS_TAX_EXC ? $order->total_products : $order->total_products_wt, $this->context->currency, false);
@@ -1245,6 +1369,8 @@ abstract class PaymentModuleCore extends Module
),
'service_products_tax' => Tools::displayPrice($service_products_tax, $this->context->currency, false),
'total_order_tax' => Tools::displayPrice($total_order_tax, $this->context->currency, false),
+ 'total_tourism_tax' => Tools::displayPrice($totalTourismTax, $this->context->currency, false),
+ 'has_tourism_tax' => $totalTourismTax > 0,
'total_paid' => Tools::displayPrice($order->total_paid, $this->context->currency, false),
'total_products' => Tools::displayPrice($total_products, $this->context->currency, false),
'total_discounts' => Tools::displayPrice(-$order->total_discounts, $this->context->currency, false),
diff --git a/classes/Product.php b/classes/Product.php
index d34325ea1..f09b83c20 100644
--- a/classes/Product.php
+++ b/classes/Product.php
@@ -226,6 +226,9 @@ class ProductCore extends ObjectModel
public $id_tax_rules_group = 1;
+ /** @var int Tourism tax rules group id (0 = no tourism tax) */
+ public $id_tourism_tax_rules_group = 0;
+
/**
* We keep this variable for retrocompatibility for themes
* @deprecated 1.5.0
@@ -313,8 +316,9 @@ class ProductCore extends ObjectModel
/* Shop fields */
'id_category_default' => array('type' => self::TYPE_INT, 'shop' => true, 'validate' => 'isUnsignedId'),
- 'id_tax_rules_group' => array('type' => self::TYPE_INT, 'shop' => true, 'validate' => 'isUnsignedId'),
- 'on_sale' => array('type' => self::TYPE_BOOL, 'shop' => true, 'validate' => 'isBool'),
+ 'id_tax_rules_group' => array('type' => self::TYPE_INT, 'shop' => true, 'validate' => 'isUnsignedId'),
+ 'id_tourism_tax_rules_group' => array('type' => self::TYPE_INT, 'shop' => true, 'validate' => 'isUnsignedId'),
+ 'on_sale' => array('type' => self::TYPE_BOOL, 'shop' => true, 'validate' => 'isBool'),
'online_only' => array('type' => self::TYPE_BOOL, 'shop' => true, 'validate' => 'isBool'),
'ecotax' => array('type' => self::TYPE_FLOAT, 'shop' => true, 'validate' => 'isPrice'),
'minimal_quantity' => array('type' => self::TYPE_INT, 'shop' => true, 'validate' => 'isUnsignedInt'),
@@ -5418,6 +5422,31 @@ class ProductCore extends ObjectModel
return Cache::retrieve($key);
}
+ /**
+ * Returns the tourism tax rules group assigned to a product, mirroring
+ * getIdTaxRulesGroupByIdProduct() for tourism tax's own product_shop column.
+ *
+ * @param int $id_product
+ * @param Context|null $context
+ * @return int
+ */
+ public static function getIdTourismTaxRulesGroupByIdProduct($id_product, ?Context $context = null)
+ {
+ if (!$context) {
+ $context = Context::getContext();
+ }
+ $key = 'product_id_tourism_tax_rules_group_'.(int)$id_product.'_'.(int)$context->shop->id;
+ if (!Cache::isStored($key)) {
+ $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT `id_tourism_tax_rules_group`
+ FROM `'._DB_PREFIX_.'product_shop`
+ WHERE `id_product` = '.(int)$id_product.' AND id_shop='.(int)$context->shop->id);
+ Cache::store($key, (int)$result);
+ return (int)$result;
+ }
+ return Cache::retrieve($key);
+ }
+
/**
* Returns tax rate.
*
@@ -6743,21 +6772,24 @@ class ProductCore extends ObjectModel
$idAddress = null,
$useReduc = 1,
$idGroup = null,
- $idCartBooking = 0
+ $idCartBooking = 0,
+ $includeTourismTax = false,
+ $numAdults = null,
+ $childrenAges = null
) {
if ($useTax === null) {
$useTax = Product::$_taxCalculationMethod == PS_TAX_EXC ? false : true;
}
$price = Product::getPriceStatic(
- (int)$idProduct,
+ $idProduct,
$useTax,
$idProductOption,
6,
null,
false,
$useReduc,
- (int)$quantity,
+ $quantity,
false,
null,
$idCart,
@@ -6767,8 +6799,8 @@ class ProductCore extends ObjectModel
true,
null,
true,
- (int)$idHotel,
- (int)$idProductRoomType,
+ $idHotel,
+ $idProductRoomType,
$idGroup,
$idCartBooking
);
@@ -6789,22 +6821,56 @@ class ProductCore extends ObjectModel
)
);
- if (Product::getProductPriceCalculation($idProduct) == Product::PRICE_CALCULATION_METHOD_PER_DAY
- && $dateFrom && $dateTo
- ) {
+ $isPerDay = Product::getProductPriceCalculation($idProduct) == Product::PRICE_CALCULATION_METHOD_PER_DAY
+ && $dateFrom && $dateTo;
+ if ($isPerDay) {
$price = $price * HotelHelper::getNumberOfDays($dateFrom, $dateTo);
}
- $price = $price * (int)$quantity;
+ $price = $price * $quantity;
- if (!$specificPrice || ($specificPrice['id_cart'] == 0 && $specificPrice['id_htl_cart_booking'] == 0)) {
- if (!$idGroup || !Validate::isLoadedObject(new Group((int)$idGroup))) {
- $idGroup = (int)Group::getCurrent()->id;
+ $tourismTaxHotelId = $idHotel;
+ if (!$tourismTaxHotelId && $idProductRoomType) {
+ $objRoomType = new HotelRoomType();
+ if ($roomTypeInfo = $objRoomType->getRoomTypeInfoByIdProduct($idProductRoomType)) {
+ $tourismTaxHotelId = $roomTypeInfo['id_hotel'];
}
- $price = Product::applyGroupDiscount($price, $idProduct, $idGroup);
}
+
+ if ($includeTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $priceCalcNumDays = $isPerDay ? HotelHelper::getNumberOfDays($dateFrom, $dateTo) : 1;
+ $unitPriceExcl = ($useTax ? Product::getServiceProductPrice($idProduct, $idProductOption, $idHotel, $idProductRoomType, false, 1, $dateFrom, $dateTo, $idCart, $idAddress, $useReduc, $idGroup, $idCartBooking) : $price / (int) $quantity) / $priceCalcNumDays;
+
+ $fallbackAddress = new Address(Cart::getIdAddressForTaxCalculation($idProduct));
+ if ($numAdults === null) {
+ $taxContext = TaxConfiguration::resolveServiceLineTaxContext($tourismTaxHotelId, 0, $fallbackAddress, $idCartBooking);
+ } else {
+ // caller supplied its own occupancy (e.g. a live search form) — use it as-is, only resolve address/collection type
+ $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($tourismTaxHotelId, $fallbackAddress);
+ $taxContext = array(
+ 'address' => $hotelContext['address'],
+ 'collectionType' => $hotelContext['collectionType'],
+ 'checkInDate' => $dateFrom,
+ 'numNights' => max(1, HotelHelper::getNumberOfDays($dateFrom, $dateTo)),
+ 'numAdults' => $numAdults,
+ 'childrenAges' => $childrenAges ?: array(),
+ );
+ }
+
+ // no $idCurrency passed — TaxCalculator::getTaxesAmount() already resolves it from the current context when null
+ $taxCalculator = TaxManagerFactory::getManager($taxContext['address'], $idTourismTaxRulesGroup)->getTaxCalculator();
+ $price += $taxCalculator->getTaxesTotalAmount(
+ $unitPriceExcl,
+ $taxContext['checkInDate'],
+ $taxContext['numNights'],
+ $taxContext['numAdults'],
+ $taxContext['childrenAges'],
+ $taxContext['collectionType'],
+ $quantity
+ );
+ }
+
return $price;
-
}
/**
diff --git a/classes/order/Order.php b/classes/order/Order.php
index ed5f03706..b1097b0e2 100644
--- a/classes/order/Order.php
+++ b/classes/order/Order.php
@@ -1162,16 +1162,25 @@ class OrderCore extends ObjectModel
$selling_preference_type = null,
$product_auto_add = null,
$product_price_addition_type = null,
- $ids_order_detail = []
+ $ids_order_detail = [],
+ $includeTourismTax = true
) {
/* Retro-compatibility (now set directly on the validateOrder() method) */
if (!$products) {
$products = $this->getProductsDetail($bookingProducts, $selling_preference_type, $product_auto_add, $product_price_addition_type, $ids_order_detail);
}
+ $tourismTaxByOrderDetail = array();
+ if (!$includeTourismTax && $products) {
+ $tourismTaxByOrderDetail = OrderTaxDetail::getAmountsByOrderDetail((int) $this->id);
+ }
+
$return = 0;
foreach ($products as $row) {
$return += $row['total_price_tax_incl'];
+ if (!$includeTourismTax) {
+ $return -= isset($tourismTaxByOrderDetail[$row['id_order_detail']]) ? $tourismTaxByOrderDetail[$row['id_order_detail']] : 0;
+ }
}
return $return;
@@ -1822,10 +1831,11 @@ class OrderCore extends ObjectModel
// if one of the order details use the tax computation method the display will be different
return Db::getInstance()->getValue('
SELECT od.`tax_computation_method`
- FROM `'._DB_PREFIX_.'order_detail_tax` odt
+ FROM `'._DB_PREFIX_.'order_tax_detail` odt
LEFT JOIN `'._DB_PREFIX_.'order_detail` od ON (od.`id_order_detail` = odt.`id_order_detail`)
WHERE od.`id_order` = '.(int)$this->id.'
- AND od.`tax_computation_method` = '.(int)TaxCalculator::ONE_AFTER_ANOTHER_METHOD
+ AND od.`tax_computation_method` = '.(int)TaxCalculator::ONE_AFTER_ANOTHER_METHOD.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = odt.`id_tax` AND tc.`is_tourism_tax` = 1)'
);
}
@@ -2153,61 +2163,6 @@ class OrderCore extends ObjectModel
return $this->update();
}
- /**
- * Returns the correct product taxes breakdown.
- *
- * @since 1.5.0.1
- * @return array
- */
- public function getProductTaxesBreakdown()
- {
- $tmp_tax_infos = array();
- if ($this->useOneAfterAnotherTaxComputationMethod()) {
- // sum by taxes
- $taxes_by_tax = Db::getInstance()->executeS('
- SELECT odt.`id_order_detail`, t.`name`, t.`rate`, SUM(`total_amount`) AS `total_amount`
- FROM `'._DB_PREFIX_.'order_detail_tax` odt
- LEFT JOIN `'._DB_PREFIX_.'tax` t ON (t.`id_tax` = odt.`id_tax`)
- LEFT JOIN `'._DB_PREFIX_.'order_detail` od ON (od.`id_order_detail` = odt.`id_order_detail`)
- WHERE od.`id_order` = '.(int)$this->id.'
- GROUP BY odt.`id_tax`
- ');
-
- // format response
- $tmp_tax_infos = array();
- foreach ($taxes_by_tax as $tax_infos) {
- $tmp_tax_infos[$tax_infos['rate']]['total_amount'] = $tax_infos['tax_amount'];
- $tmp_tax_infos[$tax_infos['rate']]['name'] = $tax_infos['name'];
- }
- } else {
- // sum by order details in order to retrieve real taxes rate
- $taxes_infos = Db::getInstance()->executeS('
- SELECT odt.`id_order_detail`, t.`rate` AS `name`, SUM(od.`total_price_tax_excl`) AS total_price_tax_excl, SUM(t.`rate`) AS rate, SUM(`total_amount`) AS `total_amount`
- FROM `'._DB_PREFIX_.'order_detail_tax` odt
- LEFT JOIN `'._DB_PREFIX_.'tax` t ON (t.`id_tax` = odt.`id_tax`)
- LEFT JOIN `'._DB_PREFIX_.'order_detail` od ON (od.`id_order_detail` = odt.`id_order_detail`)
- WHERE od.`id_order` = '.(int)$this->id.'
- GROUP BY odt.`id_order_detail`
- ');
-
- // sum by taxes
- $tmp_tax_infos = array();
- foreach ($taxes_infos as $tax_infos) {
- if (!isset($tmp_tax_infos[$tax_infos['rate']])) {
- $tmp_tax_infos[$tax_infos['rate']] = array('total_amount' => 0,
- 'name' => 0,
- 'total_price_tax_excl' => 0);
- }
-
- $tmp_tax_infos[$tax_infos['rate']]['total_amount'] += $tax_infos['total_amount'];
- $tmp_tax_infos[$tax_infos['rate']]['name'] = $tax_infos['name'];
- $tmp_tax_infos[$tax_infos['rate']]['total_price_tax_excl'] += $tax_infos['total_price_tax_excl'];
- }
- }
-
- return $tmp_tax_infos;
- }
-
/**
* Returns the shipping taxes breakdown
*
@@ -2539,7 +2494,7 @@ class OrderCore extends ObjectModel
} else {
$order_details = $this->getOrderDetailList();
}
- $expected_total_tax = (float)$this->getTotalProductsWithTaxes($limitToOrderDetails) - (float)$this->getTotalProductsWithoutTaxes($limitToOrderDetails);
+ $expected_total_tax = (float)$this->getTotalProductsWithTaxes($limitToOrderDetails, null, null, null, null, [], false) - (float)$this->getTotalProductsWithoutTaxes($limitToOrderDetails);
$order_ecotax_tax = 0;
diff --git a/classes/order/OrderDetail.php b/classes/order/OrderDetail.php
index cce75207a..2275f7811 100644
--- a/classes/order/OrderDetail.php
+++ b/classes/order/OrderDetail.php
@@ -218,7 +218,7 @@ class OrderDetailCore extends ObjectModel
'tax_computation_method' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
'id_tax_rules_group' => array('type' => self::TYPE_INT, 'validate' => 'isInt'),
'ecotax' => array('type' => self::TYPE_FLOAT, 'validate' => 'isFloat'),
- 'ecotax_tax_rate' => array('type' => self::TYPE_FLOAT, 'validate' => 'isFloat'),
+ 'ecotax_tax_rate' => array('type' => self::TYPE_FLOAT, 'validate' => 'isFloat'),
'discount_quantity_applied' => array('type' => self::TYPE_INT, 'validate' => 'isInt'),
'download_hash' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName'),
'download_nb' => array('type' => self::TYPE_INT, 'validate' => 'isInt'),
@@ -292,7 +292,11 @@ class OrderDetailCore extends ObjectModel
return false;
}
- Db::getInstance()->delete('order_detail_tax', 'id_order_detail='.(int)$this->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$this->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
return $res;
}
@@ -345,15 +349,23 @@ class OrderDetailCore extends ObjectModel
public static function getTaxCalculatorStatic($id_order_detail)
{
$sql = 'SELECT t.*, d.`tax_computation_method`
- FROM `'._DB_PREFIX_.'order_detail_tax` t
+ FROM `'._DB_PREFIX_.'order_tax_detail` t
LEFT JOIN `'._DB_PREFIX_.'order_detail` d ON (d.`id_order_detail` = t.`id_order_detail`)
- WHERE d.`id_order_detail` = '.(int)$id_order_detail;
+ WHERE d.`id_order_detail` = '.(int)$id_order_detail.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = t.`id_tax` AND tc.`is_tourism_tax` = 1)';
$computation_method = 1;
$taxes = array();
+ $seenTaxIds = array();
if ($results = Db::getInstance()->executeS($sql)) {
foreach ($results as $result) {
- $taxes[] = new Tax((int)$result['id_tax']);
+ $idTax = (int) $result['id_tax'];
+ if (isset($seenTaxIds[$idTax])) {
+
+ continue;
+ }
+ $seenTaxIds[$idTax] = true;
+ $taxes[] = new Tax($idTax);
}
$computation_method = $result['tax_computation_method'];
@@ -449,26 +461,30 @@ class OrderDetailCore extends ObjectModel
0,
$taxGroupInfo['id_room_type'],
)) {
- $serviceProductData = array_shift($serviceProductData);
- $numDays = 1;
- if ((Product::PRICE_CALCULATION_METHOD_PER_DAY == $this->product_price_calculation_method)
- && (!$numDays = HotelHelper::getNumberOfDays($serviceProductData['date_from'], $serviceProductData['date_to']))
- ) {
- $numDays = 1;
+ $quantity = 0;
+ $totalPriceTaxExcl = 0.0;
+ $firstServiceProduct = null;
+ foreach ($serviceProductData as $bookingServiceData) {
+ $bookingNumDays = 1;
+ if (Product::PRICE_CALCULATION_METHOD_PER_DAY == $this->product_price_calculation_method) {
+ $bookingNumDays = HotelHelper::getNumberOfDays($bookingServiceData['date_from'], $bookingServiceData['date_to']) ?: 1;
+ }
+ foreach ($bookingServiceData['additional_services'] as $item) {
+ $itemQuantity = (isset($item['quantity']) ? $item['quantity'] : 0) * $bookingNumDays;
+ $quantity += $itemQuantity;
+ $totalPriceTaxExcl += $itemQuantity * (isset($item['unit_price_tax_excl']) ? $item['unit_price_tax_excl'] : 0);
+ if ($firstServiceProduct === null) {
+ $firstServiceProduct = $item;
+ }
+ }
}
- $quantity = array_reduce($serviceProductData['additional_services'], function ($totalQty, $item) {
- return $totalQty + (isset($item['quantity']) ? $item['quantity'] : 0);
- }, 0);
+ $unit_price_tax_excl = $quantity > 0 ? ($totalPriceTaxExcl / $quantity) : 0;
- $firstServiceProduct = array_shift($serviceProductData['additional_services']);
-
- $unit_price_tax_excl = isset($firstServiceProduct['unit_price_tax_excl']) ? $firstServiceProduct['unit_price_tax_excl'] : 0;
-
- $quantity = $quantity * $numDays;
-
- $tax_manager = TaxManagerFactory::getManager($this->vat_address, (int)$firstServiceProduct['id_tax_rules_group']);
- $this->tax_calculator = $tax_manager->getTaxCalculator();
+ if ($firstServiceProduct) {
+ $tax_manager = TaxManagerFactory::getManager($this->vat_address, (int)$firstServiceProduct['id_tax_rules_group']);
+ $this->tax_calculator = $tax_manager->getTaxCalculator();
+ }
} elseif ($serviceProductData = $objServiceProductCartDetail->getServiceProductsInCart(
$idCart,
array(),
@@ -477,22 +493,21 @@ class OrderDetailCore extends ObjectModel
$taxGroupInfo['id_room_type'],
$this->product_id
)) {
-
- $quantity = array_reduce($serviceProductData, function ($totalQty, $item) {
- return $totalQty + (isset($item['quantity']) ? $item['quantity'] : 0);
- }, 0);
-
- $serviceProductData = array_shift($serviceProductData);
- $unit_price_tax_excl = isset($serviceProductData['unit_price_tax_excl']) ? $serviceProductData['unit_price_tax_excl'] : 0;
-
- $numDays = 1;
- if ((Product::PRICE_CALCULATION_METHOD_PER_DAY == $this->product_price_calculation_method)
- && (!$numDays = HotelHelper::getNumberOfDays($serviceProductData['date_from'], $serviceProductData['date_to']))
- ) {
- $numDays = 1;
+ // Same sum-across-every-row fix as above, cart-side: don't let array_shift() collapse
+ // the representative unit price/day-count down to just the first cart row.
+ $quantity = 0;
+ $totalPriceTaxExcl = 0.0;
+ foreach ($serviceProductData as $cartServiceItem) {
+ $itemNumDays = 1;
+ if (Product::PRICE_CALCULATION_METHOD_PER_DAY == $this->product_price_calculation_method) {
+ $itemNumDays = HotelHelper::getNumberOfDays($cartServiceItem['date_from'], $cartServiceItem['date_to']) ?: 1;
+ }
+ $itemQuantity = (isset($cartServiceItem['quantity']) ? $cartServiceItem['quantity'] : 0) * $itemNumDays;
+ $quantity += $itemQuantity;
+ $totalPriceTaxExcl += $itemQuantity * (isset($cartServiceItem['unit_price_tax_excl']) ? $cartServiceItem['unit_price_tax_excl'] : 0);
}
- $quantity = $quantity * $numDays;
+ $unit_price_tax_excl = $quantity > 0 ? ($totalPriceTaxExcl / $quantity) : 0;
}
if ($this->tax_calculator == null) {
@@ -512,7 +527,7 @@ class OrderDetailCore extends ObjectModel
$total_amount = Tools::processPriceRounding($amount, $quantity, $order->round_type, $order->round_mode);
- $values .= '('.(int)$this->id.','.(int)$id_tax.','.(float)$amount.','.(float)$total_amount.'),';
+ $values .= '('.(int)$order->id.','.(int)$this->id.',0,0,'.(int)$id_tax.','.(float)$amount.','.(float)$total_amount.',NOW()),';
}
}
}
@@ -546,7 +561,7 @@ class OrderDetailCore extends ObjectModel
$total_amount = Tools::processPriceRounding($amount, $this->product_quantity, $order->round_type, $order->round_mode);
- $values .= '('.(int)$this->id.','.(int)$id_tax.','.(float)$amount.','.(float)$total_amount.'),';
+ $values .= '('.(int)$order->id.','.(int)$this->id.',0,0,'.(int)$id_tax.','.(float)$amount.','.(float)$total_amount.',NOW()),';
}
}
@@ -554,10 +569,15 @@ class OrderDetailCore extends ObjectModel
if ($values) {
if ($replace) {
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$this->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$this->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
}
- $sql = 'INSERT INTO `'._DB_PREFIX_.'order_detail_tax` (id_order_detail, id_tax, unit_amount, total_amount)
+ $sql = 'INSERT INTO `'._DB_PREFIX_.'order_tax_detail`
+ (id_order, id_order_detail, id_htl_booking, id_service_product_order_detail, id_tax, unit_amount, total_amount, date_add)
VALUES '.$values;
return Db::getInstance()->execute($sql);
@@ -573,7 +593,10 @@ class OrderDetailCore extends ObjectModel
$tax_manager = TaxManagerFactory::getManager($address, (int)Product::getIdTaxRulesGroupByIdProduct((int)$this->product_id, $this->context));
$this->tax_calculator = $tax_manager->getTaxCalculator();
- return $this->saveTaxCalculator($order, true);
+ $result = $this->saveTaxCalculator($order, true);
+ OrderTaxDetail::rescopeVatAfterReset((int) $this->id);
+
+ return $result;
}
/**
@@ -593,9 +616,25 @@ class OrderDetailCore extends ObjectModel
public static function getTaxListStatic($id_order_detail)
{
- $sql = 'SELECT * FROM `'._DB_PREFIX_.'order_detail_tax`
- WHERE `id_order_detail` = '.(int)$id_order_detail;
- return Db::getInstance()->executeS($sql);
+ $sql = 'SELECT * FROM `'._DB_PREFIX_.'order_tax_detail` t
+ WHERE t.`id_order_detail` = '.(int)$id_order_detail.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = t.`id_tax` AND tc.`is_tourism_tax` = 1)';
+ $rows = Db::getInstance()->executeS($sql);
+ if (!$rows) {
+ return $rows;
+ }
+
+ $seenTaxIds = array();
+ $deduped = array();
+ foreach ($rows as $row) {
+ if (isset($seenTaxIds[$row['id_tax']])) {
+ continue;
+ }
+ $seenTaxIds[$row['id_tax']] = true;
+ $deduped[] = $row;
+ }
+
+ return $deduped;
}
/*
@@ -729,6 +768,11 @@ class OrderDetailCore extends ObjectModel
$this->total_price_tax_incl = (float)$product['total_wt'];
$this->total_price_tax_excl = (float)$product['total'];
+ if (isset($this->tax_calculator) && Product::getIdTourismTaxRulesGroupByIdProduct((int) $product['id_product'])) {
+ $this->unit_price_tax_incl = $this->tax_calculator->addTaxes($this->unit_price_tax_excl);
+ $this->total_price_tax_incl = $this->tax_calculator->addTaxes($this->total_price_tax_excl);
+ }
+
$this->purchase_supplier_price = (float)$product['wholesale_price'];
if ($product['id_supplier'] > 0 && ($supplier_price = ProductSupplier::getProductPrice((int)$product['id_supplier'], $product['id_product'], $product['id_product_attribute'], true)) > 0) {
$this->purchase_supplier_price = (float)$supplier_price;
@@ -896,10 +940,11 @@ class OrderDetailCore extends ObjectModel
public function getWsTaxes()
{
$query = new DbQuery();
- $query->select('id_tax as id');
- $query->from('order_detail_tax', 'tax');
+ $query->select('tax.`id_tax` as id');
+ $query->from('order_tax_detail', 'tax');
$query->leftJoin('order_detail', 'od', 'tax.`id_order_detail` = od.`id_order_detail`');
$query->where('od.`id_order_detail` = '.(int)$this->id_order_detail);
+ $query->where('NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = tax.`id_tax` AND tc.`is_tourism_tax` = 1)');
return Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($query);
}
diff --git a/classes/order/OrderInvoice.php b/classes/order/OrderInvoice.php
index 3c4950b1c..83ba35ce1 100644
--- a/classes/order/OrderInvoice.php
+++ b/classes/order/OrderInvoice.php
@@ -147,6 +147,28 @@ class OrderInvoiceCore extends ObjectModel
'.($this->id && $this->number ? ' AND od.`id_order_invoice` = '.(int)$this->id : '').' ORDER BY od.`product_name`');
}
+ /**
+ * Recompute this invoice's totals live from its own order_detail lines, instead of trusting
+ * accumulated deltas. Mirrors Order::getOrderTotal()'s formula, scoped to this invoice only.
+ *
+ * @return bool
+ */
+ public function recomputeTotalsFromLines()
+ {
+ $order = new Order((int) $this->id_order);
+ $invoiceProducts = $this->getProductsDetail();
+
+ $totalProductsTe = $order->getTotalProductsWithoutTaxes($invoiceProducts);
+ $totalProductsTi = $order->getTotalProductsWithTaxes($invoiceProducts);
+
+ $this->total_products = $totalProductsTe;
+ $this->total_products_wt = $totalProductsTi;
+ $this->total_paid_tax_excl = max(0, $totalProductsTe + $this->total_shipping_tax_excl + $this->total_wrapping_tax_excl - $this->total_discount_tax_excl);
+ $this->total_paid_tax_incl = max(0, $totalProductsTi + $this->total_shipping_tax_incl + $this->total_wrapping_tax_incl - $this->total_discount_tax_incl);
+
+ return $this->update();
+ }
+
public static function getInvoiceByNumber($id_invoice)
{
if (is_numeric($id_invoice)) {
@@ -323,11 +345,12 @@ class OrderInvoiceCore extends ObjectModel
// if one of the order details use the tax computation method the display will be different
return Db::getInstance()->getValue('
SELECT od.`tax_computation_method`
- FROM `' . _DB_PREFIX_ . 'order_detail_tax` odt
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` odt
LEFT JOIN `' . _DB_PREFIX_ . 'order_detail` od ON (od.`id_order_detail` = odt.`id_order_detail`)
WHERE od.`id_order` = ' . (int) $this->id_order . '
AND od.`id_order_invoice` = ' . (int) $this->id . '
- AND od.`tax_computation_method` = ' . (int) TaxCalculator::ONE_AFTER_ANOTHER_METHOD)
+ AND od.`tax_computation_method` = ' . (int) TaxCalculator::ONE_AFTER_ANOTHER_METHOD . '
+ AND NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'tax` tc WHERE tc.`id_tax` = odt.`id_tax` AND tc.`is_tourism_tax` = 1)')
|| Configuration::get('PS_INVOICE_TAXES_BREAKDOWN');
}
@@ -795,6 +818,28 @@ class OrderInvoiceCore extends ObjectModel
return $wrapping_breakdown;
}
+ /**
+ * Return tourism tax breakdown grouped by id_tax for invoice display. Collapsed into a single
+ * combined row when the invoice's tax breakdown display is off — same rule every other tax
+ * category (rooms, services, ...) already applies via useOneAfterAnotherTaxComputationMethod().
+ *
+ * @return array [['tax_name' => string, 'total_amount' => float], ...]
+ */
+ public function getTourismTaxBreakdown()
+ {
+ $breakdown = OrderTaxDetail::getBreakdownForInvoice((int) $this->id_order);
+ if (!$breakdown || $this->useOneAfterAnotherTaxComputationMethod()) {
+ return $breakdown;
+ }
+
+ $total = 0.0;
+ foreach ($breakdown as $row) {
+ $total += (float) $row['total_amount'];
+ }
+
+ return array(array('id_tax' => 0, 'tax_name' => '', 'total_amount' => $total));
+ }
+
/**
* Returns the ecotax taxes breakdown
*
diff --git a/classes/order/OrderReturn.php b/classes/order/OrderReturn.php
index 84a23f2e4..79785f982 100644
--- a/classes/order/OrderReturn.php
+++ b/classes/order/OrderReturn.php
@@ -210,6 +210,9 @@ class OrderReturnCore extends ObjectModel
$calcServicePriceFirst = true;
}
+ $appliedTourismTaxByBooking = OrderTaxDetail::getAppliedTourismTaxTotals($idOrder, OrderTaxDetail::SCOPE_ROOM);
+ $appliedServiceTourismTaxByBooking = OrderTaxDetail::getAppliedTourismTaxTotals($idOrder, OrderTaxDetail::SCOPE_SERVICE);
+
foreach ($returnDetails as $key => &$bookingRow) {
if ($skipReqCompletedNonRefunded) {
$objReturnState = new OrderReturnState($bookingRow['id_return_state']);
@@ -223,6 +226,8 @@ class OrderReturnCore extends ObjectModel
$bookingRow['extra_service_total_paid_amount'] = 0;
$bookingRow['extra_service_total_price_tax_incl'] = 0;
$bookingRow['room_paid_amount'] = 0;
+ $bookingRow['tourism_tax_applied_amount'] = 0;
+ $bookingRow['tourism_tax_paid_amount'] = 0;
if ($roomSelectedServices = $objServiceProductOrderDetail->getRoomTypeServiceProducts(
@@ -265,6 +270,12 @@ class OrderReturnCore extends ObjectModel
}
}
}
+ $bookingRow['tourism_tax_applied_amount'] = (isset($appliedTourismTaxByBooking[$bookingRow['id_htl_booking']]) ? (float) $appliedTourismTaxByBooking[$bookingRow['id_htl_booking']] : 0.0)
+ + (isset($appliedServiceTourismTaxByBooking[$bookingRow['id_htl_booking']]) ? (float) $appliedServiceTourismTaxByBooking[$bookingRow['id_htl_booking']] : 0.0);
+ if ($bookingRow['tourism_tax_applied_amount'] > 0 && $objOrder->total_paid_real > 0) {
+ $denominator = $objOrder->total_paid_tax_incl + $objOrder->total_discounts_tax_incl;
+ $bookingRow['tourism_tax_paid_amount'] = $denominator > 0 ? ($objOrder->total_paid_real * $bookingRow['tourism_tax_applied_amount'] / $denominator) : 0.0;
+ }
if ($customerView) {
$dateJoin = $bookingRow['id_product'].'_'.strtotime($bookingRow['date_from']).strtotime($bookingRow['date_to']);
if (isset($returnsCustView[$dateJoin]['num_rooms'])) {
diff --git a/classes/order/OrderTaxDetail.php b/classes/order/OrderTaxDetail.php
new file mode 100644
index 000000000..768c5c3ae
--- /dev/null
+++ b/classes/order/OrderTaxDetail.php
@@ -0,0 +1,1430 @@
+ 'order_tax_detail',
+ 'primary' => 'id_order_tax_detail',
+ 'fields' => array(
+ 'id_order' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'id_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'id_htl_booking' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'id_service_product_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
+ 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'unit_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ 'total_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
+ ),
+ );
+
+ /**
+ * Whether a booking's tourism tax (room + every attached service) is currently excluded from totals.
+ *
+ * @param int $idHtlBooking
+ * @return bool
+ */
+ public static function isBookingExempted($idHtlBooking)
+ {
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT 1 FROM `' . _DB_PREFIX_ . 'order_tax_exemption`
+ WHERE `id_htl_booking` = ' . (int) $idHtlBooking . '
+ AND `id_service_product_order_detail` = 0'
+ );
+ }
+
+ /**
+ * Whether a standalone (no room attachment) service line is currently excluded from totals.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return bool
+ */
+ public static function isServiceLineExempted($idServiceProductOrderDetail)
+ {
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT 1 FROM `' . _DB_PREFIX_ . 'order_tax_exemption`
+ WHERE `id_htl_booking` = 0
+ AND `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail
+ );
+ }
+
+ /**
+ * Delete the exemption marker for a booking or standalone service line — id_htl_booking=0 for the
+ * latter, id_service_product_order_detail=0 for the former (their own two-shape storage key).
+ * Kept as a shared helper since it's used 3 times (hardDeleteForBooking, applyBooking, applyServiceLine).
+ *
+ * @param int $idHtlBooking
+ * @param int $idServiceProductOrderDetail
+ * @return bool
+ */
+ protected static function deleteExemptionMarker($idHtlBooking, $idServiceProductOrderDetail)
+ {
+ return Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'order_tax_exemption`
+ WHERE `id_htl_booking` = ' . (int) $idHtlBooking . '
+ AND `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail
+ );
+ }
+
+ /**
+ * Applied (non-exempted, non-refunded) tourism tax totals for every booking of an order, keyed by id_htl_booking — cached per order.
+ *
+ * @param int $idOrder
+ * @param int $scope self::SCOPE_ROOM (default) — float per room, keyed by id_htl_booking (room's own tax only)
+ * self::SCOPE_ROOM_STATUS — ['status' => int, 'total' => float] per room, same keys
+ * self::SCOPE_SERVICE — float per room, keyed by the attached room's id_htl_booking
+ * @return array keyed as described above per $scope
+ */
+ public static function getAppliedTourismTaxTotals($idOrder, $scope = self::SCOPE_ROOM)
+ {
+ $idOrder = (int) $idOrder;
+ $cacheId = 'OrderTaxDetail::getAppliedTourismTaxTotals-' . $idOrder . '-' . $scope;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $result = array();
+ if ($scope === self::SCOPE_SERVICE) {
+ $sql = 'SELECT spod.`id_htl_booking_detail`, SUM(tt.`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1
+ INNER JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = tt.`id_service_product_order_detail`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON (spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`)
+ OR (spod.`id_htl_booking_detail` = 0 AND ex.`id_service_product_order_detail` = tt.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0)
+ WHERE tt.`id_order` = ' . $idOrder . '
+ AND spod.`id_htl_booking_detail` != 0
+ AND spod.`is_refunded` = 0
+ AND ex.`id_order_tax_exemption` IS NULL
+ GROUP BY spod.`id_htl_booking_detail`';
+ $rows = Db::getInstance()->executeS($sql);
+ foreach ((array) $rows as $row) {
+ $result[(int) $row['id_htl_booking_detail']] = (float) $row['total_amount'];
+ }
+ } else {
+ $rows = Db::getInstance()->executeS(
+ 'SELECT tt.`id_htl_booking`,
+ SUM(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN tt.`total_amount` ELSE 0 END) AS total_amount,
+ MAX(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN 1 ELSE 0 END) AS applied_count
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1
+ LEFT JOIN `' . _DB_PREFIX_ . 'htl_booking_detail` hbd
+ ON hbd.`id` = tt.`id_htl_booking`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON ex.`id_htl_booking` = tt.`id_htl_booking`
+ WHERE tt.`id_order` = ' . $idOrder . '
+ AND tt.`id_htl_booking` != 0
+ AND hbd.`is_refunded` = 0
+ GROUP BY tt.`id_htl_booking`'
+ );
+ foreach ((array) $rows as $row) {
+ if ($scope === self::SCOPE_ROOM_STATUS) {
+ $result[(int) $row['id_htl_booking']] = array(
+ 'status' => ((int) $row['applied_count'] > 0) ? self::STATUS_APPLIED : self::STATUS_EXEMPTED,
+ 'total' => (float) $row['total_amount'],
+ );
+ } else {
+ $result[(int) $row['id_htl_booking']] = (float) $row['total_amount'];
+ }
+ }
+ }
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Applied (non-exempted, non-refunded) tourism tax status+total for every standalone (not attached
+ * to a room) service line of an order, keyed by id_service_product_order_detail — the service-line
+ * equivalent of getAppliedTourismTaxTotals(..., SCOPE_ROOM_STATUS), for lines with no room to fold
+ * their status into.
+ *
+ * @param int $idOrder
+ * @return array [id_service_product_order_detail => ['status' => int, 'total' => float]]
+ */
+ public static function getStandaloneServiceTourismTaxStatuses($idOrder)
+ {
+ $idOrder = (int) $idOrder;
+ $cacheId = 'OrderTaxDetail::getStandaloneServiceTourismTaxStatuses-' . $idOrder;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $result = array();
+ $rows = Db::getInstance()->executeS(
+ 'SELECT tt.`id_service_product_order_detail`,
+ SUM(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN tt.`total_amount` ELSE 0 END) AS total_amount,
+ MAX(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN 1 ELSE 0 END) AS applied_count
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1
+ INNER JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = tt.`id_service_product_order_detail`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON ex.`id_service_product_order_detail` = tt.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0
+ WHERE tt.`id_order` = ' . $idOrder . '
+ AND tt.`id_service_product_order_detail` != 0
+ AND spod.`id_htl_booking_detail` = 0
+ AND spod.`is_refunded` = 0
+ GROUP BY tt.`id_service_product_order_detail`'
+ );
+ foreach ((array) $rows as $row) {
+ $result[(int) $row['id_service_product_order_detail']] = array(
+ 'status' => ((int) $row['applied_count'] > 0) ? self::STATUS_APPLIED : self::STATUS_EXEMPTED,
+ 'total' => (float) $row['total_amount'],
+ );
+ }
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Delete every row (VAT and tourism alike) for a scope — used only when the booking/service line
+ * itself is being permanently removed. Unlike the tourism-only delete in saveTourismTax() (which
+ * runs mid-lifecycle while VAT rows for the same scope still need to survive), this clears everything.
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function deleteAllForScope($scopeColumn, $scopeValue)
+ {
+ return Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'order_tax_detail`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Same as deleteAllForScope(), but tourism tax only — VAT rows scoped to the same booking/service
+ * are left untouched. Used before a fresh recompute (re-applying after an exemption) so a changed
+ * tourism tax rule is picked up instead of restoring whatever was stored before the exemption.
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function deleteTourismRowsForScope($scopeColumn, $scopeValue)
+ {
+ return Db::getInstance()->execute(
+ 'DELETE otd FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1
+ WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ *
+ * @param int $idOrderDetail
+ * @param int $idHtlBooking 0 for a service-line scope
+ * @param int $idServiceProductOrderDetail 0 for a room-booking scope
+ * @return bool
+ */
+ public static function updateVatScoping($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail)
+ {
+ $idOrderDetail = (int) $idOrderDetail;
+ $idHtlBooking = (int) $idHtlBooking;
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $db = Db::getInstance();
+
+ $rawUnscopedRows = $db->executeS(
+ 'SELECT `id_order_tax_detail`, `id_order`, `id_tax`, `total_amount`
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail`
+ WHERE `id_order_detail` = ' . $idOrderDetail . '
+ AND `id_htl_booking` = 0
+ AND `id_service_product_order_detail` = 0'
+ );
+ if (!$rawUnscopedRows) {
+ return true;
+ }
+
+ $unscopedRows = array();
+ foreach ($rawUnscopedRows as $row) {
+ $idTax = (int) $row['id_tax'];
+ if (isset($unscopedRows[$idTax])) {
+ $unscopedRows[$idTax]['total_amount'] += (float) $row['total_amount'];
+ $db->delete('order_tax_detail', '`id_order_tax_detail` = ' . (int) $row['id_order_tax_detail']);
+ continue;
+ }
+ $unscopedRows[$idTax] = $row;
+ $unscopedRows[$idTax]['total_amount'] = (float) $row['total_amount'];
+ }
+
+ if ($idHtlBooking) {
+ $ownPriceTaxExcl = (float) $db->getValue(
+ 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id` = ' . $idHtlBooking
+ );
+ } elseif ($idServiceProductOrderDetail) {
+ $ownPriceTaxExcl = (float) $db->getValue(
+ 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'service_product_order_detail`
+ WHERE `id_service_product_order_detail` = ' . $idServiceProductOrderDetail
+ );
+ } else {
+ $ownPriceTaxExcl = 0.0;
+ }
+
+ $ownAmounts = OrderDetail::getTaxCalculatorStatic($idOrderDetail)->getTaxesAmount($ownPriceTaxExcl);
+
+ $result = true;
+ foreach ($unscopedRows as $row) {
+ $idTax = (int) $row['id_tax'];
+ $remaining = (float) $row['total_amount'];
+ $claimed = min($remaining, Tools::ps_round(isset($ownAmounts[$idTax]) ? (float) $ownAmounts[$idTax] : 0.0, 6));
+ $remaining = Tools::ps_round($remaining - $claimed, 6);
+
+ $result = $db->execute(
+ 'INSERT INTO `' . _DB_PREFIX_ . 'order_tax_detail`
+ (id_order, id_order_detail, id_htl_booking, id_service_product_order_detail, id_tax, unit_amount, total_amount, date_add)
+ VALUES (' . (int) $row['id_order'] . ', ' . $idOrderDetail . ', ' . (int) $idHtlBooking . ', '
+ . (int) $idServiceProductOrderDetail . ', ' . $idTax . ', ' . $claimed . ', '
+ . $claimed . ', NOW())'
+ ) && $result;
+
+ if ($remaining <= 0.000001) {
+ $result = $db->delete('order_tax_detail', '`id_order_tax_detail` = ' . (int) $row['id_order_tax_detail']) && $result;
+ } else {
+ $result = $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_tax_detail`
+ SET `total_amount` = ' . $remaining . '
+ WHERE `id_order_tax_detail` = ' . (int) $row['id_order_tax_detail']
+ ) && $result;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ *
+ * @param int $idOrderDetail
+ * @return bool
+ */
+ public static function rescopeVatAfterReset($idOrderDetail)
+ {
+ $idOrderDetail = (int) $idOrderDetail;
+ $db = Db::getInstance();
+ $result = true;
+
+ $roomIds = $db->executeS(
+ 'SELECT `id` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ foreach ($roomIds as $row) {
+ $result = self::updateVatScoping($idOrderDetail, (int) $row['id'], 0) && $result;
+ }
+
+ $serviceIds = $db->executeS(
+ 'SELECT `id_service_product_order_detail` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ foreach ($serviceIds as $row) {
+ $result = self::updateVatScoping($idOrderDetail, 0, (int) $row['id_service_product_order_detail']) && $result;
+ }
+
+ if ($roomIds || $serviceIds) {
+ $result = self::recomputeInclusivePrice($idOrderDetail, 0, 0) && $result;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Hard-delete every tax row — VAT and tourism alike — for a booking and its attached service
+ * lines (room removed from an already-placed order), plus the exemption marker if one exists.
+ *
+ * @param int $idHtlBooking
+ * @param int[] $serviceLineIds ServiceProductOrderDetail ids attached to this booking
+ * @return void
+ */
+ public static function hardDeleteForBooking($idHtlBooking, array $serviceLineIds)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ self::deleteAllForScope(self::SCOPE_COLUMN_ROOM, $idHtlBooking);
+ foreach ($serviceLineIds as $idServiceLine) {
+ self::deleteAllForScope(self::SCOPE_COLUMN_SERVICE, (int) $idServiceLine);
+ }
+ self::deleteExemptionMarker($idHtlBooking, 0);
+ }
+
+ /**
+ * Sum of total_amount for a booking/service-line scope (every row counts — exemption is never
+ * stored on the row itself, it's derived from marker existence at read time).
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return float
+ */
+ protected static function getScopedTotal($scopeColumn, $scopeValue)
+ {
+ return (float) Db::getInstance()->getValue(
+ 'SELECT COALESCE(SUM(otd.`total_amount`), 0)
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1
+ WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Whether tourism tax has never been computed for this scope — no rows exist yet, and none were
+ * ever refunded either. True means a fresh compute (buildRoomTaxParams()/buildServiceLineTaxParams()
+ * + saveTourismTaxFromParams()) is needed instead of restoring/reusing existing rows.
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function hasNeverBeenComputed($scopeColumn, $scopeValue)
+ {
+ if (self::hasRefundedRowsForScope($scopeColumn, $scopeValue)) {
+ return false;
+ }
+
+ return !(bool) Db::getInstance()->getValue(
+ 'SELECT otd.`id_order_tax_detail` FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1
+ WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Whether a scope's tax is currently excluded from totals — resolves a service line to its parent
+ * room's marker when attached, its own marker when standalone. Used by saveTourismTax() to decide
+ * whether a freshly (re)computed amount should move the denormalized display totals.
+ *
+ * @param int $idHtlBooking 0 for a service-line scope
+ * @param int $idServiceProductOrderDetail 0 for a room-booking scope
+ * @return bool
+ */
+ protected static function isScopeExempted($idHtlBooking, $idServiceProductOrderDetail)
+ {
+ if ($idHtlBooking) {
+ return self::isBookingExempted($idHtlBooking);
+ }
+ if ($idServiceProductOrderDetail) {
+ $serviceLine = new ServiceProductOrderDetail((int) $idServiceProductOrderDetail);
+ if (Validate::isLoadedObject($serviceLine) && $serviceLine->id_htl_booking_detail) {
+ return self::isBookingExempted((int) $serviceLine->id_htl_booking_detail);
+ }
+ return self::isServiceLineExempted($idServiceProductOrderDetail);
+ }
+ return false;
+ }
+
+ /**
+ * Compute and save tourism tax for one room-booking or service-product line.
+ * @param int $idTaxRulesGroup Tourism TRG assigned to the product
+ * @param Address $address Jurisdiction address (the hotel's own, not the customer's)
+ * @param float $unitPriceTaxExcl Room/service unit_price_tax_excl (% base and tier lookup)
+ * @param string $checkInDate 'YYYY-MM-DD'
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges Ages at check-in date
+ * @param int $idCurrency
+ * @param int $collectionType HotelBranchInformation::tourism_tax_collection_type snapshot
+ * @param int $idLang
+ * @param int $quantity Line quantity multiplier. Rooms: always 1.
+ * @param int $idOrder
+ * @param int $idOrderDetail
+ * @param int $idHtlBooking 0 for service lines
+ * @param int $idServiceProductOrderDetail 0 for room bookings
+ * @return void
+ */
+ public static function saveTourismTax(
+ $idTaxRulesGroup,
+ Address $address,
+ $unitPriceTaxExcl,
+ $checkInDate,
+ $numNights,
+ $numAdults,
+ array $childrenAges,
+ $idCurrency,
+ $collectionType,
+ $idLang,
+ $quantity,
+ $idOrder,
+ $idOrderDetail,
+ $idHtlBooking,
+ $idServiceProductOrderDetail
+ ) {
+ $idTaxRulesGroup = (int) $idTaxRulesGroup;
+ $idOrderDetail = (int) $idOrderDetail;
+ $idOrder = (int) $idOrder;
+ $idHtlBooking = (int) $idHtlBooking;
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ if (!Configuration::get('QLO_USE_TOURISM_TAX') || !$idTaxRulesGroup || !$idOrderDetail) {
+ return;
+ }
+
+ $taxAmounts = array();
+ $validNightsByTax = array();
+ if ($idTaxRulesGroup) {
+ $taxCalculator = TaxManagerFactory::getManager($address, $idTaxRulesGroup)->getTaxCalculator();
+ $taxAmounts = $taxCalculator->getTaxesAmount(
+ $unitPriceTaxExcl,
+ $checkInDate,
+ $numNights,
+ $numAdults,
+ $childrenAges,
+ $collectionType,
+ $quantity,
+ $idCurrency,
+ $validNightsByTax
+ );
+ }
+
+ $scopeColumn = $idServiceProductOrderDetail ? self::SCOPE_COLUMN_SERVICE : self::SCOPE_COLUMN_ROOM;
+ $scopeValue = $idServiceProductOrderDetail ?: $idHtlBooking;
+
+ self::deleteTourismRowsForScope($scopeColumn, $scopeValue);
+
+ foreach ($taxAmounts as $idTax => $rowAmount) {
+ $rowAmount = (float) $rowAmount;
+
+ $tourismTax = TaxConfiguration::getByTaxId((int) $idTax);
+ $divisor = ($tourismTax && $tourismTax->per_night)
+ ? max(1, (int) ($validNightsByTax[$idTax] ?? $numNights))
+ : max(1, (int) $quantity);
+
+ $taxRow = new OrderTaxDetail();
+ $taxRow->id_order = $idOrder;
+ $taxRow->id_order_detail = $idOrderDetail;
+ $taxRow->id_htl_booking = $idHtlBooking;
+ $taxRow->id_service_product_order_detail = $idServiceProductOrderDetail;
+ $taxRow->id_tax = (int) $idTax;
+ $taxRow->unit_amount = Tools::ps_round($rowAmount / $divisor, 6);
+ $taxRow->total_amount = $rowAmount;
+ $taxRow->add();
+ }
+
+ Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_detail`
+ SET `id_tourism_tax_rule_group` = ' . $idTaxRulesGroup . '
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+
+ self::recomputeInclusivePrice($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail);
+ }
+
+ /**
+ * Sum of VAT + tourism tax rows (exemption-aware) for one scope value of one order_tax_detail column.
+ *
+ * @param string $scopeColumn e.g. 'id_order_detail', self::SCOPE_COLUMN_ROOM, self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return float
+ */
+ private static function computeScopedTaxSum($scopeColumn, $scopeValue)
+ {
+ return (float) Db::getInstance()->getValue(
+ 'SELECT COALESCE(SUM(
+ CASE WHEN t.`is_tourism_tax` = 1 AND ex.`id_order_tax_exemption` IS NOT NULL THEN 0
+ ELSE ott.`total_amount`
+ END
+ ), 0)
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax`
+ LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`)
+ OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`)
+ OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0)
+ WHERE ott.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * A WITH_ROOM auto-add service line's own VAT never gets an order_tax_detail row (it's baked into
+ * the room's price via room-rate substitution instead — see saveTaxCalculator()'s WITH_ROOM skip).
+ * This reconstructs that "phantom" VAT for one service instance so its own inclusive total is complete.
+ *
+ * @param array $serviceRow id_product, id_htl_booking_detail, total_price_tax_excl from service_product_order_detail
+ * @param int $idOrder
+ * @return float
+ */
+ private static function computePhantomVatForServiceLine(array $serviceRow, $idOrder)
+ {
+ if (!$serviceRow['id_htl_booking_detail']) {
+ return 0.0;
+ }
+
+ $roomBooking = new HotelBookingDetail((int) $serviceRow['id_htl_booking_detail']);
+ if (!Validate::isLoadedObject($roomBooking)) {
+ return 0.0;
+ }
+
+ $priceInfo = RoomTypeServiceProductPrice::getProductRoomTypePriceAndTax(
+ (int) $serviceRow['id_product'],
+ (int) $roomBooking->id_product,
+ RoomTypeServiceProduct::WK_ELEMENT_TYPE_ROOM_TYPE
+ );
+ if (!$priceInfo || !isset($priceInfo['id_tax_rules_group'])) {
+ return 0.0;
+ }
+
+ $order = new Order((int) $idOrder);
+ $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType(
+ (int) $roomBooking->id_hotel,
+ new Address((int) $order->id_address_tax)
+ );
+ $address = $hotelContext['address'];
+ if (!Validate::isLoadedObject($address)) {
+ return 0.0;
+ }
+
+ $totalPriceTaxExcl = (float) $serviceRow['total_price_tax_excl'];
+ $taxCalculator = TaxManagerFactory::getManager($address, (int) $priceInfo['id_tax_rules_group'])->getTaxCalculator();
+
+ return $taxCalculator->addTaxes($totalPriceTaxExcl) - $totalPriceTaxExcl;
+ }
+
+ /**
+ * A room booking's own tax-inclusive total: its own price plus only ITS OWN order_tax_detail rows
+ * (scoped by id_htl_booking) — never another booking's, even when several bookings of the same
+ * room type share one order_detail row (same product, different date ranges).
+ *
+ * @param int $idHtlBooking
+ * @return float
+ */
+ private static function computeRoomBookingInclusivePrice($idHtlBooking)
+ {
+ $totalPriceTaxExcl = (float) Db::getInstance()->getValue(
+ 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id` = ' . (int) $idHtlBooking
+ );
+
+ return $totalPriceTaxExcl + self::computeScopedTaxSum(self::SCOPE_COLUMN_ROOM, $idHtlBooking);
+ }
+
+ /**
+ * A service line instance's own tax-inclusive total: its own price plus only ITS OWN order_tax_detail
+ * rows (scoped by id_service_product_order_detail) plus its own phantom VAT share if WITH_ROOM auto-add
+ * — never another instance's, even when several instances of the same service share one order_detail row.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @param array $orderDetailRow product_auto_add, product_price_addition_type, id_order (shared by all
+ * instances under this order_detail, since they're all the same product)
+ * @return float
+ */
+ private static function computeServiceLineInclusivePrice($idServiceProductOrderDetail, array $orderDetailRow)
+ {
+ $serviceRow = Db::getInstance()->getRow(
+ 'SELECT `total_price_tax_excl`, `id_product`, `id_htl_booking_detail`
+ FROM `' . _DB_PREFIX_ . 'service_product_order_detail`
+ WHERE `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail
+ );
+ $totalPriceTaxExcl = (float) $serviceRow['total_price_tax_excl'];
+
+ $phantomVat = 0.0;
+ if ($orderDetailRow['product_auto_add']
+ && (int) $orderDetailRow['product_price_addition_type'] === Product::PRICE_ADDITION_TYPE_WITH_ROOM
+ ) {
+ $phantomVat = self::computePhantomVatForServiceLine($serviceRow, (int) $orderDetailRow['id_order']);
+ }
+
+ return $totalPriceTaxExcl + self::computeScopedTaxSum(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail) + $phantomVat;
+ }
+
+ /**
+ *
+ * @param int $idOrderDetail
+ * @param int $idHtlBooking unused; kept for call-site compatibility
+ * @param int $idServiceProductOrderDetail unused; kept for call-site compatibility
+ * @return bool
+ */
+ protected static function recomputeInclusivePrice($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail)
+ {
+ $idOrderDetail = (int) $idOrderDetail;
+ $db = Db::getInstance();
+
+ $orderDetailRow = $db->getRow(
+ 'SELECT `product_quantity`, `product_auto_add`, `product_price_addition_type`, `id_order`
+ FROM `' . _DB_PREFIX_ . 'order_detail`
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+
+ $result = true;
+ $aggregateTaxIncl = 0.0;
+
+ $roomIds = $db->executeS(
+ 'SELECT `id` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ foreach ($roomIds as $row) {
+ $roomTaxIncl = self::computeRoomBookingInclusivePrice((int) $row['id']);
+ $aggregateTaxIncl += $roomTaxIncl;
+ $result = $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'htl_booking_detail`
+ SET `total_price_tax_incl` = ' . $roomTaxIncl . '
+ WHERE `id` = ' . (int) $row['id']
+ ) && $result;
+ }
+
+ $serviceIds = $db->executeS(
+ 'SELECT `id_service_product_order_detail`, `quantity`
+ FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ foreach ($serviceIds as $row) {
+ $serviceTaxIncl = self::computeServiceLineInclusivePrice((int) $row['id_service_product_order_detail'], $orderDetailRow);
+ $aggregateTaxIncl += $serviceTaxIncl;
+ $result = $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'service_product_order_detail`
+ SET `total_price_tax_incl` = ' . $serviceTaxIncl . ',
+ `unit_price_tax_incl` = COALESCE(' . $serviceTaxIncl . ' / NULLIF(' . (int) $row['quantity'] . ', 0), 0)
+ WHERE `id_service_product_order_detail` = ' . (int) $row['id_service_product_order_detail']
+ ) && $result;
+ }
+
+ $result = $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_detail`
+ SET `total_price_tax_incl` = ' . $aggregateTaxIncl . ',
+ `unit_price_tax_incl` = COALESCE(' . $aggregateTaxIncl . ' / NULLIF(`product_quantity`, 0), 0)
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ ) && $result;
+
+ return $result;
+ }
+
+ /**
+ * Breakdown for invoice/PDF — grouped by id_tax, excluding exempted (via marker) or refunded rows, cached per order+lang.
+ *
+ * @param int $idOrder
+ * @param int|null $idLang defaults to the current context language
+ * @return array
+ */
+ public static function getBreakdownForInvoice($idOrder, $idLang = null)
+ {
+ $idOrder = (int) $idOrder;
+ $idLang = $idLang !== null ? (int) $idLang : (int) Context::getContext()->language->id;
+ $cacheId = 'OrderTaxDetail::getBreakdownForInvoice-' . $idOrder . '-' . $idLang;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $result = Db::getInstance()->executeS(
+ 'SELECT ott.`id_tax`, tl.`name` AS tax_name,
+ SUM(ott.`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax` AND t.`is_tourism_tax` = 1
+ LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail`
+ LEFT JOIN `' . _DB_PREFIX_ . 'htl_booking_detail` hbd
+ ON hbd.`id` = ott.`id_htl_booking`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`)
+ OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`)
+ OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0)
+ LEFT JOIN `' . _DB_PREFIX_ . 'tax_lang` tl
+ ON tl.`id_tax` = ott.`id_tax` AND tl.`id_lang` = ' . $idLang . '
+ WHERE ott.`id_order` = ' . $idOrder . '
+ AND ((ott.`id_htl_booking` != 0 AND hbd.`is_refunded` = 0)
+ OR (ott.`id_htl_booking` = 0 AND spod.`is_refunded` = 0))
+ AND ex.`id_order_tax_exemption` IS NULL
+ GROUP BY ott.`id_tax`, tl.`name`'
+ );
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Applied (non-exempted, non-refunded) tourism tax total per order_detail line. Same
+ * exemption/refund-aware join as getBreakdownForInvoice(), grouped by id_order_detail instead of
+ * id_tax.
+ *
+ * @param int $idOrder
+ * @return array [id_order_detail => total_amount]
+ */
+ public static function getAmountsByOrderDetail($idOrder)
+ {
+ $idOrder = (int) $idOrder;
+ $cacheId = 'OrderTaxDetail::getAmountsByOrderDetail-' . $idOrder;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $rows = Db::getInstance()->executeS(
+ 'SELECT ott.`id_order_detail`, SUM(ott.`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott
+ INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax` AND t.`is_tourism_tax` = 1
+ LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail`
+ LEFT JOIN `' . _DB_PREFIX_ . 'htl_booking_detail` hbd
+ ON hbd.`id` = ott.`id_htl_booking`
+ LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex
+ ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`)
+ OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`)
+ OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0)
+ WHERE ott.`id_order` = ' . $idOrder . '
+ AND ((ott.`id_htl_booking` != 0 AND hbd.`is_refunded` = 0)
+ OR (ott.`id_htl_booking` = 0 AND spod.`is_refunded` = 0))
+ AND ex.`id_order_tax_exemption` IS NULL
+ GROUP BY ott.`id_order_detail`'
+ );
+
+ $result = array();
+ foreach ((array) $rows as $row) {
+ $result[(int) $row['id_order_detail']] = (float) $row['total_amount'];
+ }
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * The single source of truth for an order's tourism tax total — applied, non-exempted, non-refunded.
+ *
+ * @param int $idOrder
+ * @return float
+ */
+ public static function getOrderTourismTaxTotal($idOrder)
+ {
+ return (float) array_sum(array_column(self::getBreakdownForInvoice((int) $idOrder), 'total_amount'));
+ }
+
+ /**
+ * Whether a scope is refunded — apply/exempt must not touch a cancelled booking/service line.
+ * Reads the authoritative flag directly (htl_booking_detail/service_product_order_detail),
+ * not a stored copy on order_tax_detail.
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function hasRefundedRowsForScope($scopeColumn, $scopeValue)
+ {
+ $scopeValue = (int) $scopeValue;
+ if ($scopeColumn === self::SCOPE_COLUMN_ROOM) {
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT `is_refunded` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id` = ' . $scopeValue
+ );
+ }
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT `is_refunded` FROM `' . _DB_PREFIX_ . 'service_product_order_detail`
+ WHERE `id_service_product_order_detail` = ' . $scopeValue
+ );
+ }
+
+ /**
+ * Resolve every parameter saveTourismTax() needs for an already-placed room booking, from the booking's own hotel (not the order's single id_address_tax column).
+ *
+ * @param int $idHtlBooking
+ * @return array|false Ordered args for self::saveTourismTax(), or false when the
+ * booking/order/TRG/address can't be resolved
+ */
+ public static function buildRoomTaxParams($idHtlBooking)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $booking = new HotelBookingDetail($idHtlBooking);
+ if (!Validate::isLoadedObject($booking)) {
+ return false;
+ }
+
+ $order = new Order((int) $booking->id_order);
+ if (!Validate::isLoadedObject($order)) {
+ return false;
+ }
+
+ $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $booking->id_product);
+ if (!$idTaxRulesGroup) {
+ return false;
+ }
+
+ $idHotel = (int) $booking->id_hotel;
+ $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax));
+ $address = $hotelContext['address'];
+ if (!Validate::isLoadedObject($address)) {
+ return false;
+ }
+ $collectionType = $hotelContext['collectionType'];
+
+ $numNights = (int) HotelHelper::getNumberOfDays($booking->date_from, $booking->date_to);
+ if ($numNights <= 0) {
+ return false;
+ }
+
+ $unitPriceTaxExcl = Tools::ps_round((float) $booking->total_price_tax_excl / $numNights, 6);
+
+ $childrenAges = array();
+ if ($booking->child_ages) {
+ $decoded = json_decode($booking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+
+ return array(
+ 'idTaxRulesGroup' => $idTaxRulesGroup,
+ 'address' => $address,
+ 'unitPriceTaxExcl' => $unitPriceTaxExcl,
+ 'checkInDate' => $booking->date_from,
+ 'numNights' => $numNights,
+ 'numAdults' => (int) $booking->adults,
+ 'childrenAges' => $childrenAges,
+ 'idCurrency' => (int) $order->id_currency,
+ 'collectionType' => $collectionType,
+ 'idLang' => (int) Context::getContext()->language->id,
+ 'quantity' => 1,
+ 'idOrder' => (int) $booking->id_order,
+ 'idOrderDetail' => (int) $booking->id_order_detail,
+ 'idHtlBooking' => $idHtlBooking,
+ 'idServiceProductOrderDetail' => 0,
+ );
+ }
+
+ /**
+ * Same as buildRoomTaxParams(), for an already-placed service product line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return array|false
+ */
+ public static function buildServiceLineTaxParams($idServiceProductOrderDetail)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail);
+ if (!Validate::isLoadedObject($serviceLine)) {
+ return false;
+ }
+
+ $order = new Order((int) $serviceLine->id_order);
+ if (!Validate::isLoadedObject($order)) {
+ return false;
+ }
+
+ $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $serviceLine->id_product);
+ if (!$idTaxRulesGroup) {
+ return false;
+ }
+
+ $idHotel = (int) $serviceLine->id_hotel;
+ $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax));
+ $address = $hotelContext['address'];
+ if (!Validate::isLoadedObject($address)) {
+ return false;
+ }
+ $collectionType = $hotelContext['collectionType'];
+
+ $idHtlBookingDetail = (int) $serviceLine->id_htl_booking_detail;
+ $roomBooking = $idHtlBookingDetail ? new HotelBookingDetail($idHtlBookingDetail) : null;
+ if ($roomBooking && Validate::isLoadedObject($roomBooking)) {
+ $checkInDate = $roomBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($roomBooking->date_from, $roomBooking->date_to));
+ $numAdults = (int) $roomBooking->adults;
+ $childrenAges = array();
+ if ($roomBooking->child_ages) {
+ $decoded = json_decode($roomBooking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+ } else {
+ $checkInDate = date('Y-m-d');
+ $numNights = 1;
+ $numAdults = 1;
+ $childrenAges = array();
+ }
+
+ return array(
+ 'idTaxRulesGroup' => $idTaxRulesGroup,
+ 'address' => $address,
+ 'unitPriceTaxExcl' => (float) $serviceLine->unit_price_tax_excl,
+ 'checkInDate' => $checkInDate,
+ 'numNights' => $numNights,
+ 'numAdults' => $numAdults,
+ 'childrenAges' => $childrenAges,
+ 'idCurrency' => (int) $order->id_currency,
+ 'collectionType' => $collectionType,
+ 'idLang' => (int) Context::getContext()->language->id,
+ 'quantity' => (int) $serviceLine->quantity,
+ 'idOrder' => (int) $serviceLine->id_order,
+ 'idOrderDetail' => (int) $serviceLine->id_order_detail,
+ 'idHtlBooking' => 0,
+ 'idServiceProductOrderDetail' => $idServiceProductOrderDetail,
+ );
+ }
+
+ /**
+ * Call saveTourismTax() with a buildRoomTaxParams()/buildServiceLineTaxParams() result.
+ *
+ * @param array $params
+ * @return void
+ */
+ protected static function saveTourismTaxFromParams(array $params)
+ {
+ self::saveTourismTax(
+ $params['idTaxRulesGroup'],
+ $params['address'],
+ $params['unitPriceTaxExcl'],
+ $params['checkInDate'],
+ $params['numNights'],
+ $params['numAdults'],
+ $params['childrenAges'],
+ $params['idCurrency'],
+ $params['collectionType'],
+ $params['idLang'],
+ $params['quantity'],
+ $params['idOrder'],
+ $params['idOrderDetail'],
+ $params['idHtlBooking'],
+ $params['idServiceProductOrderDetail']
+ );
+ }
+
+ /**
+ *
+ * @param int $idOrder
+ * @param int $idEmployee
+ * @param string $autoMsg
+ * @param string|null $note Optional remark appended as "| {$remarkLabel}: ..." — omit for apply actions
+ * @param string $remarkLabel Pre-translated "Remark" label
+ */
+ protected static function saveTourismTaxNote($idOrder, $idEmployee, $autoMsg, $note = null, $remarkLabel = 'Remark')
+ {
+ $noteMessage = $autoMsg . ($note && Validate::isMessage($note) ? ' | ' . $remarkLabel . ': ' . $note : '');
+ $message = new Message();
+ $message->message = $noteMessage;
+ $message->id_order = (int) $idOrder;
+ $message->id_employee = (int) $idEmployee;
+ $message->private = 1;
+ $message->save();
+ }
+
+ /**
+ * Exempt a booking's tourism tax — one marker row covers the room and every service attached to
+ * it. Idempotent: exempting an already-exempted booking is a no-op success.
+ *
+ * @param int $idHtlBooking
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $note Reason for exemption, entered by the employee
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label
+ * @param string $remarkLabel Pre-translated "Remark" label
+ * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE
+ */
+ public static function exemptBooking($idHtlBooking, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark')
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ if (self::isBookingExempted($idHtlBooking)) {
+ return self::EXEMPT_OK;
+ }
+
+ $booking = new HotelBookingDetail($idHtlBooking);
+ if (!Validate::isLoadedObject($booking)) {
+ return self::EXEMPT_ERROR_NO_RULE;
+ }
+
+ $objOrderTaxExemption = new OrderTaxExemption();
+ $objOrderTaxExemption->id_htl_booking = $idHtlBooking;
+ $objOrderTaxExemption->id_service_product_order_detail = 0;
+ $objOrderTaxExemption->id_order = (int) $booking->id_order;
+ $objOrderTaxExemption->id_employee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id;
+ $objOrderTaxExemption->note = $note;
+ if (!$objOrderTaxExemption->add()) {
+ return self::EXEMPT_ERROR_SAVE;
+ }
+
+ if ($autoMsgLabel !== null) {
+ $roomTypeName = Product::getProductName((int) $booking->id_product, null, Context::getContext()->language->id);
+ $autoMsg = $autoMsgLabel . ': ' . $booking->room_num . ' - ' . $roomTypeName . ': ('
+ . date('d/m/Y', strtotime($booking->date_from)) . ' - ' . date('d/m/Y', strtotime($booking->date_to)) . ')';
+ self::saveTourismTaxNote((int) $booking->id_order, $objOrderTaxExemption->id_employee, $autoMsg, $note, $remarkLabel);
+ }
+
+ self::syncDenormalizedTotalsForBooking($idHtlBooking, false);
+
+ return self::EXEMPT_OK;
+ }
+
+ /**
+ * Apply (or re-apply) tourism tax for a booking — deletes the exemption marker if one exists,
+ * restoring the room + every attached service's tax into the denormalized display totals. If
+ * nothing has ever been computed for the room and/or a given service line (e.g. config was off
+ * at order time), computes it fresh instead.
+ *
+ * @param int $idHtlBooking
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely
+ * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NOT_APPLICABLE
+ */
+ public static function applyBooking($idHtlBooking, $idEmployee = 0, $autoMsgLabel = null)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $booking = new HotelBookingDetail($idHtlBooking);
+ $wasExempted = self::isBookingExempted($idHtlBooking);
+ $changed = $wasExempted;
+ if ($wasExempted) {
+ if (!self::deleteExemptionMarker($idHtlBooking, 0)) {
+ return self::APPLY_ERROR_RESTORE;
+ }
+ self::deleteTourismRowsForScope(self::SCOPE_COLUMN_ROOM, $idHtlBooking);
+ }
+
+ if (self::hasNeverBeenComputed(self::SCOPE_COLUMN_ROOM, $idHtlBooking)) {
+ $params = self::buildRoomTaxParams($idHtlBooking);
+ if ($params) {
+ $params['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE;
+ self::saveTourismTaxFromParams($params);
+ $changed = true;
+ if (!$wasExempted) {
+ self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_ROOM, $idHtlBooking, $params['idOrder']);
+ }
+ }
+ }
+
+ $idServiceLines = ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking);
+ foreach ($idServiceLines as $idServiceLine) {
+ if ($wasExempted) {
+ self::deleteTourismRowsForScope(self::SCOPE_COLUMN_SERVICE, $idServiceLine);
+ }
+ if (!self::hasNeverBeenComputed(self::SCOPE_COLUMN_SERVICE, $idServiceLine)) {
+ continue;
+ }
+ $svcParams = self::buildServiceLineTaxParams($idServiceLine);
+ if ($svcParams) {
+ $svcParams['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE;
+ self::saveTourismTaxFromParams($svcParams);
+ $changed = true;
+ if (!$wasExempted) {
+ self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_SERVICE, $idServiceLine, $svcParams['idOrder']);
+ }
+ }
+ }
+
+ if ($wasExempted) {
+ self::syncDenormalizedTotalsForBooking($idHtlBooking, true);
+ }
+
+ $hasAnyTourismTax = self::getScopedTotal(self::SCOPE_COLUMN_ROOM, $idHtlBooking) > 0;
+ foreach ($idServiceLines as $idServiceLine) {
+ if ($hasAnyTourismTax) {
+ break;
+ }
+ $hasAnyTourismTax = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceLine) > 0;
+ }
+ if (!$hasAnyTourismTax) {
+ return self::APPLY_ERROR_NOT_APPLICABLE;
+ }
+
+ if ($autoMsgLabel !== null && $changed) {
+ $idEmployee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id;
+ $roomTypeName = Product::getProductName((int) $booking->id_product, null, Context::getContext()->language->id);
+ $autoMsg = $autoMsgLabel . ': ' . $booking->room_num . ' - ' . $roomTypeName . ': ('
+ . date('d/m/Y', strtotime($booking->date_from)) . ' - ' . date('d/m/Y', strtotime($booking->date_to)) . ')';
+ self::saveTourismTaxNote((int) $booking->id_order, $idEmployee, $autoMsg);
+ }
+
+ return self::APPLY_OK;
+ }
+
+ /**
+ * Exempt a single standalone (no room attachment) service line directly — the one case a
+ * booking-level marker can't cover, since there's no booking to attach it to.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @param int $idEmployee
+ * @param string|null $note
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label — null skips the Order Notes entry entirely
+ * @param string $remarkLabel Pre-translated "Remark" label
+ * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE
+ */
+ public static function exemptServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark')
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ if (self::isServiceLineExempted($idServiceProductOrderDetail)) {
+ return self::EXEMPT_OK;
+ }
+
+ $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail);
+ if (!Validate::isLoadedObject($serviceLine) || $serviceLine->id_htl_booking_detail) {
+ return self::EXEMPT_ERROR_NO_RULE;
+ }
+
+ $objOrderTaxExemption = new OrderTaxExemption();
+ $objOrderTaxExemption->id_htl_booking = 0;
+ $objOrderTaxExemption->id_service_product_order_detail = $idServiceProductOrderDetail;
+ $objOrderTaxExemption->id_order = (int) $serviceLine->id_order;
+ $objOrderTaxExemption->id_employee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id;
+ $objOrderTaxExemption->note = $note;
+ if (!$objOrderTaxExemption->add()) {
+ return self::EXEMPT_ERROR_SAVE;
+ }
+
+ if ($autoMsgLabel !== null) {
+ $autoMsg = $autoMsgLabel . ': ' . $serviceLine->name;
+ self::saveTourismTaxNote((int) $serviceLine->id_order, $objOrderTaxExemption->id_employee, $autoMsg, $note, $remarkLabel);
+ }
+
+ $total = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail);
+ if ($total) {
+ self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceProductOrderDetail);
+ self::adjustOrderAndInvoiceTotals((int) $serviceLine->id_order, -$total);
+ }
+
+ return self::EXEMPT_OK;
+ }
+
+ /**
+ * Apply (or re-apply) tourism tax for a single standalone service line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely
+ * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NOT_APPLICABLE
+ */
+ public static function applyServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $autoMsgLabel = null)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail);
+ $wasExempted = self::isServiceLineExempted($idServiceProductOrderDetail);
+ $changed = $wasExempted;
+ if ($wasExempted) {
+ if (!self::deleteExemptionMarker(0, $idServiceProductOrderDetail)) {
+ return self::APPLY_ERROR_RESTORE;
+ }
+ self::deleteTourismRowsForScope(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail);
+ }
+
+ if (self::hasNeverBeenComputed(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail)) {
+ $params = self::buildServiceLineTaxParams($idServiceProductOrderDetail);
+ if ($params) {
+ $params['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE;
+ self::saveTourismTaxFromParams($params);
+ $changed = true;
+ if (!$wasExempted) {
+ self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail, $params['idOrder']);
+ }
+ }
+ }
+
+ if ($wasExempted) {
+ $total = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail);
+ if ($total && Validate::isLoadedObject($serviceLine)) {
+ self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceProductOrderDetail);
+ self::adjustOrderAndInvoiceTotals((int) $serviceLine->id_order, $total);
+ }
+ }
+
+ if (self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail) <= 0) {
+ return self::APPLY_ERROR_NOT_APPLICABLE;
+ }
+
+ if ($autoMsgLabel !== null && $changed) {
+ $idEmployee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id;
+ $autoMsg = $autoMsgLabel . ': ' . $serviceLine->name;
+ self::saveTourismTaxNote((int) $serviceLine->id_order, $idEmployee, $autoMsg);
+ }
+
+ return self::APPLY_OK;
+ }
+
+ /**
+ * Exempt tourism tax for every booking (room + attached services) and every standalone service line
+ * of an order; returns only the failing entries.
+ *
+ * @param int $idOrder
+ * @param int $idEmployee
+ * @param string|null $note Reason for exemption, applied to every booking/line in the order
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label — null skips the Order Notes entry entirely, for every booking/line in the order
+ * @param string $remarkLabel Pre-translated "Remark" label
+ * @return array [{'id_htl_booking' => int, 'result' => int}, ...] plus
+ * [{'id_service_product_order_detail' => int, 'result' => int}, ...] — failures only
+ */
+ public static function exemptForOrder($idOrder, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark')
+ {
+ $idOrder = (int) $idOrder;
+ $failures = array();
+ $objBookingDetail = new HotelBookingDetail();
+ $bookings = $objBookingDetail->getBookingDataByOrderId($idOrder);
+ if ($bookings) {
+ foreach ($bookings as $booking) {
+ if (!empty($booking['is_refunded']) || !empty($booking['is_cancelled'])) {
+ continue;
+ }
+ $result = self::exemptBooking($booking['id'], $idEmployee, $note, $autoMsgLabel, $remarkLabel);
+ if ($result !== self::EXEMPT_OK) {
+ $failures[] = array('id_htl_booking' => (int) $booking['id'], 'result' => $result);
+ }
+ }
+ }
+ foreach (ServiceProductOrderDetail::getActiveStandaloneIdsByOrder($idOrder) as $idServiceLine) {
+ $result = self::exemptServiceLine($idServiceLine, $idEmployee, $note, $autoMsgLabel, $remarkLabel);
+ if ($result !== self::EXEMPT_OK) {
+ $failures[] = array('id_service_product_order_detail' => $idServiceLine, 'result' => $result);
+ }
+ }
+ return $failures;
+ }
+
+ /**
+ * Apply tourism tax for every booking and every standalone service line of an order; returns only
+ * the failing entries. A booking/line with nothing applicable (neither the room nor any attached
+ * service, or the standalone line itself, has a tourism tax rule) is not a failure — it's silently
+ * skipped, same as if it had simply been left alone.
+ *
+ * @param int $idOrder
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely, for every booking/line in the order
+ * @return array [{'id_htl_booking' => int, 'result' => int}, ...] plus
+ * [{'id_service_product_order_detail' => int, 'result' => int}, ...] — failures only
+ */
+ public static function applyForOrder($idOrder, $idEmployee = 0, $autoMsgLabel = null)
+ {
+ $idOrder = (int) $idOrder;
+ $failures = array();
+ $objBookingDetail = new HotelBookingDetail();
+ $bookings = $objBookingDetail->getBookingDataByOrderId($idOrder);
+ if ($bookings) {
+ foreach ($bookings as $booking) {
+ if (!empty($booking['is_refunded']) || !empty($booking['is_cancelled'])) {
+ continue;
+ }
+ $result = self::applyBooking($booking['id'], $idEmployee, $autoMsgLabel);
+ if ($result !== self::APPLY_OK && $result !== self::APPLY_ERROR_NOT_APPLICABLE) {
+ $failures[] = array('id_htl_booking' => (int) $booking['id'], 'result' => $result);
+ }
+ }
+ }
+ foreach (ServiceProductOrderDetail::getActiveStandaloneIdsByOrder($idOrder) as $idServiceLine) {
+ $result = self::applyServiceLine($idServiceLine, $idEmployee, $autoMsgLabel);
+ if ($result !== self::APPLY_OK && $result !== self::APPLY_ERROR_NOT_APPLICABLE) {
+ $failures[] = array('id_service_product_order_detail' => $idServiceLine, 'result' => $result);
+ }
+ }
+ return $failures;
+ }
+
+ /**
+ * After fresh-computing tourism tax for a scope that had no prior rows (e.g. config was off at
+ * order time), fold the newly-applied total into the order's own totals — recomputeInclusivePrice()
+ * (called inside saveTourismTax()) only syncs order_detail/htl_booking_detail/service_product_order_detail,
+ * never orders/order_invoice.
+ *
+ * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE
+ * @param int $scopeValue
+ * @param int $idOrder
+ * @return void
+ */
+ protected static function adjustOrderTotalsForNewRows($scopeColumn, $scopeValue, $idOrder)
+ {
+ $appliedTotal = self::getScopedTotal($scopeColumn, $scopeValue);
+ self::adjustOrderAndInvoiceTotals($idOrder, $appliedTotal);
+ }
+
+ /**
+ * Sum the room's own tax + every attached service line's tax and apply that whole delta once —
+ * used on exemptBooking()/applyBooking() to move the booking's tourism tax in or out of the
+ * denormalized display totals in one pass, rather than per-row.
+ *
+ * @param int $idHtlBooking
+ * @param bool $nowIncluded true = restoring (apply), false = removing (exempt)
+ * @return void
+ */
+ protected static function syncDenormalizedTotalsForBooking($idHtlBooking, $nowIncluded)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $booking = new HotelBookingDetail($idHtlBooking);
+ if (!Validate::isLoadedObject($booking)) {
+ return;
+ }
+ $totalDelta = 0.0;
+
+ $roomTotal = self::getScopedTotal(self::SCOPE_COLUMN_ROOM, $idHtlBooking);
+ if ($roomTotal) {
+ $prev = $nowIncluded ? 0.0 : $roomTotal;
+ $new = $nowIncluded ? $roomTotal : 0.0;
+ self::recomputeInclusivePrice((int) $booking->id_order_detail, $idHtlBooking, 0);
+ $totalDelta += ($new - $prev);
+ }
+
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking) as $idServiceLine) {
+ $serviceTotal = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceLine);
+ if (!$serviceTotal) {
+ continue;
+ }
+ $serviceLine = new ServiceProductOrderDetail($idServiceLine);
+ if (!Validate::isLoadedObject($serviceLine)) {
+ continue;
+ }
+ $prev = $nowIncluded ? 0.0 : $serviceTotal;
+ $new = $nowIncluded ? $serviceTotal : 0.0;
+ self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceLine);
+ $totalDelta += ($new - $prev);
+ }
+
+ self::adjustOrderAndInvoiceTotals((int) $booking->id_order, $totalDelta);
+ }
+
+ /**
+ * Apply an applied-tourism-tax delta to both the order's own totals and every invoice already generated for it.
+ *
+ * @param int $idOrder
+ * @param float $delta Signed — positive when restoring/adding tax, negative when exempting it
+ */
+ protected static function adjustOrderAndInvoiceTotals($idOrder, $delta)
+ {
+ $delta = (float) $delta;
+ if (!$delta) {
+ return;
+ }
+
+ $idOrder = (int) $idOrder;
+ Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'orders`
+ SET `total_paid_tax_incl` = GREATEST(0, `total_paid_tax_incl` + ' . $delta . '),
+ `total_paid_tax_excl` = GREATEST(0, `total_paid_tax_excl` + ' . $delta . '),
+ `total_paid` = GREATEST(0, `total_paid` + ' . $delta . ')
+ WHERE `id_order` = ' . $idOrder
+ );
+ Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_invoice`
+ SET `total_paid_tax_incl` = GREATEST(0, `total_paid_tax_incl` + ' . $delta . '),
+ `total_paid_tax_excl` = GREATEST(0, `total_paid_tax_excl` + ' . $delta . ')
+ WHERE `id_order` = ' . $idOrder
+ );
+ }
+}
\ No newline at end of file
diff --git a/classes/order/OrderTaxExemption.php b/classes/order/OrderTaxExemption.php
new file mode 100644
index 000000000..dbbac21c3
--- /dev/null
+++ b/classes/order/OrderTaxExemption.php
@@ -0,0 +1,46 @@
+ 'order_tax_exemption',
+ 'primary' => 'id_order_tax_exemption',
+ 'fields' => array(
+ 'id_htl_booking' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
+ 'id_service_product_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
+ 'id_order' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'id_employee' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'note' => array('type' => self::TYPE_STRING, 'validate' => 'isMessage', 'allow_null' => true),
+ 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
+ ),
+ );
+}
diff --git a/classes/pdf/HTMLTemplateBookingVoucher.php b/classes/pdf/HTMLTemplateBookingVoucher.php
index e282ac305..890a5772d 100644
--- a/classes/pdf/HTMLTemplateBookingVoucher.php
+++ b/classes/pdf/HTMLTemplateBookingVoucher.php
@@ -307,7 +307,9 @@ class HTMLTemplateBookingVoucherCore extends HTMLTemplate
$footer['total_without_discount_te'] = $footer['room_price_tax_excl'] + $footer['total_convenience_fee_te'] + $footer['additional_service_price_tax_excl'] + $footer['service_products_price_tax_excl'];
$totalWithoutDiscountTaxIncl = $roomsCostTaxIncl + $serviceProductsCostTaxIncl + $additionalServicesCostTaxIncl + $convenienceFeeTaxIncl;
- $footer['total_tax_without_discount'] = max(0, $totalWithoutDiscountTaxIncl - $footer['total_without_discount_te']);
+ $totalTourismTax = OrderTaxDetail::getOrderTourismTaxTotal((int) $this->order->id);
+ $footer['total_tax_without_discount'] = max(0, $totalWithoutDiscountTaxIncl - $footer['total_without_discount_te'] - $totalTourismTax);
+ $footer['total_tourism_tax'] = $totalTourismTax;
$grandTotal = max(0.0, $totalWithoutDiscountTaxIncl - $productDiscountsTaxIncl);
$footer['product_discounts_tax_incl'] = $productDiscountsTaxIncl;
$footer['total_paid_tax_incl'] = $grandTotal;
diff --git a/classes/pdf/HTMLTemplateInvoice.php b/classes/pdf/HTMLTemplateInvoice.php
index c72d26b01..514b001d4 100644
--- a/classes/pdf/HTMLTemplateInvoice.php
+++ b/classes/pdf/HTMLTemplateInvoice.php
@@ -609,7 +609,9 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate
$footer['total_without_discount_te'] = $footer['room_price_tax_excl'] + $footer['total_convenience_fee_te'] + $footer['additional_service_price_tax_excl'] + $footer['service_products_price_tax_excl'];
$footer['total_without_discount_ti'] = $footer['room_price_tax_incl'] + $footer['total_convenience_fee_ti'] + $footer['additional_service_price_tax_incl'] + $footer['service_products_price_tax_incl'];
- $footer['total_tax_without_discount'] = $footer['total_without_discount_ti'] - $footer['total_without_discount_te'];
+ $footer['total_tourism_tax'] = OrderTaxDetail::getOrderTourismTaxTotal((int) $this->order->id);
+
+ $footer['total_tax_without_discount'] = $footer['total_without_discount_ti'] - $footer['total_without_discount_te'] - $footer['total_tourism_tax'];
if ($footer['total_tax_without_discount'] < 0) {
$footer['total_tax_without_discount'] = 0;
}
@@ -732,6 +734,7 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate
'shipping_tax' => $this->order_invoice->getShippingTaxesBreakdown($this->order),
'ecotax_tax' => $this->order_invoice->getEcoTaxTaxesBreakdown(),
'wrapping_tax' => $this->order_invoice->getWrappingTaxesBreakdown(),
+ 'tourism_tax' => $this->order_invoice->getTourismTaxBreakdown(),
);
foreach ($breakdowns as $type => $bd) {
diff --git a/classes/pdf/HTMLTemplateOrderSlip.php b/classes/pdf/HTMLTemplateOrderSlip.php
index 16396243f..045eeb52a 100644
--- a/classes/pdf/HTMLTemplateOrderSlip.php
+++ b/classes/pdf/HTMLTemplateOrderSlip.php
@@ -267,10 +267,11 @@ class HTMLTemplateOrderSlipCore extends HTMLTemplateInvoice
// if one of the order details use the tax computation method the display will be different
return Db::getInstance()->getValue('
SELECT od.`tax_computation_method`
- FROM `' . _DB_PREFIX_ . 'order_detail_tax` odt
+ FROM `' . _DB_PREFIX_ . 'order_tax_detail` odt
LEFT JOIN `' . _DB_PREFIX_ . 'order_detail` od ON (od.`id_order_detail` = odt.`id_order_detail`)
WHERE od.`id_order` = ' . (int) $this->order->id . '
- AND od.`tax_computation_method` = ' . (int) TaxCalculator::ONE_AFTER_ANOTHER_METHOD)
+ AND od.`tax_computation_method` = ' . (int) TaxCalculator::ONE_AFTER_ANOTHER_METHOD . '
+ AND NOT EXISTS (SELECT 1 FROM `' . _DB_PREFIX_ . 'tax` tc WHERE tc.`id_tax` = odt.`id_tax` AND tc.`is_tourism_tax` = 1)')
|| Configuration::get('PS_INVOICE_TAXES_BREAKDOWN');
}
diff --git a/classes/tax/AverageTaxOfProductsTaxCalculator.php b/classes/tax/AverageTaxOfProductsTaxCalculator.php
index 272935920..3c07d12c6 100644
--- a/classes/tax/AverageTaxOfProductsTaxCalculator.php
+++ b/classes/tax/AverageTaxOfProductsTaxCalculator.php
@@ -20,9 +20,10 @@ class AverageTaxOfProductsTaxCalculator
$sql = 'SELECT t.id_tax, t.rate, od.total_price_tax_excl FROM '.$prefix.'orders o
INNER JOIN '.$prefix.'order_detail od ON od.id_order = o.id_order
- INNER JOIN '.$prefix.'order_detail_tax odt ON odt.id_order_detail = od.id_order_detail
+ INNER JOIN '.$prefix.'order_tax_detail odt ON odt.id_order_detail = od.id_order_detail
INNER JOIN '.$prefix.'tax t ON t.id_tax = odt.id_tax
- WHERE o.id_order = '.(int)$this->id_order;
+ WHERE o.id_order = '.(int)$this->id_order.'
+ AND NOT EXISTS (SELECT 1 FROM '.$prefix.'tax tc WHERE tc.id_tax = odt.id_tax AND tc.is_tourism_tax = 1)';
return $this->db->select($sql);
}
diff --git a/classes/tax/Tax.php b/classes/tax/Tax.php
index a7f8ee1c3..8bfff493c 100644
--- a/classes/tax/Tax.php
+++ b/classes/tax/Tax.php
@@ -39,6 +39,9 @@ class TaxCore extends ObjectModel
/** @var bool true if the tax has been historized */
public $deleted = 0;
+ /** @var int 1 = this ps_tax row is a tourism tax (subtype params in tourism_tax) */
+ public $is_tourism_tax = 0;
+
/**
* @see ObjectModel::$definition
*/
@@ -47,11 +50,12 @@ class TaxCore extends ObjectModel
'primary' => 'id_tax',
'multilang' => true,
'fields' => array(
- 'rate' => array('type' => self::TYPE_FLOAT, 'validate' => 'isFloat', 'required' => true),
- 'active' => array('type' => self::TYPE_BOOL),
- 'deleted' => array('type' => self::TYPE_BOOL),
+ 'rate' => array('type' => self::TYPE_FLOAT, 'validate' => 'isFloat', 'required' => true),
+ 'active' => array('type' => self::TYPE_BOOL),
+ 'deleted' => array('type' => self::TYPE_BOOL),
+ 'is_tourism_tax' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
/* Lang fields */
- 'name' => array('type' => self::TYPE_STRING, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 32),
+ 'name' => array('type' => self::TYPE_STRING, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 32),
),
);
@@ -133,7 +137,7 @@ class TaxCore extends ObjectModel
{
return Db::getInstance()->getValue('
SELECT `id_tax`
- FROM `'._DB_PREFIX_.'order_detail_tax`
+ FROM `'._DB_PREFIX_.'order_tax_detail`
WHERE `id_tax` = '.(int)$this->id
);
}
diff --git a/classes/tax/TaxCalculator.php b/classes/tax/TaxCalculator.php
index df2d2d6df..38508d21d 100644
--- a/classes/tax/TaxCalculator.php
+++ b/classes/tax/TaxCalculator.php
@@ -132,14 +132,155 @@ class TaxCalculatorCore
/**
* Return the tax amount associated to each taxes of the TaxCalculator
*
- * @param float $price_te
+ * @param float $price_te
+ * @param string|null $checkInDate
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges
+ * @param int|null $collectionType
+ * @param int $quantity
+ * @param int|null $idCurrency
* @return array $taxes_amount
*/
- public function getTaxesAmount($price_te)
- {
+ public function getTaxesAmount(
+ $price_te,
+ $checkInDate = null,
+ $numNights = 1,
+ $numAdults = 1,
+ $childrenAges = array(),
+ $collectionType = null,
+ $quantity = 1,
+ $idCurrency = null,
+ &$validNightsByTax = null
+ ) {
$taxes_amounts = array();
+ $tourismTaxIds = array();
foreach ($this->taxes as $tax) {
+ if (TaxConfiguration::getByTaxId((int) $tax->id)) {
+ $tourismTaxIds[] = $tax->id;
+ }
+ }
+
+ if ($checkInDate !== null && $collectionType !== null && Configuration::get('QLO_USE_TOURISM_TAX') && $collectionType !== HotelBranchInformation::TAX_COLLECTION_TYPE_AT_HOTEL) {
+ if (!$idCurrency) {
+ $context = Context::getContext();
+ $idCurrency = Validate::isLoadedObject($context->currency) ? (int) $context->currency->id : (int) Configuration::get('PS_CURRENCY_DEFAULT');
+ }
+
+ $checkIn = new DateTime($checkInDate);
+ $isoDayIndex = $checkIn->format('N') - 1;
+
+ $infantMaxAge = Configuration::get('QLO_GLOBAL_MAX_INFANT_AGE');
+ $childMaxAge = Configuration::get('WK_GLOBAL_CHILD_MAX_AGE');
+ $eligibleChildAges = array_filter($childrenAges, function ($age) use ($infantMaxAge, $childMaxAge) {
+ return $age >= $infantMaxAge && $age < $childMaxAge;
+ });
+
+ $runningPriceExcl = (float) $price_te;
+
+ foreach ($this->taxes as $tax) {
+ $tourismTax = TaxConfiguration::getByTaxId((int) $tax->id);
+ if (!$tourismTax) {
+ continue;
+ }
+
+ $hasMultiRanges = (bool) $tourismTax->has_multiple_valid_ranges;
+ $validityRanges = $hasMultiRanges ? TaxValidityRange::getByTaxId((int) $tax->id) : null;
+
+ $specialDays = null;
+ if ($tourismTax->special_days) {
+ $decoded = json_decode($tourismTax->special_days, true);
+ $specialDays = is_array($decoded) ? $decoded : array();
+ }
+
+ $isPerNight = (bool) $tourismTax->per_night;
+
+ if ($isPerNight) {
+ $validNights = 0;
+ for ($i = 0; $i < $numNights; $i++) {
+ $night = clone $checkIn;
+ $night->modify('+' . $i . ' days');
+ if ($hasMultiRanges && !TaxValidityRange::dateMatchesAnyRange($validityRanges, $night)) {
+ continue;
+ }
+ if ($specialDays !== null && !in_array(TaxConfiguration::DAY_KEYS[$night->format('N') - 1], $specialDays)) {
+ continue;
+ }
+ $validNights++;
+ }
+ if ($validNights === 0) {
+ continue;
+ }
+ } else {
+ if ($hasMultiRanges && !TaxValidityRange::dateMatchesAnyRange($validityRanges, $checkIn)) {
+ continue;
+ }
+ if ($specialDays !== null && !in_array(TaxConfiguration::DAY_KEYS[$isoDayIndex], $specialDays)) {
+ continue;
+ }
+ $validNights = $numNights;
+ }
+
+ if ($validNightsByTax !== null) {
+ $validNightsByTax[$tax->id] = $validNights;
+ }
+
+ $taxType = (int) $tourismTax->calculation_type;
+
+ $baseValue = (float) $tourismTax->tax_value;
+ if ($tourismTax->has_tiered_pricing) {
+ $tier = TaxPriceTier::getMatchingTier((int) $tax->id, $runningPriceExcl);
+ if ($tier) {
+ $baseValue = (float) $tier['tax_value'];
+ }
+ }
+
+ $adultMultiplier = 1;
+ if ($isPerNight) {
+ $adultMultiplier *= $validNights;
+ }
+ if ($tourismTax->per_person) {
+ $adultMultiplier *= $numAdults;
+ }
+
+ if ($taxType === TaxConfiguration::CALCULATION_TYPE_FIXED) {
+ $unitAmountAdult = $baseValue;
+ $totalAmountAdult = $baseValue * $adultMultiplier;
+ } else {
+ $unitAmountAdult = $runningPriceExcl * ($baseValue / 100);
+ $totalAmountAdult = $unitAmountAdult * $adultMultiplier;
+ }
+
+ $totalAmountChild = 0.0;
+ if ($tourismTax->apply_on_child && !empty($eligibleChildAges)) {
+ $contribution = TaxChildRange::getChildContribution(
+ $tax->id,
+ $eligibleChildAges,
+ (int) $tourismTax->child_calculation_type,
+ $unitAmountAdult,
+ $tourismTax->has_child_age_range
+ );
+ if ($contribution['count'] > 0) {
+ $nightMultiplier = $isPerNight ? $validNights : 1;
+ $totalAmountChild = $contribution['total'] * $nightMultiplier;
+ }
+ }
+
+ if ($this->computation_method == TaxCalculator::ONE_AFTER_ANOTHER_METHOD) {
+ $runningPriceExcl += $unitAmountAdult;
+ }
+
+ $totalAmountAdult = Tools::convertPrice($totalAmountAdult, $idCurrency);
+ $totalAmountChild = Tools::convertPrice($totalAmountChild, $idCurrency);
+ $taxes_amounts[$tax->id] = Tools::ps_round(($totalAmountAdult + $totalAmountChild) * max(1, $quantity), 6);
+ }
+ }
+
+ foreach ($this->taxes as $tax) {
+ if (in_array($tax->id, $tourismTaxIds)) {
+ continue;
+ }
if (!isset($taxes_amounts[$tax->id])) {
$taxes_amounts[$tax->id] = 0;
}
@@ -155,20 +296,59 @@ class TaxCalculatorCore
}
/**
- * Return the total taxes amount
*
* @param float $price_te
* @return float $amount
*/
- public function getTaxesTotalAmount($price_te)
- {
+ public function getTaxesTotalAmount(
+ $price_te,
+ $checkInDate = null,
+ $numNights = 1,
+ $numAdults = 1,
+ $childrenAges = array(),
+ $collectionType = null,
+ $quantity = 1,
+ $idCurrency = null
+ ) {
$amount = 0;
- $taxes = $this->getTaxesAmount($price_te);
+ $taxes = $this->getTaxesAmount($price_te, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity, $idCurrency);
foreach ($taxes as $tax) {
$amount += $tax;
}
return $amount;
}
+
+ /**
+ *
+ * @param float $price_te
+ * @param string $checkInDate
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges
+ * @param int $collectionType
+ * @param int $quantity
+ * @return float price with tourism tax
+ */
+ public function addTourismTaxes($price_te, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity = 1)
+ {
+ return $price_te + $this->getTaxesTotalAmount($price_te, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity);
+ }
+
+ /**
+ *
+ * @param float $price_ti
+ * @param string $checkInDate 'YYYY-MM-DD'
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges Ages at check-in date
+ * @param int $collectionType HotelBranchInformation::TAX_COLLECTION_TYPE_*
+ * @param int $quantity Line quantity multiplier
+ * @return float price without tourism tax
+ */
+ public function removeTourismTaxes($price_ti, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity = 1)
+ {
+ return $price_ti - $this->getTaxesTotalAmount($price_ti, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity);
+ }
}
diff --git a/classes/tax/TaxChildRange.php b/classes/tax/TaxChildRange.php
new file mode 100644
index 000000000..58d861895
--- /dev/null
+++ b/classes/tax/TaxChildRange.php
@@ -0,0 +1,134 @@
+ 'tax_child_range',
+ 'primary' => 'id_tax_child_range',
+ 'fields' => array(
+ 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'min_age' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'),
+ 'max_age' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'),
+ 'tax_value' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ ),
+ );
+
+ /**
+ * All child age ranges for a tax in entry order (id_tax_child_range order), cached per request.
+ *
+ * @param int $idTax
+ * @return array
+ */
+ public static function getByTaxId($idTax)
+ {
+ $idTax = (int) $idTax;
+ $cacheId = 'TaxChildRange::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $ranges = Db::getInstance()->executeS(
+ 'SELECT * FROM `' . _DB_PREFIX_ . 'tax_child_range`
+ WHERE `id_tax` = ' . $idTax . '
+ ORDER BY `id_tax_child_range` ASC'
+ );
+ Cache::store($cacheId, $ranges);
+
+ return $ranges;
+ }
+
+ /**
+ * Replace all child range rows for $idTax with the submitted arrays, skipping entries where tax_value is empty.
+ *
+ * @param int $idTax
+ * @param array $mins
+ * @param array $maxs
+ * @param array $values
+ * @return void
+ */
+ public static function saveAll($idTax, $mins, $maxs, $values)
+ {
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_child_range`
+ WHERE `id_tax` = ' . (int) $idTax
+ );
+ foreach ($values as $rowIndex => $value) {
+ if ($value === '' || $value === false) {
+ continue;
+ }
+ $range = new TaxChildRange();
+ $range->id_tax = (int) $idTax;
+ $range->min_age = isset($mins[$rowIndex]) ? (int) $mins[$rowIndex] : 0;
+ $range->max_age = isset($maxs[$rowIndex]) ? (int) $maxs[$rowIndex] : 0;
+ $range->tax_value = (float) $value;
+ $range->add();
+ }
+ }
+
+ /**
+ * Sum per-band tax contributions for the given child ages — each child matched to the first qualifying
+ * band (band value interpreted per $childCalcType); unmatched children pay the adult's own already-computed
+ * per-unit tourism tax amount flatly, since they're not governed by any band.
+ *
+ * @param int $idTax
+ * @param int[] $ages Ages at check-in date
+ * @param int $childCalcType 0=fixed, 1=percentage — governs matched-band contributions only
+ * @param float $unitTourismTaxAmountAdult Adult's already-computed per-unit tourism tax amount (tier-aware);
+ * @param bool $useRanges false = skip band lookup, every child is treated as unmatched
+ * @return array ['total' => float, 'count' => int]
+ */
+ public static function getChildContribution($idTax, $ages, $childCalcType, $unitTourismTaxAmountAdult, $useRanges = true)
+ {
+ $ranges = $useRanges ? self::getByTaxId((int) $idTax) : array();
+ $total = 0.0;
+ $count = 0;
+ foreach ($ages as $age) {
+ $age = (int) $age;
+ $matched = false;
+ foreach ($ranges as $range) {
+ $maxAge = (int) $range['max_age'];
+ $inLower = ($age >= (int) $range['min_age']);
+ $inUpper = ($maxAge == 0 || $age <= $maxAge);
+ if ($inLower && $inUpper) {
+ $bandValue = (float) $range['tax_value'];
+ $total += ($childCalcType === TaxConfiguration::CALCULATION_TYPE_FIXED) ? $bandValue : ($unitTourismTaxAmountAdult * ($bandValue / 100));
+ $count++;
+ $matched = true;
+ break;
+ }
+ }
+ if (!$matched) {
+ $total += $unitTourismTaxAmountAdult;
+ $count++;
+ }
+ }
+ return array('total' => $total, 'count' => $count);
+ }
+}
diff --git a/classes/tax/TaxConfiguration.php b/classes/tax/TaxConfiguration.php
new file mode 100644
index 000000000..141761ee9
--- /dev/null
+++ b/classes/tax/TaxConfiguration.php
@@ -0,0 +1,365 @@
+ 'tax_configuration',
+ 'primary' => 'id_tax',
+ 'fields' => array(
+ 'tax_value' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ 'calculation_type' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'),
+ 'per_night' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'per_person' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'has_tiered_pricing' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'apply_on_child' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'has_child_age_range' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'child_calculation_type' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'),
+ 'has_multiple_valid_ranges' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'special_days' => array('type' => self::TYPE_STRING),
+ ),
+ );
+
+ /**
+ * Override add() because id_tax is a FK-as-PK — the base ObjectModel::add() never writes the primary key column, which would default to 0 and create phantom rows.
+ *
+ * @param bool $auto_date ignored — table has no date_add/date_upd
+ * @param bool $null_values ignored
+ * @return bool
+ */
+ public function add($auto_date = true, $null_values = false)
+ {
+ $idTax = (int) $this->id;
+ if (!$idTax) {
+ return false;
+ }
+ $result = Db::getInstance()->insert(
+ 'tax_configuration',
+ array(
+ 'id_tax' => $idTax,
+ 'tax_value' => (float) $this->tax_value,
+ 'calculation_type' => (int) $this->calculation_type,
+ 'per_night' => (int) $this->per_night,
+ 'per_person' => (int) $this->per_person,
+ 'has_tiered_pricing' => (int) $this->has_tiered_pricing,
+ 'apply_on_child' => (int) $this->apply_on_child,
+ 'has_child_age_range' => (int) $this->has_child_age_range,
+ 'child_calculation_type' => (int) $this->child_calculation_type,
+ 'has_multiple_valid_ranges' => (int) $this->has_multiple_valid_ranges,
+ 'special_days' => $this->special_days ? pSQL($this->special_days) : null,
+ )
+ );
+ if ($result) {
+ $this->id_tax = $idTax;
+ }
+ return $result;
+ }
+
+ /**
+ * Load a tourism tax subtype row by id_tax, cached per request.
+ *
+ * @param int $idTax
+ * @return TaxConfiguration|false
+ */
+ public static function getByTaxId($idTax)
+ {
+ $idTax = (int) $idTax;
+ $cacheId = 'TaxConfiguration::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $obj = new TaxConfiguration($idTax);
+ $result = Validate::isLoadedObject($obj) ? $obj : false;
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Shared core for cleanFromTourismTaxRulesGroups()/cleanFromVatTaxRulesGroups().
+ *
+ * @param int $idTax
+ * @param bool $isTourismTaxRulesGroup true = clean from tourism tax rules groups, false = clean from VAT tax rules groups
+ * @return int
+ */
+ protected static function cleanTaxRulesGroupsByTourismFlag($idTax, $isTourismTaxRulesGroup)
+ {
+ $flag = $isTourismTaxRulesGroup ? 1 : 0;
+ $db = Db::getInstance();
+ $db->execute(
+ 'DELETE tr FROM `' . _DB_PREFIX_ . 'tax_rule` tr
+ INNER JOIN `' . _DB_PREFIX_ . 'tax_rules_group` trg
+ ON trg.`id_tax_rules_group` = tr.`id_tax_rules_group`
+ WHERE tr.`id_tax` = ' . (int) $idTax . '
+ AND trg.`is_tourism_tax_rule_group` = ' . $flag
+ );
+ return $db->Affected_Rows();
+ }
+
+ /**
+ * Remove this tax from all tourism tax rules groups.
+ *
+ * @param int $idTax
+ * @return int
+ */
+ public static function cleanFromTourismTaxRulesGroups($idTax)
+ {
+ return self::cleanTaxRulesGroupsByTourismFlag($idTax, true);
+ }
+
+ /**
+ * Remove this tax from all standard (non-tourism) tax rules groups — symmetric counterpart of cleanFromTourismTaxRulesGroups().
+ *
+ * @param int $idTax
+ * @return int
+ */
+ public static function cleanFromVatTaxRulesGroups($idTax)
+ {
+ return self::cleanTaxRulesGroupsByTourismFlag($idTax, false);
+ }
+
+ /**
+ * Format a tax rate/amount for display, VAT as a percentage and tourism tax per its own calc type.
+ *
+ * @param mixed $value ps_tax.rate (used for non-tourism rows)
+ * @param array $row List row — must include is_tourism_tax, tourism_tax_calc_type, tourism_tax_value
+ * @param Currency $currency
+ * @return string
+ */
+ public static function getFormattedRateForDisplay($value, $row, $currency)
+ {
+ if (!(int) $row['is_tourism_tax']) {
+ return Tools::safeOutput($value) . '%';
+ }
+ $displayValue = Tools::safeOutput((float) $row['tourism_tax_value']);
+ if ((int) $row['tourism_tax_calc_type'] === self::CALCULATION_TYPE_PERCENTAGE) {
+ return $displayValue . '%';
+ }
+ $currencySign = Tools::safeOutput(trim($currency->prefix . $currency->suffix));
+ return $currencySign . ' ' . $displayValue;
+ }
+
+ /**
+ * Delete the subtype row and all related tiers/child-ranges.
+ *
+ * @return bool
+ */
+ public function delete()
+ {
+ if (parent::delete()) {
+ $idTax = (int) $this->id;
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_price_tier`
+ WHERE `id_tax` = ' . $idTax
+ );
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_child_range`
+ WHERE `id_tax` = ' . $idTax
+ );
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_validity_range`
+ WHERE `id_tax` = ' . $idTax
+ );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Whether tourism tax should be visually folded into the displayed room/service price.
+ *
+ * @param float $tourismTax
+ * @return bool
+ */
+ public static function isGrossedUp($tourismTax)
+ {
+ return (float) $tourismTax > 0 && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
+ }
+
+ /**
+ * Resolve the hotel's own jurisdiction address and current tourism-tax collection type — shared by every cart, order, and preview caller that needs this context.
+ *
+ * @param int $idHotel 0 = no hotel context (fully standalone product)
+ * @param Address $fallbackAddress Used only when the hotel has no address on file
+ * @return array ['address' => Address, 'collectionType' => int]
+ */
+ public static function resolveHotelAddressAndCollectionType($idHotel, $fallbackAddress)
+ {
+ $idHotel = (int) $idHotel;
+
+ $address = null;
+ if ($idHotel) {
+ $hotelAddress = HotelBranchInformation::getAddress($idHotel);
+ if ($hotelAddress && !empty($hotelAddress['id_address'])) {
+ $address = new Address((int) $hotelAddress['id_address']);
+ }
+ }
+ if (!$address || !Validate::isLoadedObject($address)) {
+ $address = $fallbackAddress;
+ }
+
+ $collectionType = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE;
+ if ($idHotel && Validate::isLoadedObject($hotelBranch = new HotelBranchInformation($idHotel))) {
+ $collectionType = (int) $hotelBranch->tourism_tax_collection_type;
+ }
+
+ return array(
+ 'address' => $address,
+ 'collectionType' => $collectionType,
+ );
+ }
+
+ /**
+ * Resolve the jurisdiction address, collection type, and occupancy (check-in/nights/adults/children) a service product line needs before computing its tourism tax.
+ *
+ * @param int $idHotel 0 = no hotel context (fully standalone product)
+ * @param int $idHtlBookingDetail 0 = not attached to an order-side room booking
+ * @param Address $fallbackAddress Used only when the hotel has no address on file
+ * @param int $idHtlCartBooking 0 = not attached to a cart-side room booking (pre-order preview only —
+ * ignored when $idHtlBookingDetail is given)
+ * @return array ['address' => Address, 'collectionType' => int, 'checkInDate' => string,
+ * 'numNights' => int, 'numAdults' => int, 'childrenAges' => int[]]
+ */
+ public static function resolveServiceLineTaxContext($idHotel, $idHtlBookingDetail, $fallbackAddress, $idHtlCartBooking = 0)
+ {
+ $idHotel = (int) $idHotel;
+ $idHtlBookingDetail = (int) $idHtlBookingDetail;
+ $idHtlCartBooking = (int) $idHtlCartBooking;
+
+ $hotelContext = self::resolveHotelAddressAndCollectionType($idHotel, $fallbackAddress);
+ $address = $hotelContext['address'];
+ $collectionType = $hotelContext['collectionType'];
+
+ $roomBooking = $idHtlBookingDetail ? new HotelBookingDetail($idHtlBookingDetail) : null;
+ $cartBooking = (!$roomBooking && $idHtlCartBooking) ? new HotelCartBookingData($idHtlCartBooking) : null;
+ if ($roomBooking && Validate::isLoadedObject($roomBooking)) {
+ $checkInDate = $roomBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($roomBooking->date_from, $roomBooking->date_to));
+ $numAdults = (int) $roomBooking->adults;
+ $childrenAges = array();
+ if ($roomBooking->child_ages) {
+ $decoded = json_decode($roomBooking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+ } elseif ($cartBooking && Validate::isLoadedObject($cartBooking)) {
+ $checkInDate = $cartBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($cartBooking->date_from, $cartBooking->date_to));
+ $numAdults = (int) $cartBooking->adults;
+ $childrenAges = !empty($cartBooking->child_ages) ? (array) json_decode($cartBooking->child_ages, true) : array();
+ } else {
+ $checkInDate = date('Y-m-d');
+ $numNights = 1;
+ $numAdults = 1;
+ $childrenAges = array();
+ }
+
+ return array(
+ 'address' => $address,
+ 'collectionType' => $collectionType,
+ 'checkInDate' => $checkInDate,
+ 'numNights' => $numNights,
+ 'numAdults' => $numAdults,
+ 'childrenAges' => $childrenAges,
+ );
+ }
+
+ /**
+ * Preview parameters for a tax rules group — a deliberate 1-adult/1-night approximation for js/admin/price.js's live price preview.
+ *
+ * @param int $idTaxRulesGroup
+ * @param Address $address
+ * @param int $idLang
+ * @return array List of ['tax_calc_type','is_tiered','tax_value','tiers']
+ */
+ public static function getPreviewParams($idTaxRulesGroup, $address, $idLang, $collectionType = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE)
+ {
+ $idTaxRulesGroup = (int) $idTaxRulesGroup;
+ if (!$idTaxRulesGroup || Tax::excludeTaxeOption() || (int) $collectionType === HotelBranchInformation::TAX_COLLECTION_TYPE_AT_HOTEL) {
+ return array();
+ }
+
+ $taxCalculator = TaxManagerFactory::getManager($address, $idTaxRulesGroup)->getTaxCalculator();
+ $preview = array();
+
+ $today = new DateTime(date('Y-m-d'));
+ $todayDayKey = self::DAY_KEYS[$today->format('N') - 1];
+
+ foreach ($taxCalculator->taxes as $tax) {
+ $tourismTax = self::getByTaxId((int) $tax->id);
+ if (!$tourismTax) {
+ continue;
+ }
+
+ if ((bool) $tourismTax->has_multiple_valid_ranges
+ && !TaxValidityRange::dateMatchesAnyRange(TaxValidityRange::getByTaxId((int) $tax->id), $today)
+ ) {
+ continue;
+ }
+ if ($tourismTax->special_days) {
+ $specialDays = json_decode($tourismTax->special_days, true);
+ if (!is_array($specialDays) || !in_array($todayDayKey, $specialDays)) {
+ continue;
+ }
+ }
+
+ $tiers = array();
+ if ((bool) $tourismTax->has_tiered_pricing) {
+ foreach (TaxPriceTier::getByTaxId((int) $tax->id) as $tier) {
+ $tiers[] = array(
+ 'min_amount' => (float) $tier['min_amount'],
+ 'max_amount' => (float) $tier['max_amount'],
+ 'tax_value' => (float) $tier['tax_value'],
+ );
+ }
+ }
+ $preview[] = array(
+ 'tax_calc_type' => (int) $tourismTax->calculation_type,
+ 'is_tiered' => (bool) $tourismTax->has_tiered_pricing,
+ 'tax_value' => (float) $tourismTax->tax_value,
+ 'tiers' => $tiers,
+ );
+ }
+
+ return $preview;
+ }
+}
diff --git a/classes/tax/TaxPriceTier.php b/classes/tax/TaxPriceTier.php
new file mode 100644
index 000000000..49550c496
--- /dev/null
+++ b/classes/tax/TaxPriceTier.php
@@ -0,0 +1,116 @@
+ 'tax_price_tier',
+ 'primary' => 'id_tax_price_tier',
+ 'fields' => array(
+ 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'min_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ 'max_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ 'tax_value' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'),
+ ),
+ );
+
+ /**
+ * All tiers for a tax in entry order (id_tax_price_tier order), cached per request.
+ *
+ * @param int $idTax
+ * @return array
+ */
+ public static function getByTaxId($idTax)
+ {
+ $idTax = (int) $idTax;
+ $cacheId = 'TaxPriceTier::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $tiers = Db::getInstance()->executeS(
+ 'SELECT * FROM `' . _DB_PREFIX_ . 'tax_price_tier`
+ WHERE `id_tax` = ' . $idTax . '
+ ORDER BY `id_tax_price_tier` ASC'
+ );
+ Cache::store($cacheId, $tiers);
+
+ return $tiers;
+ }
+
+ /**
+ * Find the matching tier for a given unit price, or false when no tier matches.
+ *
+ * @param int $idTax
+ * @param float $unitPrice
+ * @return array|false
+ */
+ public static function getMatchingTier($idTax, $unitPrice)
+ {
+ $tiers = self::getByTaxId($idTax);
+ foreach ($tiers as $tier) {
+ $maxAmount = (float) $tier['max_amount'];
+ $inLower = ($unitPrice >= (float) $tier['min_amount']);
+ $inUpper = ($maxAmount == 0 || $unitPrice <= $maxAmount);
+ if ($inLower && $inUpper) {
+ return $tier;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Replace all tier rows for $idTax with the submitted arrays, skipping entries where tax_value is empty.
+ *
+ * @param int $idTax
+ * @param array $mins
+ * @param array $maxs
+ * @param array $values
+ * @return void
+ */
+ public static function saveAll($idTax, $mins, $maxs, $values)
+ {
+ $idTax = (int) $idTax;
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_price_tier`
+ WHERE `id_tax` = ' . $idTax
+ );
+ foreach ($values as $rowIndex => $value) {
+ if ($value === '' || $value === false) {
+ continue;
+ }
+ $tier = new TaxPriceTier();
+ $tier->id_tax = $idTax;
+ $tier->min_amount = isset($mins[$rowIndex]) ? (float) $mins[$rowIndex] : 0;
+ $tier->max_amount = isset($maxs[$rowIndex]) ? (float) $maxs[$rowIndex] : 0;
+ $tier->tax_value = (float) $value;
+ $tier->add();
+ }
+ }
+}
diff --git a/classes/tax/TaxRulesGroup.php b/classes/tax/TaxRulesGroup.php
index c37d6367a..bf62813a7 100644
--- a/classes/tax/TaxRulesGroup.php
+++ b/classes/tax/TaxRulesGroup.php
@@ -40,6 +40,9 @@ class TaxRulesGroupCore extends ObjectModel
/** @var string Object last modification date */
public $date_upd;
+ /** @var int 1 = all rules in this TRG reference tourism taxes only */
+ public $is_tourism_tax_rule_group = 0;
+
/**
* @see ObjectModel::$definition
*/
@@ -47,11 +50,12 @@ class TaxRulesGroupCore extends ObjectModel
'table' => 'tax_rules_group',
'primary' => 'id_tax_rules_group',
'fields' => array(
- 'name' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 64),
- 'active' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
- 'deleted' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
- 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
- 'date_upd' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
+ 'name' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 64),
+ 'active' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'deleted' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'is_tourism_tax_rule_group' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
+ 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
+ 'date_upd' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
),
);
@@ -139,23 +143,31 @@ class TaxRulesGroupCore extends ObjectModel
return false;
}
- public static function getTaxRulesGroups($only_active = true)
+ /**
+ * @param bool $only_active
+ * @param bool $isTourismTax true = return only tourism TRGs; false (default) = return standard TRGs
+ * @return array
+ */
+ public static function getTaxRulesGroups($only_active = true, $isTourismTax = false)
{
return Db::getInstance()->executeS('
SELECT DISTINCT g.id_tax_rules_group, g.name, g.active
FROM `'._DB_PREFIX_.'tax_rules_group` g'
- .Shop::addSqlAssociation('tax_rules_group', 'g').' WHERE deleted = 0'
+ .Shop::addSqlAssociation('tax_rules_group', 'g').' WHERE deleted = 0
+ AND g.`is_tourism_tax_rule_group` = '.($isTourismTax ? 1 : 0)
.($only_active ? ' AND g.`active` = 1' : '').'
ORDER BY name ASC');
}
/**
- * @return array an array of tax rules group formatted as $id => $name
- */
- public static function getTaxRulesGroupsForOptions()
+ * @param bool $onlyActive
+ * @param bool $isTourismTax true = tourism TRG list with "None" placeholder; false (default) = standard TRG list with "No tax" placeholder
+ * @return array
+ */
+ public static function getTaxRulesGroupsForOptions($onlyActive = true, $isTourismTax = false)
{
- $tax_rules[] = array('id_tax_rules_group' => 0, 'name' => Tools::displayError('No tax'));
- return array_merge($tax_rules, TaxRulesGroup::getTaxRulesGroups());
+ $tax_rules[] = array('id_tax_rules_group' => 0, 'name' => Tools::displayError('No Tax'));
+ return array_merge($tax_rules, TaxRulesGroup::getTaxRulesGroups($onlyActive, $isTourismTax));
}
public function delete()
@@ -222,6 +234,61 @@ class TaxRulesGroupCore extends ObjectModel
);
}
+ /**
+ * @return bool true if any of this group's tax rules reference a tourism tax
+ */
+ public function hasTourismTaxRules()
+ {
+ return (bool) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT tr.`id_tax_rule` FROM `'._DB_PREFIX_.'tax_rule` tr
+ INNER JOIN `'._DB_PREFIX_.'tax` t ON t.`id_tax` = tr.`id_tax`
+ WHERE tr.`id_tax_rules_group` = '.(int) $this->id.'
+ AND t.`is_tourism_tax` = 1'
+ );
+ }
+
+ /**
+ * @return bool true if any product currently uses this group as its tourism tax rule
+ */
+ public function hasProductsUsingAsTourismGroup()
+ {
+ return (bool) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT `id_product` FROM `'._DB_PREFIX_.'product_shop`
+ WHERE `id_tourism_tax_rules_group` = '.(int) $this->id
+ );
+ }
+
+ /**
+ * @return bool true if any of this group's tax rules reference a non-tourism (VAT) tax
+ */
+ public function hasVatTaxRules()
+ {
+ return (bool) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT tr.`id_tax_rule` FROM `'._DB_PREFIX_.'tax_rule` tr
+ INNER JOIN `'._DB_PREFIX_.'tax` t ON t.`id_tax` = tr.`id_tax`
+ WHERE tr.`id_tax_rules_group` = '.(int) $this->id.'
+ AND tr.`id_tax` != 0
+ AND t.`is_tourism_tax` = 0'
+ );
+ }
+
+ /**
+ * @return bool true if any product or service price currently uses this group as its regular (VAT) tax rule
+ */
+ public function hasProductsUsingAsVatGroup()
+ {
+ $hasRegularProductConflict = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT `id_product` FROM `'._DB_PREFIX_.'product_shop`
+ WHERE `id_tax_rules_group` = '.(int) $this->id
+ );
+ $hasServicePriceConflict = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT `id_room_type_service_product_price` FROM `'._DB_PREFIX_.'htl_room_type_service_product_price`
+ WHERE `id_tax_rules_group` = '.(int) $this->id
+ );
+
+ return (bool) ($hasRegularProductConflict || $hasServicePriceConflict);
+ }
+
/**
* @deprecated since 1.5
*/
diff --git a/classes/tax/TaxValidityRange.php b/classes/tax/TaxValidityRange.php
new file mode 100644
index 000000000..91aa3dc0c
--- /dev/null
+++ b/classes/tax/TaxValidityRange.php
@@ -0,0 +1,113 @@
+ 'tax_validity_range',
+ 'primary' => 'id_tax_validity_range',
+ 'fields' => array(
+ 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'valid_from' => array('type' => self::TYPE_DATE, 'validate' => 'isDate', 'allow_null' => true),
+ 'valid_to' => array('type' => self::TYPE_DATE, 'validate' => 'isDate', 'allow_null' => true),
+ ),
+ );
+
+ /**
+ * All validity ranges for a tax in entry order (id_tax_validity_range order), cached per request.
+ *
+ * @param int $idTax
+ * @return array
+ */
+ public static function getByTaxId($idTax)
+ {
+ $idTax = (int) $idTax;
+ $cacheId = 'TaxValidityRange::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $ranges = Db::getInstance()->executeS(
+ 'SELECT * FROM `' . _DB_PREFIX_ . 'tax_validity_range`
+ WHERE `id_tax` = ' . $idTax . '
+ ORDER BY `id_tax_validity_range` ASC'
+ );
+ Cache::store($cacheId, $ranges);
+
+ return $ranges;
+ }
+
+ /**
+ * Whether $date falls within any of the given ranges (OR across rows) — a blank bound on either
+ * @param array $ranges Rows as returned by getByTaxId()
+ * @param DateTime $date
+ * @return bool
+ */
+ public static function dateMatchesAnyRange($ranges, $date)
+ {
+ if (empty($ranges)) {
+ return true;
+ }
+ foreach ($ranges as $range) {
+ $from = (!empty($range['valid_from']) && $range['valid_from'] !== '0000-00-00') ? new DateTime($range['valid_from']) : null;
+ $to = (!empty($range['valid_to']) && $range['valid_to'] !== '0000-00-00') ? new DateTime($range['valid_to']) : null;
+ if ((!$from || $date >= $from) && (!$to || $date <= $to)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Replace all validity-range rows for $idTax with the submitted arrays, skipping rows where
+ * @param int $idTax
+ * @param array $froms
+ * @param array $tos
+ * @return void
+ */
+ public static function saveAll($idTax, $froms, $tos)
+ {
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tax_validity_range`
+ WHERE `id_tax` = ' . $idTax
+ );
+ foreach ($froms as $rowIndex => $from) {
+ $to = isset($tos[$rowIndex]) ? $tos[$rowIndex] : '';
+ $fromEmpty = ($from === '' || $from === false);
+ $toEmpty = ($to === '' || $to === false);
+ if ($fromEmpty && $toEmpty) {
+ continue;
+ }
+ $range = new TaxValidityRange();
+ $range->id_tax = $idTax;
+ $range->valid_from = $fromEmpty ? null : $from;
+ $range->valid_to = $toEmpty ? null : $to;
+ $range->add();
+ }
+ }
+}
diff --git a/classes/webservice/WebserviceSpecificManagementBookings.php b/classes/webservice/WebserviceSpecificManagementBookings.php
index 4be186c46..54327aa11 100644
--- a/classes/webservice/WebserviceSpecificManagementBookings.php
+++ b/classes/webservice/WebserviceSpecificManagementBookings.php
@@ -1049,7 +1049,11 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$this->wsRequestedRooms[$dateRoomJoinKey]['id_tax_rules_group'] = $this->createTaxRule(($this->wsRequestedRooms[$dateRoomJoinKey]['total_tax']/$objHotelBookingDetail->total_price_tax_excl)*100, $objAddress);
} else if (isset($this->wsRequestedRooms[$dateRoomJoinKey]['total_tax'])) {
$objOrderDetail->id_tax_rules_group = 0;
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$objOrderDetail->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$objOrderDetail->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
$priceWithTax = $objHotelBookingDetail->total_price_tax_excl + $this->wsRequestedRooms[$dateRoomJoinKey]['total_tax'];
}
@@ -1078,7 +1082,7 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$objOrderDetail->save();
if (isset($this->wsRequestedRooms[$dateRoomJoinKey]['id_tax_rules_group'])) {
- $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRooms[$dateRoomJoinKey]['id_tax_rules_group']);
+ $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRooms[$dateRoomJoinKey]['id_tax_rules_group'], (int) $objHotelBookingDetail->id, 0);
}
$objOrder->total_paid += $taxDiff;
$objOrder->total_paid_tax_incl += $taxDiff;
@@ -1090,17 +1094,22 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
}
}
- public function saveTaxCalculator($idOrderDetail, $idTaxRulesGroup)
+ public function saveTaxCalculator($idOrderDetail, $idTaxRulesGroup, $idHtlBooking = 0, $idServiceProductOrderDetail = 0)
{
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$idOrderDetail);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$idOrderDetail.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
$idTax = $this->wsTaxRulesGroup[$idTaxRulesGroup]['tax'];
$objOrderDetail = new OrderDetail($idOrderDetail);
- $values = '('.(int)$objOrderDetail->id.','.(int)$idTax.','.
+ $values = '('.(int)$objOrderDetail->id_order.','.(int)$objOrderDetail->id.','.(int)$idHtlBooking.','.(int)$idServiceProductOrderDetail.','.(int)$idTax.','.
(float)($objOrderDetail->unit_price_tax_incl - $objOrderDetail->unit_price_tax_excl).','.
- (float)($objOrderDetail->total_price_tax_incl - $objOrderDetail->total_price_tax_excl).'),';
+ (float)($objOrderDetail->total_price_tax_incl - $objOrderDetail->total_price_tax_excl).',NOW()),';
$values = rtrim($values, ',');
- $sql = 'INSERT INTO `'._DB_PREFIX_.'order_detail_tax` (id_order_detail, id_tax, unit_amount, total_amount)
+ $sql = 'INSERT INTO `'._DB_PREFIX_.'order_tax_detail`
+ (id_order, id_order_detail, id_htl_booking, id_service_product_order_detail, id_tax, unit_amount, total_amount, date_add)
VALUES '.$values;
return Db::getInstance()->execute($sql);
@@ -1211,7 +1220,11 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'] = $this->createTaxRule(($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax']/$objServiceProductOrderDetail->total_price_tax_excl)*100, $objAddress);
} else if (isset($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax'])) {
$objOrderDetail->id_tax_rules_group = 0;
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$objOrderDetail->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$objOrderDetail->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
$unitPriceTaxIncl = $objServiceProductOrderDetail->total_price_tax_excl + $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax'];
$priceDiffTaxIncl = $unitPriceTaxIncl - $objServiceProductOrderDetail->total_price_tax_incl;
@@ -1240,7 +1253,7 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$objServiceProductOrderDetail->save();
$objOrderDetail->save();
if (!$isAutoAdded && isset($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'])) {
- $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group']);
+ $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'], 0, (int) $objServiceProductOrderDetail->id);
}
}
}
@@ -2245,7 +2258,11 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'] = $this->createTaxRule(($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax']/$totalPriceTaxExcl)*100, $objAddress);
} else if (isset($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax'])) {
$objOrderDetail->id_tax_rules_group = 0;
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$objOrderDetail->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$objOrderDetail->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
$unitPriceTaxIncl = $objServiceProductOrderDetail->total_price_tax_excl + $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['total_tax'];
$priceDiffTaxIncl = $unitPriceTaxIncl - $objServiceProductOrderDetail->total_price_tax_incl;
}
@@ -2304,7 +2321,7 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
}
if (!$isAutoAdded && isset($this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'])) {
- $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group']);
+ $this->saveTaxCalculator($objOrderDetail->id, $this->wsRequestedRoomTypes[$dateRoomJoinKey]['services'][$service['id_product']]['id_tax_rules_group'], 0, (int) $objServiceProductOrderDetail->id);
}
}
@@ -3175,7 +3192,11 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$unitPriceTaxIncl = Tools::ps_round($totalPriceTaxIncl / $quantity, _PS_PRICE_COMPUTE_PRECISION_);
} else if (isset($services[$product['id_product']]['total_tax'])) {
$objOrderDetail->id_tax_rules_group = 0;
- Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'order_detail_tax` WHERE id_order_detail='.(int)$objOrderDetail->id);
+ Db::getInstance()->execute(
+ 'DELETE otd FROM `'._DB_PREFIX_.'order_tax_detail` otd
+ WHERE otd.`id_order_detail` = '.(int)$objOrderDetail->id.'
+ AND NOT EXISTS (SELECT 1 FROM `'._DB_PREFIX_.'tax` tc WHERE tc.`id_tax` = otd.`id_tax` AND tc.`is_tourism_tax` = 1)'
+ );
$unitPriceTaxIncl = $unitPriceTaxExcl + $services[$product['id_product']]['total_tax'];
$totalPriceTaxIncl = Tools::ps_round(($unitPriceTaxIncl * $quantity), _PS_PRICE_COMPUTE_PRECISION_);
}
@@ -3228,7 +3249,7 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
$objOrderDetail->update();
if (!$isAutoAdded && isset($services[$product['id_product']]['id_tax_rules_group'])) {
- $this->saveTaxCalculator($objOrderDetail->id, $services[$product['id_product']]['id_tax_rules_group']);
+ $this->saveTaxCalculator($objOrderDetail->id, $services[$product['id_product']]['id_tax_rules_group'], 0, (int) $objServiceProductOrderDetail->id);
}
}
}
@@ -3279,7 +3300,7 @@ class WebserviceSpecificManagementBookingsCore Extends ObjectModel implements We
}
- private function manageBookingFilter($filters)
+ private function manageBookingFilter($filters)
{
$ret = '';
$allowedFilters = $this->allowedFilters;
diff --git a/controllers/admin/AdminNormalProductsController.php b/controllers/admin/AdminNormalProductsController.php
index 43838ccc8..2681ad61f 100644
--- a/controllers/admin/AdminNormalProductsController.php
+++ b/controllers/admin/AdminNormalProductsController.php
@@ -587,6 +587,12 @@ class AdminNormalProductsControllerCore extends AdminController
if ($this->_list) {
$context = $this->context->cloneContext();
$context->shop = clone($context->shop);
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $tourismTaxContext = $useTourismTax ? TaxConfiguration::resolveServiceLineTaxContext(
+ (int) Configuration::get('WK_PRIMARY_HOTEL'),
+ 0,
+ new Address(0)
+ ) : null;
/* update product final price */
for ($i = 0; $i < $nb; $i++) {
if (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP) {
@@ -598,6 +604,31 @@ class AdminNormalProductsControllerCore extends AdminController
$this->_list[$i]['price_tmp'] = Product::getPriceStatic($this->_list[$i]['id_product'], true, null,
(int)Configuration::get('PS_PRICE_DISPLAY_PRECISION'), null, false, true, 1, true, null, null, null, $nothing, true, true,
$context);
+
+ if ($useTourismTax) {
+ $unitPriceTaxExcl = Product::getPriceStatic($this->_list[$i]['id_product'], false, null,
+ 6, null, false, true, 1, true, null, null, null, $nothing, true, true,
+ $context);
+ $serviceTourismTaxAmount = 0.0;
+ $idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $this->_list[$i]['id_product']);
+ if ($idTourismTaxRulesGroup) {
+ $taxCalculator = TaxManagerFactory::getManager(
+ $tourismTaxContext['address'],
+ $idTourismTaxRulesGroup
+ )->getTaxCalculator();
+ $serviceTourismTaxAmount = $taxCalculator->getTaxesTotalAmount(
+ (float) $unitPriceTaxExcl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ $tourismTaxContext['collectionType'],
+ 1,
+ $this->context->currency->id
+ );
+ }
+ $this->_list[$i]['price_tmp'] += $serviceTourismTaxAmount;
+ }
}
}
@@ -3373,6 +3404,26 @@ class AdminNormalProductsControllerCore extends AdminController
// $data->assign('productFeaturePrices', $productFeaturePrices);
// }
+ if (Configuration::get('QLO_USE_TOURISM_TAX') && !Tax::excludeTaxeOption()) {
+ $tourismTaxRulesGroups = TaxRulesGroup::getTaxRulesGroupsForOptions(true, true);
+ $data->assign('tourismTaxRulesGroups', $tourismTaxRulesGroups);
+ $data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0);
+
+ $tourismTaxRates = array();
+ foreach ($tourismTaxRulesGroups as $tourismTaxRulesGroup) {
+ $idTourismTaxRulesGroup = (int) $tourismTaxRulesGroup['id_tax_rules_group'];
+ if (!$idTourismTaxRulesGroup) {
+ continue;
+ }
+ $tourismTaxRates[$idTourismTaxRulesGroup] = TaxConfiguration::getPreviewParams(
+ $idTourismTaxRulesGroup,
+ $address,
+ (int) $this->context->language->id
+ );
+ }
+ $data->assign('tourismTaxRatesByGroup', $tourismTaxRates);
+ }
+
$this->tpl_form_vars['custom_form'] = $data->fetch();
}
diff --git a/controllers/admin/AdminOrdersController.php b/controllers/admin/AdminOrdersController.php
index a5e13e4a8..e39c5d6dd 100644
--- a/controllers/admin/AdminOrdersController.php
+++ b/controllers/admin/AdminOrdersController.php
@@ -510,6 +510,7 @@ class AdminOrdersControllerCore extends AdminController
if (!$cart_order_exists) {
$this->context->cart = $cart;
$this->context->currency = new Currency((int)$cart->id_currency);
+ $this->context->smarty->assign('info_icon_path', $this->context->link->getMediaLink(_MODULE_DIR_.'hotelreservationsystem/views/img/Slices/icon-info.svg'));
$this->context->customer = new Customer((int)$cart->id_customer);
// validate cart for removing invalid data from cart before new order creation
// remove not linked services with room types from cart if not allowed to book them
@@ -984,6 +985,7 @@ class AdminOrdersControllerCore extends AdminController
'invoices_collection' => $objOrder->getInvoicesCollection(),
'currency' => new Currency($objOrder->id_currency),
'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'),
+ 'use_tourism_tax' => (bool) Configuration::get('QLO_USE_TOURISM_TAX'),
)
);
$modal = array(
@@ -2666,6 +2668,10 @@ class AdminOrdersControllerCore extends AdminController
_PS_PRICE_COMPUTE_PRECISION_
);
$order->update();
+
+ if (isset($order_invoice) && $order_invoice->id) {
+ $order_invoice->recomputeTotalsFromLines();
+ }
}
Tools::redirectAdmin(self::$currentIndex.'&id_order='.$order->id.'&vieworder&conf=4&token='.$this->token);
@@ -2834,6 +2840,11 @@ class AdminOrdersControllerCore extends AdminController
$order->total_paid = Tools::ps_round($order->getOrderTotal(), _PS_PRICE_COMPUTE_PRECISION_);
$order->total_paid_tax_incl = Tools::ps_round($order->getOrderTotal(), _PS_PRICE_COMPUTE_PRECISION_);
$order->total_paid_tax_excl = Tools::ps_round($order->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
+
+ $loopOrderInvoice = new OrderInvoice((int) $id_order_invoice);
+ if (Validate::isLoadedObject($loopOrderInvoice)) {
+ $loopOrderInvoice->recomputeTotalsFromLines();
+ }
}
// Update Order
@@ -3370,6 +3381,9 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail = new ServiceProductOrderDetail();
$orderHotelServiceProducts = array();
$orderStandaloneServiceProducts = array();
+ $standaloneServiceTourismTaxStatusMap = Configuration::get('QLO_USE_TOURISM_TAX')
+ ? OrderTaxDetail::getStandaloneServiceTourismTaxStatuses($order->id)
+ : array();
// products current stock (from stock_available)
foreach ($products as &$product) {
// Get total customized quantity for current product
@@ -3418,12 +3432,38 @@ class AdminOrdersControllerCore extends AdminController
if ($product['selling_preference_type'] == Product::SELLING_PREFERENCE_HOTEL_STANDALONE) {
$hotelProducts = $objServiceProductOrderDetail->getServiceProductsInOrder($order->id, $product['id_order_detail'], $product['product_id'], Product::SELLING_PREFERENCE_HOTEL_STANDALONE);
foreach ($hotelProducts as $hotelProduct) {
+ $tourismTaxStatus = isset($standaloneServiceTourismTaxStatusMap[$hotelProduct['id_service_product_order_detail']])
+ ? $standaloneServiceTourismTaxStatusMap[$hotelProduct['id_service_product_order_detail']]
+ : null;
+ if ($tourismTaxStatus) {
+ $hotelProduct['tourism_tax_status'] = $tourismTaxStatus['status'];
+ } else {
+ $hotelProduct['tourism_tax_status'] = Product::getIdTourismTaxRulesGroupByIdProduct((int) $hotelProduct['id_product'])
+ ? OrderTaxDetail::STATUS_NONE
+ : OrderTaxDetail::STATUS_NOT_APPLICABLE;
+ }
+ $hotelProduct['tourism_tax_total'] = $tourismTaxStatus ? $tourismTaxStatus['total'] : 0.0;
+ $hotelProduct['total_service_tax'] = $hotelProduct['total_price_tax_incl'] - $hotelProduct['total_price_tax_excl'];
+ $hotelProduct['total_service_tax_only'] = $hotelProduct['total_service_tax'] - $hotelProduct['tourism_tax_total'];
$orderHotelServiceProducts[] = array_merge($product, $hotelProduct);
}
}
if ($product['selling_preference_type'] == Product::SELLING_PREFERENCE_STANDALONE) {
$standaloneProducts = $objServiceProductOrderDetail->getServiceProductsInOrder($order->id, $product['id_order_detail'], $product['product_id'], Product::SELLING_PREFERENCE_STANDALONE);
foreach ($standaloneProducts as $standaloneProduct) {
+ $tourismTaxStatus = isset($standaloneServiceTourismTaxStatusMap[$standaloneProduct['id_service_product_order_detail']])
+ ? $standaloneServiceTourismTaxStatusMap[$standaloneProduct['id_service_product_order_detail']]
+ : null;
+ if ($tourismTaxStatus) {
+ $standaloneProduct['tourism_tax_status'] = $tourismTaxStatus['status'];
+ } else {
+ $standaloneProduct['tourism_tax_status'] = Product::getIdTourismTaxRulesGroupByIdProduct((int) $standaloneProduct['id_product'])
+ ? OrderTaxDetail::STATUS_NONE
+ : OrderTaxDetail::STATUS_NOT_APPLICABLE;
+ }
+ $standaloneProduct['tourism_tax_total'] = $tourismTaxStatus ? $tourismTaxStatus['total'] : 0.0;
+ $standaloneProduct['total_service_tax'] = $standaloneProduct['total_price_tax_incl'] - $standaloneProduct['total_price_tax_excl'];
+ $standaloneProduct['total_service_tax_only'] = $standaloneProduct['total_service_tax'] - $standaloneProduct['tourism_tax_total'];
$orderStandaloneServiceProducts[] = array_merge($product, $standaloneProduct);
}
}
@@ -3455,11 +3495,16 @@ class AdminOrdersControllerCore extends AdminController
$totalRoomsCostTE = 0;
$totalRefundedRooms = 0;
$orderConvenienceFeeServices = array();
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $bookingTourismTaxStatusMap = OrderTaxDetail::getAppliedTourismTaxTotals($order->id, OrderTaxDetail::SCOPE_ROOM_STATUS);
+ $serviceTourismTaxMap = OrderTaxDetail::getAppliedTourismTaxTotals($order->id, OrderTaxDetail::SCOPE_SERVICE);
if ($order_detail_data = $objBookingDetail->getOrderFormatedBookinInfoByIdOrder($order->id)) {
$objHotelRoomType = new HotelRoomType();
foreach ($order_detail_data as $key => $value) {
$order_detail_data[$key]['total_room_price_te'] = $value['total_price_tax_excl'];
$order_detail_data[$key]['total_room_price_ti'] = $value['total_price_tax_incl'];
+ $bookingTourismTaxAmount = isset($bookingTourismTaxStatusMap[$value['id']]) ? (float) $bookingTourismTaxStatusMap[$value['id']]['total'] : 0.0;
+ $order_detail_data[$key]['tourism_tax_amount'] = $bookingTourismTaxAmount;
$order_detail_data[$key]['additional_services'] = $objServiceProductOrderDetail->getRoomTypeServiceProducts(
0,
@@ -3593,7 +3638,7 @@ class AdminOrdersControllerCore extends AdminController
/*Product price when order was created*/
$totalRoomsCostTE += $value['total_price_tax_excl'];
- $total_room_tax += $value['total_price_tax_incl']-$value['total_price_tax_excl'];
+ $total_room_tax += ($value['total_price_tax_incl'] - $value['total_price_tax_excl']);
$num_days = HotelHelper::getNumberOfDays($value['date_from'], $value['date_to']);
$order_detail_data[$key]['unit_amt_tax_excl'] = $value['total_price_tax_excl']/$num_days;
$order_detail_data[$key]['unit_amt_tax_incl'] = $value['total_price_tax_incl']/$num_days;
@@ -3601,6 +3646,12 @@ class AdminOrdersControllerCore extends AdminController
$order_detail_data[$key]['amt_with_qty_tax_incl'] = $value['total_price_tax_incl'];
$order_detail_data[$key]['room_type_info'] = $objHotelRoomType->getRoomTypeInfoByIdProduct($value['id_product']);
$order_detail_data[$key]['total_room_tax'] = $order_detail_data[$key]['total_room_price_ti'] - $order_detail_data[$key]['total_room_price_te'];
+ $bookingTourismTaxStatus = isset($bookingTourismTaxStatusMap[$value['id']]) ? $bookingTourismTaxStatusMap[$value['id']] : null;
+ $roomTourismTaxTotal = $bookingTourismTaxStatus ? $bookingTourismTaxStatus['total'] : 0.0;
+ $serviceTourismTaxTotal = isset($serviceTourismTaxMap[$value['id']]) ? $serviceTourismTaxMap[$value['id']] : 0.0;
+ $order_detail_data[$key]['tourism_tax_status'] = self::combinedRoomTourismTaxStatus($value['id'], $value['id_product'], $bookingTourismTaxStatus, $serviceTourismTaxTotal);
+ $order_detail_data[$key]['tourism_tax_total'] = $roomTourismTaxTotal + $serviceTourismTaxTotal;
+ $order_detail_data[$key]['total_room_service_tax_only'] = $order_detail_data[$key]['total_room_tax'] - $roomTourismTaxTotal - $serviceTourismTaxTotal;
$order_detail_data[$key]['num_checkin_documents'] = HotelBookingDocument::getCountByIdHtlBooking($value['id']);
$order_detail_data[$key]['connected_rooms'] = HotelConnectedRoom::getConnectedRooms($value['id_room'], null, null, (int) Context::getContext()->language->id);
@@ -3617,6 +3668,40 @@ class AdminOrdersControllerCore extends AdminController
$order_detail_data = array();
}
+ $orderTourismTaxHasApplied = false;
+ $orderTourismTaxHasUnapplied = false;
+ if ($useTourismTax && $order_detail_data) {
+ foreach ($order_detail_data as $bookingRow) {
+ if (!empty($bookingRow['is_cancelled']) || !empty($bookingRow['is_refunded'])) {
+ continue;
+ }
+ if (isset($bookingRow['tourism_tax_status'])) {
+ if ($bookingRow['tourism_tax_status'] === OrderTaxDetail::STATUS_APPLIED) {
+ $orderTourismTaxHasApplied = true;
+ } elseif ($bookingRow['tourism_tax_status'] !== OrderTaxDetail::STATUS_NOT_APPLICABLE) {
+ $orderTourismTaxHasUnapplied = true;
+ }
+ }
+ }
+ }
+
+ $productsTourismTaxHasApplied = false;
+ $productsTourismTaxHasUnapplied = false;
+ if ($useTourismTax) {
+ foreach (array_merge($orderHotelServiceProducts, $orderStandaloneServiceProducts) as $productRow) {
+ if (!empty($productRow['is_cancelled']) || !empty($productRow['is_refunded'])) {
+ continue;
+ }
+ if (isset($productRow['tourism_tax_status'])) {
+ if ($productRow['tourism_tax_status'] === OrderTaxDetail::STATUS_APPLIED) {
+ $productsTourismTaxHasApplied = true;
+ } elseif ($productRow['tourism_tax_status'] !== OrderTaxDetail::STATUS_NOT_APPLICABLE) {
+ $productsTourismTaxHasUnapplied = true;
+ }
+ }
+ }
+ }
+
if ($orderedRooms = $objServiceProductOrderDetail->getRoomTypeServiceProducts(
$order->id,
0,
@@ -3745,6 +3830,17 @@ class AdminOrdersControllerCore extends AdminController
'htl_booking_order_data' => $bookingOrderInfo,
'hotel_order_status' => $htlOrderStatus,
'order_detail_data' => $order_detail_data,
+ 'use_tourism_tax' => $useTourismTax,
+ 'tourism_tax_room' => array_sum(array_column($bookingTourismTaxStatusMap, 'total')),
+ 'tourism_tax_service' => array_sum($serviceTourismTaxMap),
+ 'tourism_tax_products' => array_sum(array_column($standaloneServiceTourismTaxStatusMap, 'total')),
+ 'info_icon_path' => $this->context->link->getMediaLink(_MODULE_DIR_.'hotelreservationsystem/views/img/Slices/icon-info.svg'),
+ 'tourism_tax_status_applied' => OrderTaxDetail::STATUS_APPLIED,
+ 'tourism_tax_status_not_applicable' => OrderTaxDetail::STATUS_NOT_APPLICABLE,
+ 'order_tourism_tax_has_applied' => $orderTourismTaxHasApplied,
+ 'order_tourism_tax_has_unapplied' => $orderTourismTaxHasUnapplied,
+ 'products_tourism_tax_has_applied' => $productsTourismTaxHasApplied,
+ 'products_tourism_tax_has_unapplied' => $productsTourismTaxHasUnapplied,
'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'),
'hotel_service_products' => $orderHotelServiceProducts,
'standalone_service_products' => $orderStandaloneServiceProducts,
@@ -4231,21 +4327,23 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOption = new ServiceProductOption();
foreach ($products as $key => &$product) {
$idHotelAddress = Cart::getIdAddressForTaxCalculation($product['id_product'], $idHotel);
- $product['price_tax_incl'] = RoomTypeServiceProductPrice::getPrice($product['id_product'], $idHotel, null, true);
- $product['price_tax_excl'] = RoomTypeServiceProductPrice::getPrice($product['id_product'], $idHotel, null, false);
+ $product['price_tax_incl'] = Product::getServiceProductPrice($product['id_product'], null, $idHotel, false, true);
+ $product['price_tax_excl'] = Product::getServiceProductPrice($product['id_product'], null, $idHotel, false, false);
// if product has options then set the price of the first selected option of the product
if ($product['options'] = $objServiceProductOption->getProductOptions($product['id_product'])) {
- $product['price_tax_incl'] = RoomTypeServiceProductPrice::getPrice(
+ $product['price_tax_incl'] = Product::getServiceProductPrice(
$product['id_product'],
- $idHotel,
$product['options'][0]['id_product_option'],
+ $idHotel,
+ false,
true
);
- $product['price_tax_excl'] = RoomTypeServiceProductPrice::getPrice(
+ $product['price_tax_excl'] = Product::getServiceProductPrice(
$product['id_product'],
- $idHotel,
$product['options'][0]['id_product_option'],
+ $idHotel,
+ false,
false
);
}
@@ -4367,8 +4465,8 @@ class AdminOrdersControllerCore extends AdminController
$currency = new Currency((int)$order->id_currency);
if ($idHotel = $addressTax->id_hotel) {
if ($id_product_option = $addProduct['product_option']) {
- $price_tax_incl = RoomTypeServiceProductPrice::getPrice($addProduct['product_id'], $idHotel, $id_product_option, true);
- $price_tax_excl = RoomTypeServiceProductPrice::getPrice($addProduct['product_id'], $idHotel, $id_product_option, false);
+ $price_tax_incl = Product::getServiceProductPrice($addProduct['product_id'], $id_product_option, $idHotel, false, true);
+ $price_tax_excl = Product::getServiceProductPrice($addProduct['product_id'], $id_product_option, $idHotel, false, false);
// convert price to order currency
$price_tax_excl = Tools::ps_round(Tools::convertPrice($price_tax_excl, $currency), _PS_PRICE_COMPUTE_PRECISION_);
@@ -4382,8 +4480,8 @@ class AdminOrdersControllerCore extends AdminController
}
} else {
if ($id_product_option = $addProduct['product_option']) {
- $price_tax_incl = RoomTypeServiceProductPrice::getPrice($addProduct['product_id'], false, $id_product_option, true);
- $price_tax_excl = RoomTypeServiceProductPrice::getPrice($addProduct['product_id'], false, $id_product_option, false);
+ $price_tax_incl = Product::getServiceProductPrice($addProduct['product_id'], $id_product_option, false, false, true);
+ $price_tax_excl = Product::getServiceProductPrice($addProduct['product_id'], $id_product_option, false, false, false);
// convert price to order currency
$price_tax_excl = Tools::ps_round(Tools::convertPrice($price_tax_excl, $currency), _PS_PRICE_COMPUTE_PRECISION_);
@@ -4669,6 +4767,7 @@ class AdminOrdersControllerCore extends AdminController
$order->total_paid_tax_excl = Tools::ps_round($order->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
if (isset($order_invoice) && Validate::isLoadedObject($order_invoice)) {
+ $order_invoice->recomputeTotalsFromLines();
$order->total_shipping = $order_invoice->total_shipping_tax_incl;
$order->total_shipping_tax_incl = $order_invoice->total_shipping_tax_incl;
$order->total_shipping_tax_excl = $order_invoice->total_shipping_tax_excl;
@@ -5249,6 +5348,7 @@ class AdminOrdersControllerCore extends AdminController
$order->total_paid_tax_excl = Tools::ps_round($order->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
if (isset($order_invoice) && Validate::isLoadedObject($order_invoice)) {
+ $order_invoice->recomputeTotalsFromLines();
$order->total_shipping = $order_invoice->total_shipping_tax_incl;
$order->total_shipping_tax_incl = $order_invoice->total_shipping_tax_incl;
$order->total_shipping_tax_excl = $order_invoice->total_shipping_tax_excl;
@@ -5390,6 +5490,7 @@ class AdminOrdersControllerCore extends AdminController
$this->context->cart->id_guest,
$idProduct
)) {
+ $useTourismTaxForNewBooking = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
foreach ($cartBookingData as $cb_k => $cb_v) {
$objCartBookingData = new HotelCartBookingData($cb_v['id']);
$objCartBookingData->id_order = $order->id;
@@ -5464,6 +5565,56 @@ class AdminOrdersControllerCore extends AdminController
}
if ($objBookingDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objBookingDetail->id_order_detail, (int) $objBookingDetail->id, 0);
+
+ if ($useTourismTaxForNewBooking) {
+ $idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct);
+ if ($idTourismTaxRulesGroup) {
+ $tourismTaxAddress = null;
+ if ($objCartBookingData->id_hotel) {
+ $hotelAddress = HotelBranchInformation::getAddress($objCartBookingData->id_hotel);
+ if ($hotelAddress && !empty($hotelAddress['id_address'])) {
+ $tourismTaxAddress = new Address((int) $hotelAddress['id_address']);
+ }
+ }
+ if (!$tourismTaxAddress || !Validate::isLoadedObject($tourismTaxAddress)) {
+ $tourismTaxAddress = new Address((int) Cart::getIdAddressForTaxCalculation($idProduct));
+ }
+
+ $tourismTaxNumNights = max(1, (int) HotelHelper::getNumberOfDays($objBookingDetail->date_from, $objBookingDetail->date_to));
+ $tourismTaxChildAges = array();
+ if ($objBookingDetail->child_ages) {
+ $decodedChildAges = json_decode($objBookingDetail->child_ages, true);
+ if (is_array($decodedChildAges)) {
+ $tourismTaxChildAges = array_map('intval', $decodedChildAges);
+ }
+ }
+ $tourismTaxCollectionType = Validate::isLoadedObject($objHotelBranch)
+ ? (int) $objHotelBranch->tourism_tax_collection_type
+ : HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE;
+
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxAddress,
+ (float) $objBookingDetail->total_price_tax_excl / $tourismTaxNumNights,
+ $objBookingDetail->date_from,
+ $tourismTaxNumNights,
+ (int) $objBookingDetail->adults,
+ $tourismTaxChildAges,
+ (int) $this->context->cart->id_currency,
+ $tourismTaxCollectionType,
+ $idLang,
+ 1,
+ $order->id,
+ (int) $objBookingDetail->id_order_detail,
+ $objBookingDetail->id,
+ 0
+ );
+ // Whether the checkbox exempts this room+its services is decided once,
+ // after the auto-attached service loop below runs — see there for why.
+ }
+ }
+
$objRoomTypeServiceProduct = new RoomTypeServiceProduct();
$objRoomTypeServiceProductPrice = new RoomTypeServiceProductPrice();
$objServiceProductCartDetail = new ServiceProductCartDetail();
@@ -5539,13 +5690,52 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->total_price_tax_incl = $totalPriceTaxIncl;
$objServiceProductOrderDetail->name = $service['name'];
$objServiceProductOrderDetail->quantity = 1;
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ if ($useTourismTaxForNewBooking) {
+ $idServiceTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $service['id_product']);
+ if ($idServiceTourismTaxRulesGroup) {
+ $serviceTourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext(
+ (int) $objCartBookingData->id_hotel,
+ $objBookingDetail->id,
+ new Address((int) Cart::getIdAddressForTaxCalculation((int) $service['id_product']))
+ );
+ OrderTaxDetail::saveTourismTax(
+ $idServiceTourismTaxRulesGroup,
+ $serviceTourismTaxContext['address'],
+ $unitPriceTaxExcl,
+ $serviceTourismTaxContext['checkInDate'],
+ $serviceTourismTaxContext['numNights'],
+ $serviceTourismTaxContext['numAdults'],
+ $serviceTourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $serviceTourismTaxContext['collectionType'],
+ $idLang,
+ 1,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
+ }
}
}
+
+ if ($useTourismTaxForNewBooking && empty($product_informations['apply_tourism_tax'])) {
+ OrderTaxDetail::exemptBooking($objBookingDetail->id);
+ }
}
}
}
+ $order->total_paid = $order->getOrderTotal();
+ $order->total_paid_tax_incl = $order->total_paid;
+ $order->total_paid_tax_excl = $order->getOrderTotal(false);
+ $order->update();
+
// delete cart feature prices after room addition success
HotelRoomTypeFeaturePricing::deleteFeaturePrices($this->context->cart->id);
@@ -5798,6 +5988,7 @@ class AdminOrdersControllerCore extends AdminController
$objOrder->total_paid_tax_excl = Tools::ps_round($objOrder->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
if (isset($objOrderInvoice) && Validate::isLoadedObject($objOrderInvoice)) {
+ $objOrderInvoice->recomputeTotalsFromLines();
$objOrder->total_shipping = $objOrderInvoice->total_shipping_tax_incl;
$objOrder->total_shipping_tax_incl = $objOrderInvoice->total_shipping_tax_incl;
$objOrder->total_shipping_tax_excl = $objOrderInvoice->total_shipping_tax_excl;
@@ -5846,9 +6037,43 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->hotel_name = $objHotelBranch->hotel_name;
}
}
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ OrderTaxDetail::updateVatScoping((int) $objServiceProductOrderDetail->id_order_detail, 0, (int) $objServiceProductOrderDetail->id);
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX')
+ && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))
+ ) {
+ $tourismTaxContext = TaxConfiguration::resolveServiceLineTaxContext(
+ $objServiceProductOrderDetail->id_hotel,
+ 0,
+ new Address((int) Cart::getIdAddressForTaxCalculation($idProduct))
+ );
+ OrderTaxDetail::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $objOrder->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $objOrder->id_lang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $objOrder->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id
+ );
+ }
+ }
}
+ $objOrder->total_paid = $objOrder->getOrderTotal();
+ $objOrder->total_paid_tax_incl = $objOrder->total_paid;
+ $objOrder->total_paid_tax_excl = $objOrder->getOrderTotal(false);
+ $objOrder->update();
+
$response['status'] = true;
} else {
$response['status'] = false;
@@ -6087,9 +6312,6 @@ class AdminOrdersControllerCore extends AdminController
$order_detail->product_quantity = $old_quantity + $qty_diff;
$order_detail->reduction_percent = 0;
- // update taxes
- $res &= $order_detail->updateTaxAmount($order);
-
// Save order detail
$res &= $order_detail->update();
@@ -6211,6 +6433,7 @@ class AdminOrdersControllerCore extends AdminController
$new_total_price,
$obj_booking_detail->id
)) {
+ $res &= $order_detail->updateTaxAmount($order);
if (isset($orderServiceProducts)
&& is_array($orderServiceProducts)
@@ -6262,7 +6485,6 @@ class AdminOrdersControllerCore extends AdminController
$objOrderDetail->save();
$this->context->cart = new Cart($objServiceProductOrderDetail->id_cart);
- $objOrderDetail->updateTaxAmount($order);
if (isset($order_invoice)) {
$order_invoice->total_paid_tax_excl -= $objServiceProductOrderDetail->total_price_tax_excl;
@@ -6276,16 +6498,68 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->total_price_tax_excl = $newTotalPriceTaxExcl;
$objServiceProductOrderDetail->total_price_tax_incl = $newTotalPriceTaxIncl;
$objServiceProductOrderDetail->save();
+
+ $objOrderDetail->updateTaxAmount($order);
}
}
}
}
}
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $roomTourismTaxParams = OrderTaxDetail::buildRoomTaxParams((int) $obj_booking_detail->id);
+ if ($roomTourismTaxParams) {
+ OrderTaxDetail::saveTourismTax(
+ $roomTourismTaxParams['idTaxRulesGroup'],
+ $roomTourismTaxParams['address'],
+ $roomTourismTaxParams['unitPriceTaxExcl'],
+ $roomTourismTaxParams['checkInDate'],
+ $roomTourismTaxParams['numNights'],
+ $roomTourismTaxParams['numAdults'],
+ $roomTourismTaxParams['childrenAges'],
+ $roomTourismTaxParams['idCurrency'],
+ $roomTourismTaxParams['collectionType'],
+ $roomTourismTaxParams['idLang'],
+ $roomTourismTaxParams['quantity'],
+ $roomTourismTaxParams['idOrder'],
+ $roomTourismTaxParams['idOrderDetail'],
+ $roomTourismTaxParams['idHtlBooking'],
+ $roomTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail((int) $obj_booking_detail->id) as $idActiveServiceLine) {
+ $serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams($idActiveServiceLine);
+ if (!$serviceTourismTaxParams) {
+ continue;
+ }
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+ }
}
$order->total_paid = Tools::ps_round($order->getOrderTotal(), _PS_PRICE_COMPUTE_PRECISION_);
$order->total_paid_tax_incl = Tools::ps_round($order->getOrderTotal(), _PS_PRICE_COMPUTE_PRECISION_);
$order->total_paid_tax_excl = Tools::ps_round($order->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
+
+ if (isset($order_invoice) && $order_invoice->id) {
+ $order_invoice->recomputeTotalsFromLines();
+ }
if($order->save()){
$roomType = Product::getProductName($id_product, null, $this->context->language->id);
$oldDateFromFormatted = date('d/m/Y', strtotime($old_date_from));
@@ -6407,15 +6681,11 @@ class AdminOrdersControllerCore extends AdminController
}
$objOrderDetail->total_price_tax_incl += $diffPriceTaxIncl;
$objOrderDetail->total_price_tax_excl += $diffPriceTaxExcl;
+ $objOrderDetail->unit_price_tax_excl = $unitPriceTaxExcl;
+ $objOrderDetail->unit_price_tax_incl = $unitPriceTaxIncl;
$objOrderDetail->product_quantity += $updateQty;
$objOrderDetail->reduction_percent = 0;
- // update taxes
- $result &= $objOrderDetail->updateTaxAmount($objOrder);
-
- // Save order detail
- $result &= $objOrderDetail->update();
-
$objServiceProductOrderDetail->unit_price_tax_excl = $unitPriceTaxExcl;
$objServiceProductOrderDetail->unit_price_tax_incl = $unitPriceTaxIncl;
$objServiceProductOrderDetail->total_price_tax_excl = $totalProductsTaxExcl;
@@ -6423,6 +6693,35 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->quantity += $updateQty;
$result &= $objServiceProductOrderDetail->update();
+ // update taxes
+ $result &= $objOrderDetail->updateTaxAmount($objOrder);
+
+ // Save order detail
+ $result &= $objOrderDetail->update();
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams((int) $objServiceProductOrderDetail->id);
+ if ($serviceTourismTaxParams) {
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+ }
+
// Apply changes on Order
$objOrder = new Order($objOrderDetail->id_order);
$objOrder->total_products += $diffPriceTaxExcl;
@@ -6442,6 +6741,7 @@ class AdminOrdersControllerCore extends AdminController
$objOrderInvoice->total_paid_tax_excl += $diffPriceTaxExcl;
$objOrderInvoice->total_paid_tax_incl += $diffPriceTaxIncl;
$result &= $objOrderInvoice->update();
+ $objOrderInvoice->recomputeTotalsFromLines();
}
// Update product available quantity
@@ -6699,6 +6999,11 @@ class AdminOrdersControllerCore extends AdminController
$invoice_array[] = $invoice;
}
+ OrderTaxDetail::hardDeleteForBooking(
+ $idHotelBooking,
+ ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHotelBooking)
+ );
+
$objServiceProductOrderDetail->deleteSeviceProducts(0, $idHotelBooking);
/*By webkul to delete cart and order entries from cart and order tables of hotelreservationsystem when delete booking form the order line in order detaoil page*/
@@ -6764,6 +7069,10 @@ class AdminOrdersControllerCore extends AdminController
$order->update();
}
+ if (isset($order_invoice) && $order_invoice->id) {
+ $order_invoice->recomputeTotalsFromLines();
+ }
+
// Assign to smarty informations in order to show the new product line
$this->context->smarty->assign(array(
'order' => $order,
@@ -6856,6 +7165,10 @@ class AdminOrdersControllerCore extends AdminController
$objOrder->total_products_wt = $objOrder->total_products_wt > 0 ? $objOrder->total_products_wt : 0;
$result &= $objOrder->update();
+
+ if (isset($objOrderInvoice) && $objOrderInvoice->id) {
+ $objOrderInvoice->recomputeTotalsFromLines();
+ }
}
if (!$result) {
@@ -7585,6 +7898,29 @@ class AdminOrdersControllerCore extends AdminController
$result &= $objOrderDetail->update();
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams((int) $objServiceProductOrderDetail->id);
+ if ($serviceTourismTaxParams) {
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+ }
+
if ($objOrderDetail->id_order_invoice != 0) {
// values changes as values are calculated accoding to the quantity of the product by webkul
$order_invoice = new OrderInvoice($objOrderDetail->id_order_invoice);
@@ -7604,6 +7940,10 @@ class AdminOrdersControllerCore extends AdminController
$objOrder->total_paid_tax_excl = Tools::ps_round($objOrder->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
$result &= $objOrder->update();
+
+ if (isset($order_invoice) && $order_invoice->id) {
+ $order_invoice->recomputeTotalsFromLines();
+ }
}
}
@@ -7840,6 +8180,28 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->quantity = $objServiceProductCartDetail->quantity;
$objServiceProductOrderDetail->save();
+ if (Configuration::get('QLO_USE_TOURISM_TAX')
+ && ($serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams((int) $objServiceProductOrderDetail->id))
+ ) {
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+
// update totals amount of order
$order->total_products = Tools::ps_round((float)($order->total_products + $totalPriceChangeTaxExcl), _PS_PRICE_COMPUTE_PRECISION_);
$order->total_products_wt = Tools::ps_round((float)($order->total_products_wt + $totalPriceChangeTaxIncl), _PS_PRICE_COMPUTE_PRECISION_);
@@ -7853,6 +8215,7 @@ class AdminOrdersControllerCore extends AdminController
$objOrderInvoice->total_paid_tax_excl += Tools::ps_round((float)$totalPriceChangeTaxExcl, _PS_PRICE_COMPUTE_PRECISION_);
$objOrderInvoice->total_paid_tax_incl += Tools::ps_round((float)$totalPriceChangeTaxIncl, _PS_PRICE_COMPUTE_PRECISION_);
$objOrderInvoice->save();
+ $objOrderInvoice->recomputeTotalsFromLines();
}
// discount
@@ -8130,6 +8493,28 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->auto_added = $autoAdded;
$objServiceProductOrderDetail->save();
+ if (Configuration::get('QLO_USE_TOURISM_TAX')
+ && ($serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams((int) $objServiceProductOrderDetail->id))
+ ) {
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+
// update totals amount of order
$objOrder->total_products = Tools::ps_round((float)($objOrder->total_products + $totalPriceChangeTaxExcl), _PS_PRICE_COMPUTE_PRECISION_);
$objOrder->total_products_wt = Tools::ps_round((float)($objOrder->total_products_wt + $totalPriceChangeTaxIncl), _PS_PRICE_COMPUTE_PRECISION_);
@@ -8143,6 +8528,7 @@ class AdminOrdersControllerCore extends AdminController
$objOrderInvoice->total_paid_tax_excl += Tools::ps_round((float)$totalPriceChangeTaxExcl, _PS_PRICE_COMPUTE_PRECISION_);
$objOrderInvoice->total_paid_tax_incl += Tools::ps_round((float)$totalPriceChangeTaxIncl, _PS_PRICE_COMPUTE_PRECISION_);
$objOrderInvoice->save();
+ $objOrderInvoice->recomputeTotalsFromLines();
}
// discounts calculation for order
@@ -8256,6 +8642,8 @@ class AdminOrdersControllerCore extends AdminController
}
if ($res &= $objServiceProductOrderDetail->delete()) {
+ OrderTaxDetail::hardDeleteForBooking(0, array((int) $objServiceProductOrderDetail->id));
+
$order = new Order($objServiceProductOrderDetail->id_order);
if ($objOrderDetail->product_price_calculation_method == Product::PRICE_CALCULATION_METHOD_PER_DAY) {
$numDays = HotelHelper::getNumberOfDays(
@@ -8306,6 +8694,10 @@ class AdminOrdersControllerCore extends AdminController
$order->total_paid_tax_excl = Tools::ps_round($order->getOrderTotal(false), _PS_PRICE_COMPUTE_PRECISION_);
$res &= $order->update();
+
+ if (isset($objOrderInvoice) && $objOrderInvoice->id) {
+ $objOrderInvoice->recomputeTotalsFromLines();
+ }
}
if ($res) {
$response['service_panel']= $servicesBlock = $this->processRenderServicesPanel(
@@ -8540,4 +8932,133 @@ class AdminOrdersControllerCore extends AdminController
$this->ajaxDie(json_encode($result));
}
+
+ /**
+ *
+ * @param int $idHtlBooking
+ * @param int $idRoomProduct
+ * @param array|null $bookingTourismTaxStatus
+ * @return int OrderTaxDetail::STATUS_APPLIED|STATUS_NOT_APPLICABLE|STATUS_NONE
+ */
+ protected static function combinedRoomTourismTaxStatus($idHtlBooking, $idRoomProduct, $bookingTourismTaxStatus, $serviceTourismTaxTotal)
+ {
+ $roomApplied = $bookingTourismTaxStatus && $bookingTourismTaxStatus['status'] === OrderTaxDetail::STATUS_APPLIED;
+ if ($roomApplied || $serviceTourismTaxTotal > 0) {
+ return OrderTaxDetail::STATUS_APPLIED;
+ }
+
+ $roomHasRule = (bool) Product::getIdTourismTaxRulesGroupByIdProduct((int) $idRoomProduct);
+ $serviceHasRule = false;
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail((int) $idHtlBooking) as $idServiceLine) {
+ $serviceLine = new ServiceProductOrderDetail($idServiceLine);
+ if (Validate::isLoadedObject($serviceLine) && Product::getIdTourismTaxRulesGroupByIdProduct((int) $serviceLine->id_product)) {
+ $serviceHasRule = true;
+ break;
+ }
+ }
+
+ return ($roomHasRule || $serviceHasRule) ? OrderTaxDetail::STATUS_NONE : OrderTaxDetail::STATUS_NOT_APPLICABLE;
+ }
+
+ /**
+ * Map per-booking tourism tax apply/exempt failures (whole-order bulk case) to deduplicated messages.
+ *
+ * @param array $failures
+ * @param array $messagesByResult
+ * @return string[]
+ */
+ protected static function tourismTaxErrorsFromResults(array $failures, array $messagesByResult)
+ {
+ $messages = array();
+ foreach ($failures as $failure) {
+ if (!isset($messagesByResult[$failure['result']])) {
+ continue;
+ }
+ $messages[$failure['result']] = $messagesByResult[$failure['result']];
+ }
+ return array_values($messages);
+ }
+
+ /**
+ * Apply tourism tax to a booking (id_htl_booking), a standalone service line
+ * (id_service_product_order_detail), or every booking of a whole order (id_order) —
+ * see OrderTaxDetail::applyBooking()/applyServiceLine().
+ */
+ public function ajaxProcessApplyTourismTax()
+ {
+ $idHtlBooking = (int) Tools::getValue('id_htl_booking', 0);
+ $idServiceProductOrderDetail = (int) Tools::getValue('id_service_product_order_detail', 0);
+ $idOrder = (int) Tools::getValue('id_order', 0);
+ $idEmployee = (int) Context::getContext()->employee->id;
+ $autoMsgLabel = $this->l('Tourism Tax Applied');
+ $errors = array();
+ $messagesByResult = array(
+ OrderTaxDetail::APPLY_ERROR_RESTORE => $this->l('Could not restore tourism tax.'),
+ OrderTaxDetail::APPLY_ERROR_NOT_APPLICABLE => $this->l('Tourism tax is not applicable for this booking.'),
+ );
+
+ if ($idHtlBooking) {
+ $result = OrderTaxDetail::applyBooking($idHtlBooking, $idEmployee, $autoMsgLabel);
+ if (isset($messagesByResult[$result])) {
+ $errors[] = $messagesByResult[$result];
+ }
+ } elseif ($idServiceProductOrderDetail) {
+ $result = OrderTaxDetail::applyServiceLine($idServiceProductOrderDetail, $idEmployee, $autoMsgLabel);
+ if (isset($messagesByResult[$result])) {
+ $errors[] = $messagesByResult[$result];
+ }
+ } elseif ($idOrder) {
+ $errors = self::tourismTaxErrorsFromResults(OrderTaxDetail::applyForOrder($idOrder, $idEmployee, $autoMsgLabel), $messagesByResult);
+ } else {
+ $errors[] = $this->l('Invalid request parameters.');
+ }
+
+ die(Tools::jsonEncode(array(
+ 'hasError' => !empty($errors),
+ 'errors' => $errors,
+ )));
+ }
+
+ /**
+ * Exempt tourism tax for a booking (id_htl_booking), a standalone service line
+ * (id_service_product_order_detail), or every booking of a whole order (id_order) —
+ * see OrderTaxDetail::exemptBooking()/exemptServiceLine().
+ */
+ public function ajaxProcessExemptTourismTax()
+ {
+ $idHtlBooking = (int) Tools::getValue('id_htl_booking', 0);
+ $idServiceProductOrderDetail = (int) Tools::getValue('id_service_product_order_detail', 0);
+ $idOrder = (int) Tools::getValue('id_order', 0);
+ $idEmployee = (int) Context::getContext()->employee->id;
+ $note = trim((string) Tools::getValue('note', ''));
+ $note = ($note !== '') ? $note : null;
+ $autoMsgLabel = $this->l('Tourism Tax Exempted');
+ $remarkLabel = $this->l('Remark');
+ $errors = array();
+ $messagesByResult = array(
+ OrderTaxDetail::EXEMPT_ERROR_NO_RULE => $this->l('Tourism tax exemption is not applicable for this line.'),
+ OrderTaxDetail::EXEMPT_ERROR_SAVE => $this->l('Could not exempt tourism tax.'),
+ );
+
+ if ($idHtlBooking) {
+ $result = OrderTaxDetail::exemptBooking($idHtlBooking, $idEmployee, $note, $autoMsgLabel, $remarkLabel);
+ if (isset($messagesByResult[$result])) {
+ $errors[] = $messagesByResult[$result];
+ }
+ } elseif ($idServiceProductOrderDetail) {
+ $result = OrderTaxDetail::exemptServiceLine($idServiceProductOrderDetail, $idEmployee, $note, $autoMsgLabel, $remarkLabel);
+ if (isset($messagesByResult[$result])) {
+ $errors[] = $messagesByResult[$result];
+ }
+ } elseif ($idOrder) {
+ $errors = self::tourismTaxErrorsFromResults(OrderTaxDetail::exemptForOrder($idOrder, $idEmployee, $note, $autoMsgLabel, $remarkLabel), $messagesByResult);
+ } else {
+ $errors[] = $this->l('Invalid request parameters.');
+ }
+
+ die(Tools::jsonEncode(array(
+ 'hasError' => !empty($errors),
+ 'errors' => $errors,
+ )));
+ }
}
diff --git a/controllers/admin/AdminProductsController.php b/controllers/admin/AdminProductsController.php
index 795e4e5f3..66b70e3e9 100644
--- a/controllers/admin/AdminProductsController.php
+++ b/controllers/admin/AdminProductsController.php
@@ -242,7 +242,7 @@ class AdminProductsControllerCore extends AdminController
$this->_select .= ' a.`show_at_front`, (SELECT COUNT(hri.`id`) FROM `'._DB_PREFIX_.'htl_room_information` hri WHERE hri.`id_product` = a.`id_product`) as num_rooms, ';
$this->_select .= 'hrt.`adults`, hrt.`children`, hrt.`max_guests`, hb.`id` as id_hotel, aa.`city`, hbl.`hotel_name`, ';
$this->_select .= 'shop.`name` AS `shopname`, a.`id_shop_default`, ';
- $this->_select .= $alias_image.'.`id_image` AS `id_image`, cl.`name` AS `name_category`, '.$alias.'.`price`, 0 AS `price_final`, a.`is_virtual`, pd.`nb_downloadable`, sav.`quantity` AS `sav_quantity`, '.$alias.'.`active`, IF(sav.`quantity`<=0, 1, 0) AS `badge_danger`';
+ $this->_select .= $alias_image.'.`id_image` AS `id_image`, cl.`name` AS `name_category`, '.$alias.'.`price`, 0 AS `price_final`, a.`is_virtual`, pd.`nb_downloadable`, sav.`quantity` AS `sav_quantity`, '.$alias.'.`active`, IF(sav.`quantity`<=0, 1, 0) AS `badge_danger`, a.`id_tourism_tax_rules_group`';
$this->_select .= ', IFNULL(hap.`active`, 0) AS advance_payment';
$this->_select .= ', IF(IFNULL(hap.`active`, 0), 1, 0) badge_success, IF(IFNULL(hap.`active`, 0), 0, 1) badge_danger ';
@@ -645,7 +645,7 @@ class AdminProductsControllerCore extends AdminController
{
$prefix = $this->getCookieFilterPrefix();
$orderByPriceFinal = (empty($orderBy) ? ($this->context->cookie->__get($prefix.$this->table.'Orderby') ? $this->context->cookie->__get($prefix.$this->table.'Orderby') : 'id_'.$this->table) : $orderBy);
- $orderWayPriceFinal = (empty($orderWay) ? ($this->context->cookie->__get($prefix.$this->table.'Orderway') ? $this->context->cookie->__get($prefix.$this->table.'Orderby') : 'ASC') : $orderWay);
+ $orderWayPriceFinal = (empty($orderWay) ? ($this->context->cookie->__get($prefix.$this->table.'Orderway') ? $this->context->cookie->__get($prefix.$this->table.'Orderway') : 'ASC') : $orderWay);
if ($orderByPriceFinal == 'price_final') {
$orderBy = 'id_'.$this->table;
$orderWay = 'ASC';
@@ -680,24 +680,52 @@ class AdminProductsControllerCore extends AdminController
unset($this->fields_list['id_feature']);
unset($this->fields_list['id_service_product']);
}
- parent::getList($id_lang, $orderBy, $orderWay, $start, $limit, $this->context->shop->id);
+ parent::getList($id_lang, $orderBy, $orderWay, $start, $limit, $id_lang_shop ?: $this->context->shop->id);
/* update product quantity with attributes ...*/
$nb = count($this->_list);
if ($this->_list) {
$context = $this->context->cloneContext();
$context->shop = clone($context->shop);
+ $isAllShopsContext = (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP);
+ $priceDisplayPrecision = (int) Configuration::get('PS_PRICE_DISPLAY_PRECISION');
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $todayDate = date('Y-m-d');
+ $tomorrowDate = date('Y-m-d', strtotime('+1 day', strtotime($todayDate)));
+
/* update product final price */
for ($i = 0; $i < $nb; $i++) {
- if (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP) {
+ if ($isAllShopsContext) {
$context->shop = new Shop((int)$this->_list[$i]['id_shop_default']);
}
// convert price with the currency from context
$this->_list[$i]['price'] = Tools::convertPrice($this->_list[$i]['price'], $this->context->currency, true, $this->context);
$this->_list[$i]['price_tmp'] = Product::getPriceStatic($this->_list[$i]['id_product'], true, null,
- (int)Configuration::get('PS_PRICE_DISPLAY_PRECISION'), null, false, true, 1, true, null, null, null, $nothing, true, true,
+ $priceDisplayPrecision, null, false, true, 1, true, null, null, null, $nothing, true, true,
$context);
+
+ $idTourismTaxRulesGroup = (int) $this->_list[$i]['id_tourism_tax_rules_group'];
+ $idHotel = (int) $this->_list[$i]['id_hotel'];
+ if ($useTourismTax && $idTourismTaxRulesGroup && $idHotel) {
+ $idProduct = $this->_list[$i]['id_product'];
+ $vatAddress = new Address(HotelRoomType::getHotelIdAddressByIdProduct($idProduct));
+ $vatCalculator = TaxManagerFactory::getManager($vatAddress, Product::getIdTaxRulesGroupByIdProduct($idProduct))->getTaxCalculator();
+ $unitPriceTaxExcl = $vatCalculator->removeTaxes($this->_list[$i]['price_tmp']);
+
+ $hotelTaxContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, $vatAddress);
+ $taxCalculator = TaxManagerFactory::getManager($hotelTaxContext['address'], $idTourismTaxRulesGroup)->getTaxCalculator();
+ $this->_list[$i]['price_tmp'] += $taxCalculator->getTaxesTotalAmount(
+ $unitPriceTaxExcl,
+ $todayDate,
+ 1,
+ 1,
+ array(),
+ $hotelTaxContext['collectionType'],
+ 1,
+ $this->context->currency->id
+ );
+ }
}
}
@@ -3263,7 +3291,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRuleGroup = new TaxRulesGroup(
$serviceProductPriceInfo['id_tax_rules_group'],
$this->context->language->id
- ))) {
+ )) && !$objTaxRuleGroup->is_tourism_tax_rule_group) {
$associationInfo['tax_rules_group_name'] = $objTaxRuleGroup->name;
}
$associationInfo['id_room_type_service_product_price'] = $serviceProductPriceInfo['id_room_type_service_product_price'];
@@ -3273,7 +3301,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRuleGroup = new TaxRulesGroup(
$objProduct->id_tax_rules_group,
$this->context->language->id
- ))) {
+ )) && !$objTaxRuleGroup->is_tourism_tax_rule_group) {
$associationInfo['default_tax_rules_group_name'] = $objTaxRuleGroup->name;
}
@@ -3287,7 +3315,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRulesGroup = new TaxRulesGroup(
$serviceProduct['id_tax_rules_group'],
$this->context->language->id
- ))) {
+ )) && !$objTaxRulesGroup->is_tourism_tax_rule_group) {
$serviceProduct['tax_rules_group_name'] = $objTaxRulesGroup->name;
}
@@ -4022,6 +4050,25 @@ class AdminProductsControllerCore extends AdminController
$data->assign('productFeaturePrices', $productFeaturePrices);
}
+ if (Configuration::get('QLO_USE_TOURISM_TAX') && !Tax::excludeTaxeOption()) {
+ $tourismTaxRulesGroups = TaxRulesGroup::getTaxRulesGroupsForOptions(true, true);
+ $data->assign('tourismTaxRulesGroups', $tourismTaxRulesGroups);
+ $data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0);
+
+ $roomTypeInfo = (new HotelRoomType())->getRoomTypeInfoByIdProduct((int) $product->id);
+ $idHotel = $roomTypeInfo ? (int) $roomTypeInfo['id_hotel'] : 0;
+ $collectionType = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, $address)['collectionType'];
+
+ $tourismTaxRates = array();
+ foreach ($tourismTaxRulesGroups as $tourismTaxRulesGroup) {
+ if (!$tourismTaxRulesGroup['id_tax_rules_group']) {
+ continue;
+ }
+ $tourismTaxRates[$tourismTaxRulesGroup['id_tax_rules_group']] = TaxConfiguration::getPreviewParams($tourismTaxRulesGroup['id_tax_rules_group'], $address, $this->context->language->id, $collectionType);
+ }
+ $data->assign('tourismTaxRatesByGroup', $tourismTaxRates);
+ }
+
$this->tpl_form_vars['custom_form'] = $data->fetch();
}
diff --git a/controllers/admin/AdminTaxRulesGroupController.php b/controllers/admin/AdminTaxRulesGroupController.php
index 894e7f257..6dd6566bf 100644
--- a/controllers/admin/AdminTaxRulesGroupController.php
+++ b/controllers/admin/AdminTaxRulesGroupController.php
@@ -96,6 +96,59 @@ class AdminTaxRulesGroupControllerCore extends AdminController
return parent::renderList();
}
+ /**
+ * Return taxes for the rule-form dropdown, filtered by whether this TRG is a tourism group.
+ *
+ * @return array
+ */
+ protected function getTaxOptionsForGroup()
+ {
+ $idTaxRulesGroup = (int) Tools::getValue('id_tax_rules_group');
+ $isTourism = false;
+ if ($idTaxRulesGroup) {
+ $taxRulesGroup = new TaxRulesGroup($idTaxRulesGroup);
+ if (Validate::isLoadedObject($taxRulesGroup)) {
+ $isTourism = (bool) $taxRulesGroup->is_tourism_tax_rule_group;
+ }
+ }
+
+ $idLang = (int) $this->context->language->id;
+ $sql = new DbQuery();
+ if ($isTourism) {
+ $sql->select(
+ 't.`id_tax`, tl.`name`,'
+ . ' IFNULL(tourism_tax.`calculation_type`, 0) AS tourism_tax_calc_type,'
+ . ' IFNULL(tourism_tax.`tax_value`, 0) AS tourism_tax_value'
+ );
+ } else {
+ $sql->select('t.`id_tax`, tl.`name`, t.`rate`');
+ }
+ $sql->from('tax', 't');
+ $sql->leftJoin('tax_lang', 'tl', 't.`id_tax` = tl.`id_tax` AND tl.`id_lang` = ' . $idLang);
+ if ($isTourism) {
+ $sql->leftJoin('tax_configuration', 'tourism_tax', 'tourism_tax.`id_tax` = t.`id_tax`');
+ }
+ $sql->where('t.`deleted` != 1 AND t.`active` = 1');
+ $sql->where('t.`is_tourism_tax` = ' . ($isTourism ? 1 : 0));
+ $sql->orderBy('tl.`name` ASC');
+
+ $rows = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS($sql);
+
+ if ($isTourism && is_array($rows)) {
+ $currency = $this->context->currency;
+ $currencySign = Tools::safeOutput(trim($currency->prefix . $currency->suffix));
+ foreach ($rows as &$row) {
+ $typeLabel = ((int) $row['tourism_tax_calc_type'] === TaxConfiguration::CALCULATION_TYPE_PERCENTAGE)
+ ? (float) $row['tourism_tax_value'] . '%'
+ : $currencySign . ' ' . (float) $row['tourism_tax_value'];
+ $row['name'] = $row['name'] . ' (' . $typeLabel . ')';
+ }
+ unset($row);
+ }
+
+ return $rows;
+ }
+
public function initRulesList($id_group)
{
$this->table = 'tax_rule';
@@ -127,7 +180,10 @@ class AdminTaxRulesGroupControllerCore extends AdminController
),
'rate' => array(
'title' => $this->l('Tax'),
- 'class' => 'fixed-width-sm'
+ 'class' => 'fixed-width-sm',
+ 'callback' => 'displayTaxRuleRate',
+ 'orderby' => false,
+ 'search' => false,
),
'description' => array(
'title' => $this->l('Description')
@@ -141,7 +197,10 @@ class AdminTaxRulesGroupControllerCore extends AdminController
c.`name` AS country_name,
s.`name` AS state_name,
CONCAT_WS(" - ", a.`zipcode_from`, a.`zipcode_to`) AS zipcode,
- t.rate';
+ t.`rate`,
+ t.`is_tourism_tax`,
+ IFNULL(tourism_tax.`calculation_type`, 0) AS tourism_tax_calc_type,
+ IFNULL(tourism_tax.`tax_value`, 0) AS tourism_tax_value';
$this->_join = '
LEFT JOIN `'._DB_PREFIX_.'country_lang` c
@@ -149,7 +208,9 @@ class AdminTaxRulesGroupControllerCore extends AdminController
LEFT JOIN `'._DB_PREFIX_.'state` s
ON (a.`id_state` = s.`id_state`)
LEFT JOIN `'._DB_PREFIX_.'tax` t
- ON (a.`id_tax` = t.`id_tax`)';
+ ON (a.`id_tax` = t.`id_tax`)
+ LEFT JOIN `'._DB_PREFIX_.'tax_configuration` tourism_tax
+ ON (tourism_tax.`id_tax` = t.`id_tax`)';
$this->_where = 'AND `id_tax_rules_group` = '.(int)$id_group;
$this->_use_found_rows = false;
@@ -194,7 +255,19 @@ class AdminTaxRulesGroupControllerCore extends AdminController
'label' => $this->l('Disabled')
)
)
- )
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Tourism tax rule group'),
+ 'name' => 'is_tourism_tax_rule_group',
+ 'required' => false,
+ 'is_bool' => true,
+ 'hint' => $this->l('Mark this group as tourism-only. All tax rules added here must reference tourism taxes.'),
+ 'values' => array(
+ array('id' => 'is_tourism_tax_rule_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'is_tourism_tax_rule_off', 'value' => 0, 'label' => $this->l('No')),
+ )
+ ),
),
'submit' => array(
'title' => $this->l('Save and stay'),
@@ -309,7 +382,7 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$this->l('- This tax only: Will apply only this tax').'
',
$this->l('- Combine: Combine taxes (e.g.: 10% + 5% = 15%)').'
',
$this->l('- One after another: Apply taxes one after another (e.g.: 0 + 10% = 0 + 5% = 5.5)')
- )
+ ),
),
array(
'type' => 'select',
@@ -317,7 +390,7 @@ class AdminTaxRulesGroupControllerCore extends AdminController
'name' => 'id_tax',
'required' => false,
'options' => array(
- 'query' => Tax::getTaxes((int)$this->context->language->id),
+ 'query' => $this->getTaxOptionsForGroup(),
'id' => 'id_tax',
'name' => 'name',
'default' => array(
@@ -325,7 +398,7 @@ class AdminTaxRulesGroupControllerCore extends AdminController
'label' => $this->l('No Tax')
)
),
- 'hint' => sprintf($this->l('(Total tax: %s)'), '9%')
+ 'hint' => $this->l('Select the tax to apply for this rule.')
),
array(
'type' => 'text',
@@ -374,6 +447,75 @@ class AdminTaxRulesGroupControllerCore extends AdminController
}
+ /**
+ * List column callback: render the Tax column in the tax rules list.
+ * Tourism-fixed taxes show the currency sign; tourism-percent and regular VAT taxes show %.
+ *
+ * @param mixed $value t.rate (always 0 for tourism taxes)
+ * @param array $row Full list row — includes is_tourism_tax, tourism_tax_calc_type, tourism_tax_value
+ * @return string
+ */
+ public function displayTaxRuleRate($value, $row)
+ {
+ return TaxConfiguration::getFormattedRateForDisplay($value, $row, $this->context->currency);
+ }
+
+ public function postProcess()
+ {
+ if ($this->action == 'save') {
+ $idGroup = (int) Tools::getValue('id_tax_rules_group');
+ $newIsTourism = (int) Tools::getValue('is_tourism_tax_rule_group');
+
+ if ($idGroup) {
+ $taxRulesGroup = new TaxRulesGroup($idGroup);
+ if (Validate::isLoadedObject($taxRulesGroup)) {
+ $oldIsTourism = (int) $taxRulesGroup->is_tourism_tax_rule_group;
+
+ if ($oldIsTourism && !$newIsTourism) {
+ if ($taxRulesGroup->hasTourismTaxRules()) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a VAT group: it still contains tourism taxes. Remove the tourism tax rules first.'
+ );
+ $_POST['is_tourism_tax_rule_group'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
+
+ if ($taxRulesGroup->hasProductsUsingAsTourismGroup()) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a VAT group: one or more products still use it as their tourism tax rule. Reassign them first.'
+ );
+ $_POST['is_tourism_tax_rule_group'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
+ }
+
+ if (!$oldIsTourism && $newIsTourism) {
+ if ($taxRulesGroup->hasVatTaxRules()) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a tourism group: it still contains VAT taxes. Remove the VAT tax rules first.'
+ );
+ $_POST['is_tourism_tax_rule_group'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
+
+ if ($taxRulesGroup->hasProductsUsingAsVatGroup()) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a tourism group: one or more products or services still use it as their regular tax rule. Reassign them first.'
+ );
+ $_POST['is_tourism_tax_rule_group'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
+ }
+ }
+ }
+ }
+ parent::postProcess();
+ }
+
public function initProcess()
{
if (Tools::isSubmit('deletetax_rule')) {
@@ -430,27 +572,27 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$this->errors[] = Tools::displayError('A tax rule already exists for this country/state with tax only behavior.');
continue;
}
- $tr = new TaxRule();
+ $taxRule = new TaxRule();
// update or creation?
if (isset($id_rule) && $first) {
- $tr->id = $id_rule;
+ $taxRule->id = $id_rule;
$first = false;
}
- $tr->id_tax = $id_tax;
+ $taxRule->id_tax = $id_tax;
$tax_rules_group = new TaxRulesGroup((int)$id_tax_rules_group);
- $tr->id_tax_rules_group = (int)$tax_rules_group->id;
- $tr->id_country = (int)$id_country;
- $tr->id_state = (int)$id_state;
- list($tr->zipcode_from, $tr->zipcode_to) = $tr->breakDownZipCode($zip_code);
+ $taxRule->id_tax_rules_group = (int)$tax_rules_group->id;
+ $taxRule->id_country = (int)$id_country;
+ $taxRule->id_state = (int)$id_state;
+ list($taxRule->zipcode_from, $taxRule->zipcode_to) = $taxRule->breakDownZipCode($zip_code);
// Construct Object Country
$country = new Country((int)$id_country, (int)$this->context->language->id);
if ($zip_code && $country->need_zip_code) {
if ($country->zip_code_format) {
- foreach (array($tr->zipcode_from, $tr->zipcode_to) as $zip_code) {
+ foreach (array($taxRule->zipcode_from, $taxRule->zipcode_to) as $zip_code) {
if ($zip_code) {
if (!$country->checkZipCode($zip_code)) {
$this->errors[] = sprintf(
@@ -463,19 +605,19 @@ class AdminTaxRulesGroupControllerCore extends AdminController
}
}
- $tr->behavior = (int)$behavior;
- $tr->description = $description;
- $this->tax_rule = $tr;
- $_POST['id_state'] = $tr->id_state;
+ $taxRule->behavior = (int)$behavior;
+ $taxRule->description = $description;
+ $this->tax_rule = $taxRule;
+ $_POST['id_state'] = $taxRule->id_state;
- $this->errors = array_merge($this->errors, $this->validateTaxRule($tr));
+ $this->errors = array_merge($this->errors, $this->validateTaxRule($taxRule));
if (count($this->errors) == 0) {
$tax_rules_group = $this->updateTaxRulesGroup($tax_rules_group);
- $tr->id = (int)$tax_rules_group->getIdTaxRuleGroupFromHistorizedId((int)$tr->id);
- $tr->id_tax_rules_group = (int)$tax_rules_group->id;
+ $taxRule->id = (int)$tax_rules_group->getIdTaxRuleGroupFromHistorizedId((int)$taxRule->id);
+ $taxRule->id_tax_rules_group = (int)$tax_rules_group->id;
- if (!$tr->save()) {
+ if (!$taxRule->save()) {
$this->errors[] = Tools::displayError('An error has occurred: Cannot save the current tax rule.');
}
}
@@ -525,14 +667,14 @@ class AdminTaxRulesGroupControllerCore extends AdminController
/**
* Check if the tax rule could be added in the database
*
- * @param TaxRule $tr
+ * @param TaxRule $taxRule
*
* @return array
*/
- protected function validateTaxRule(TaxRule $tr)
+ protected function validateTaxRule(TaxRule $taxRule)
{
// @TODO: check if the rule already exists
- return $tr->validateController();
+ return $taxRule->validateController();
}
protected function displayAjaxUpdateTaxRule()
diff --git a/controllers/admin/AdminTaxesController.php b/controllers/admin/AdminTaxesController.php
index ca2787e05..1f2433087 100644
--- a/controllers/admin/AdminTaxesController.php
+++ b/controllers/admin/AdminTaxesController.php
@@ -29,6 +29,11 @@
*/
class AdminTaxesControllerCore extends AdminController
{
+ protected $invalidCoreFields = array();
+ protected $invalidTierFields = array();
+ protected $invalidChildFields = array();
+ protected $invalidValidityFields = array();
+
public function __construct()
{
$this->bootstrap = true;
@@ -49,14 +54,29 @@ class AdminTaxesControllerCore extends AdminController
$this->fields_list = array(
'id_tax' => array('title' => $this->l('ID'), 'align' => 'center', 'class' => 'fixed-width-xs'),
'name' => array('title' => $this->l('Name'), 'width' => 'auto'),
- 'rate' => array('title' => $this->l('Rate'), 'align' => 'center', 'suffix' => '%' , 'class' => 'fixed-width-md'),
+ 'is_tourism_tax' => array(
+ 'title' => $this->l('Tourism Tax'),
+ 'align' => 'center',
+ 'class' => 'fixed-width-sm',
+ 'callback' => 'displayTourismTaxBadge',
+ 'orderby' => false,
+ 'type' => 'bool',
+ ),
+ 'rate' => array(
+ 'title' => $this->l('Rate'),
+ 'align' => 'center',
+ 'class' => 'fixed-width-md',
+ 'callback' => 'displayRate',
+ 'orderby' => false,
+ ),
'active' => array('title' => $this->l('Enabled'), 'width' => 25, 'align' => 'center', 'active' => 'status', 'type' => 'bool', 'orderby' => false, 'class' => 'fixed-width-sm', 'remove_onclick' => true)
);
- $ecotax_desc = '';
- if (Configuration::get('PS_USE_ECOTAX')) {
- $ecotax_desc = $this->l('If you disable the ecotax, the ecotax for all your products will be set to 0.');
- }
+ // Code For eco-tax working
+ // $ecotax_desc = '';
+ // if (Configuration::get('PS_USE_ECOTAX')) {
+ // $ecotax_desc = $this->l('If you disable the ecotax, the ecotax for all your products will be set to 0.');
+ // }
$this->fields_options = array(
'general' => array(
@@ -87,32 +107,55 @@ class AdminTaxesControllerCore extends AdminController
// ),
// 'identifier' => 'id'
// ),
- 'PS_USE_ECOTAX' => array(
- 'title' => $this->l('Use ecotax'),
- 'desc' => $ecotax_desc,
- 'validation' => 'isBool',
- 'cast' => 'intval',
- 'type' => 'bool'
- ),
+ // Code For eco-tax working
+ // 'PS_USE_ECOTAX' => array(
+ // 'title' => $this->l('Use ecotax'),
+ // 'desc' => $ecotax_desc,
+ // 'validation' => 'isBool',
+ // 'cast' => 'intval',
+ // 'type' => 'bool'
+ // ),
),
'submit' => array('title' => $this->l('Save'))
),
);
- if (Configuration::get('PS_USE_ECOTAX') || Tools::getValue('PS_USE_ECOTAX')) {
- $this->fields_options['general']['fields']['PS_ECOTAX_TAX_RULES_GROUP_ID'] = array(
- 'title' => $this->l('Ecotax'),
- 'hint' => $this->l('Define the ecotax (e.g. French ecotax: 19.6%).'),
+ // Code For eco-tax working
+ // if (Configuration::get('PS_USE_ECOTAX') || Tools::getValue('PS_USE_ECOTAX')) {
+ // $this->fields_options['general']['fields']['PS_ECOTAX_TAX_RULES_GROUP_ID'] = array(
+ // 'title' => $this->l('Ecotax'),
+ // 'hint' => $this->l('Define the ecotax (e.g. French ecotax: 19.6%).'),
+ // 'cast' => 'intval',
+ // 'type' => 'select',
+ // 'identifier' => 'id_tax_rules_group',
+ // 'list' => TaxRulesGroup::getTaxRulesGroupsForOptions()
+ // );
+ // }
+
+ $this->fields_options['general']['fields']['QLO_USE_TOURISM_TAX'] = array(
+ 'title' => $this->l('Use tourism tax'),
+ 'hint' => $this->l('Enable tourism tax calculation for hotel room bookings.'),
+ 'validation' => 'isBool',
+ 'cast' => 'intval',
+ 'type' => 'bool',
+ );
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX') || Tools::getValue('QLO_USE_TOURISM_TAX')) {
+ $this->fields_options['general']['fields']['QLO_TOURISM_TAX_GROSSED_UP'] = array(
+ 'title' => $this->l('Include tourism tax in displayed prices'),
+ 'desc' => $this->l('When enabled, tourism tax is included in displayed prices. The applicable tourism tax is always shown as a separate line if applicable.'),
+ 'validation' => 'isBool',
'cast' => 'intval',
- 'type' => 'select',
- 'identifier' => 'id_tax_rules_group',
- 'list' => TaxRulesGroup::getTaxRulesGroupsForOptions()
- );
+ 'type' => 'bool',
+ );
}
parent::__construct();
$this->_where .= ' AND a.deleted = 0';
+ $this->_join .= ' LEFT JOIN `'._DB_PREFIX_.'tax_configuration` tourism_tax ON tourism_tax.`id_tax` = a.`id_tax`';
+ $extraSelect = 'IFNULL(tourism_tax.`calculation_type`, 0) AS `tourism_tax_calc_type`, IFNULL(tourism_tax.`tax_value`, 0) AS `tourism_tax_value`';
+ $this->_select = ($this->_select ? $this->_select . ', ' : '') . $extraSelect;
}
public function initPageHeaderToolbar()
@@ -187,30 +230,375 @@ class AdminTaxesControllerCore extends AdminController
return $tpl_enable->fetch();
}
+ public function renderList()
+ {
+ $this->_new_list_header_design = true;
+
+ return parent::renderList();
+ }
+
public function renderForm()
{
+ $idTax = (int) Tools::getValue('id_' . $this->table);
+ $subtype = null;
+ $tiers = array();
+ $childRanges = array();
+ $validityRanges = array();
+ $contextCurrency = $this->context->currency;
+ $currencySign = Tools::safeOutput(trim($contextCurrency->prefix . $contextCurrency->suffix));
+
+ $isAnySubmit = Tools::isSubmit('submitAdd' . $this->table) || Tools::isSubmit('submitAdd' . $this->table . 'AndStay');
+
+ if ($idTax && !$isAnySubmit) {
+ $subtype = TaxConfiguration::getByTaxId($idTax);
+ if ($subtype) {
+ $_POST['tax_calc_type'] = $subtype->calculation_type;
+ $_POST['is_per_night'] = $subtype->per_night;
+ $_POST['is_per_person'] = $subtype->per_person;
+ $_POST['tax_value'] = $subtype->tax_value;
+ $_POST['is_tiered'] = $subtype->has_tiered_pricing;
+ $_POST['has_child_rate'] = $subtype->apply_on_child;
+ $_POST['has_child_age_range'] = $subtype->has_child_age_range;
+ $_POST['child_calc_type'] = $subtype->child_calculation_type;
+ $_POST['has_multiple_valid_ranges'] = $subtype->has_multiple_valid_ranges;
+
+ $tiers = TaxPriceTier::getByTaxId($idTax);
+ $childRanges = TaxChildRange::getByTaxId($idTax);
+ $validityRanges = TaxValidityRange::getByTaxId($idTax);
+ }
+ } elseif ($isAnySubmit) {
+ $postTierIds = (array) Tools::getValue('tier_id', array());
+ $postTierMins = (array) Tools::getValue('tier_min', array());
+ $postTierMaxs = (array) Tools::getValue('tier_max', array());
+ $postTierValues = (array) Tools::getValue('tier_value', array());
+ foreach ($postTierIds as $tierRowIndex => $tierId) {
+ $tiers[] = array(
+ 'id_tier' => (int) $tierId,
+ 'min_amount' => isset($postTierMins[$tierRowIndex]) ? $postTierMins[$tierRowIndex] : '',
+ 'max_amount' => isset($postTierMaxs[$tierRowIndex]) ? $postTierMaxs[$tierRowIndex] : '',
+ 'tax_value' => isset($postTierValues[$tierRowIndex]) ? $postTierValues[$tierRowIndex] : '',
+ );
+ }
+
+ $postChildIds = (array) Tools::getValue('child_id', array());
+ $postChildMins = (array) Tools::getValue('child_min', array());
+ $postChildMaxs = (array) Tools::getValue('child_max', array());
+ $postChildValues = (array) Tools::getValue('child_value', array());
+ foreach ($postChildIds as $childRangeRowIndex => $childId) {
+ $childRanges[] = array(
+ 'id_child_range' => (int) $childId,
+ 'min_age' => isset($postChildMins[$childRangeRowIndex]) ? $postChildMins[$childRangeRowIndex] : '',
+ 'max_age' => isset($postChildMaxs[$childRangeRowIndex]) ? $postChildMaxs[$childRangeRowIndex] : '',
+ 'tax_value' => isset($postChildValues[$childRangeRowIndex]) ? $postChildValues[$childRangeRowIndex] : '',
+ );
+ }
+
+ $postValidityIds = (array) Tools::getValue('validity_id', array());
+ $postValidityFroms = (array) Tools::getValue('validity_from', array());
+ $postValidityTos = (array) Tools::getValue('validity_to', array());
+ foreach ($postValidityIds as $validityRangeRowIndex => $rangeId) {
+ $validityRanges[] = array(
+ 'id_validity_range' => (int) $rangeId,
+ 'valid_from' => isset($postValidityFroms[$validityRangeRowIndex]) ? $postValidityFroms[$validityRangeRowIndex] : '',
+ 'valid_to' => isset($postValidityTos[$validityRangeRowIndex]) ? $postValidityTos[$validityRangeRowIndex] : '',
+ );
+ }
+ }
+
+ $taxType = (int) Tools::getValue('tax_calc_type', 0);
+ $baseTaxValue = Tools::getValue('tax_value', $subtype ? $subtype->tax_value : 0);
+ $allDayKeys = TaxConfiguration::DAY_KEYS;
+ if ($subtype && $subtype->special_days) {
+ $defaultSpecialDays = json_decode($subtype->special_days, true) ?: $allDayKeys;
+ } else {
+ $defaultSpecialDays = $allDayKeys;
+ }
+ $specialDaysChecked = Tools::getValue('valid_days', $defaultSpecialDays);
+ if (!is_array($specialDaysChecked)) {
+ $specialDaysChecked = $allDayKeys;
+ }
+
+ $initialTypeSign = ($taxType == TaxConfiguration::CALCULATION_TYPE_PERCENTAGE) ? '%' : $currencySign;
+ $taxValueInputHtml = '
+
+ ' . $initialTypeSign . '
+
+
';
+ $rateCurrentValue = (float) Tools::getValue('rate', (isset($this->object) && $this->object) ? $this->object->rate : 0);
+ $rateInputHtml = '
+
+ %
+
+
';
+ $priceBracketsHtml = '
+
+
+ ';
+
+ $infantMaxAge = (int) Configuration::get('QLO_GLOBAL_MAX_INFANT_AGE');
+ $childMaxAge = (int) Configuration::get('WK_GLOBAL_CHILD_MAX_AGE');
+
+ $ageBandsHtml = '
+
+
+ ';
+
+ $validityRangesHtml = '
+
+
+ ';
+
+ $weekdayLabels = array(
+ 'mon' => $this->l('Mon'), 'tue' => $this->l('Tue'), 'wed' => $this->l('Wed'),
+ 'thu' => $this->l('Thu'), 'fri' => $this->l('Fri'), 'sat' => $this->l('Sat'),
+ 'sun' => $this->l('Sun'),
+ );
+
+ $weekdayCheckboxesHtml = '
';
+ foreach ($weekdayLabels as $dayKey => $label) {
+ $checked = in_array($dayKey, $specialDaysChecked) ? ' checked="checked"' : '';
+ $weekdayCheckboxesHtml .= '
+
+ '
+ . $label . '
+ ';
+ }
+ $weekdayCheckboxesHtml .= '
';
+
$this->fields_form = array(
'legend' => array(
'title' => $this->l('Taxes'),
- 'icon' => 'icon-money'
+ 'icon' => 'icon-money',
),
'input' => array(
- array(
- 'type' => 'text',
- 'label' => $this->l('Name'),
- 'name' => 'name',
- 'required' => true,
- 'lang' => true,
- 'hint' => $this->l('Tax name to display in carts and on invoices (e.g. "VAT").').' - '.$this->l('Invalid characters').' <>;=#{}'
- ),
- array(
- 'type' => 'text',
- 'label' => $this->l('Rate'),
- 'name' => 'rate',
- 'maxlength' => 6,
- 'required' => true,
- 'hint' => $this->l('Format: XX.XX or XX.XXX (e.g. 19.60 or 13.925)').' - '.$this->l('Invalid characters').' <>;=#{}'
- ),
array(
'type' => 'switch',
'label' => $this->l('Enable'),
@@ -229,15 +617,195 @@ class AdminTaxesControllerCore extends AdminController
'label' => $this->l('Disabled')
)
)
- )
- ),
- 'submit' => array(
- 'title' => $this->l('Save')
+ ),
+ array(
+ 'type' => 'text',
+ 'label' => $this->l('Name'),
+ 'name' => 'name',
+ 'required' => true,
+ 'lang' => true,
+ 'col' => 4,
+ 'hint' => $this->l('Tax name to display in carts and on invoices (e.g. "City Tax").')
+ . ' - ' . $this->l('Invalid characters') . ' <>;=#{}',
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Rate'),
+ 'name' => 'rate_wrap',
+ 'html_content' => $rateInputHtml,
+ 'hint' => $this->l('Format: XX.XX or XX.XXX (e.g. 19.60 or 13.925)')
+ . ' - ' . $this->l('Invalid characters') . ' <>;=#{}',
+ 'form_group_class' => 'tourism-tax-non-tourism',
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Is tourism tax'),
+ 'name' => 'is_tourism_tax',
+ 'is_bool' => true,
+ 'hint' => $this->l('When enabled, this tax will be used for tourism tax purposes only.'),
+ 'values' => array(
+ array('id' => 'is_tourism_tax_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'is_tourism_tax_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'select',
+ 'label' => $this->l('Tax Calculation Type'),
+ 'name' => 'tax_calc_type',
+ 'form_group_class' => 'tourism-tax-field',
+ 'options' => array(
+ 'query' => array(
+ array('id' => 0, 'name' => $this->l('Fixed amount')),
+ array('id' => 1, 'name' => $this->l('Percentage')),
+ ),
+ 'id' => 'id',
+ 'name' => 'name',
+ ),
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Tax value'),
+ 'name' => 'tax_value_wrap',
+ 'required' => true,
+ 'html_content' => $taxValueInputHtml,
+ 'form_group_class' => 'tourism-tax-field',
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Per night'),
+ 'name' => 'is_per_night',
+ 'is_bool' => true,
+ 'hint' => $this->l('When enabled, the tax amount applies for each night of the booking instead of once per booking.'),
+ 'form_group_class' => 'tourism-tax-field',
+ 'default_value' => 1,
+ 'values' => array(
+ array('id' => 'per_night_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'per_night_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Per occupancy'),
+ 'name' => 'is_per_person',
+ 'is_bool' => true,
+ 'hint' => $this->l('When enabled, the tax amount multiplies by the number of adult occupants.'),
+ 'form_group_class' => 'tourism-tax-field',
+ 'values' => array(
+ array('id' => 'per_person_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'per_person_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Apply on child'),
+ 'name' => 'has_child_rate',
+ 'is_bool' => true,
+ 'hint' => sprintf(
+ $this->l('When enabled, children pay the adult rate by default (default child age ranges %d–%d according to current general settings.'),
+ $infantMaxAge,
+ $childMaxAge - 1
+ ),
+ 'form_group_class' => 'tourism-tax-field',
+ 'values' => array(
+ array('id' => 'child_rate_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'child_rate_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Set child age range'),
+ 'name' => 'has_child_age_range',
+ 'is_bool' => true,
+ 'desc' => $this->l('When enabled, define a set of child age bands below, each with its own tax value.'),
+ 'form_group_class' => 'tourism-tax-field tourism-tax-child-field',
+ 'values' => array(
+ array('id' => 'child_age_range_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'child_age_range_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'select',
+ 'label' => $this->l('Child Tax Calculation Type'),
+ 'name' => 'child_calc_type',
+ 'form_group_class' => 'tourism-tax-field tourism-tax-child-band-field',
+ 'hint' => $this->l('Fixed: each age range charges its own flat amount. Percentage: each age range charges a percentage of the adult\'s own calculated tax.'),
+ 'options' => array(
+ 'query' => array(
+ array('id' => 0, 'name' => $this->l('Fixed')),
+ array('id' => 1, 'name' => $this->l('Percentage')),
+ ),
+ 'id' => 'id',
+ 'name' => 'name',
+ ),
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Child age ranges'),
+ 'name' => 'child_ranges',
+ 'html_content' => $ageBandsHtml,
+ 'form_group_class' => 'tourism-tax-field tourism-tax-child-band-field',
+ 'desc' => sprintf(
+ $this->l('Leave blank for no limit; ages must be ≥ %d and < %d. Children outside all ranges are charged the adult’s calculated tax.'),
+ $infantMaxAge,
+ $childMaxAge
+ ),
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Tiered pricing'),
+ 'name' => 'is_tiered',
+ 'is_bool' => true,
+ 'desc' => $this->l('When enabled, the tax amount is determined by the matching price range below. Prices outside all ranges use the base Tax value above.'),
+ 'form_group_class' => 'tourism-tax-field',
+ 'values' => array(
+ array('id' => 'tiered_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'tiered_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Price ranges'),
+ 'name' => 'tiers',
+ 'html_content' => $priceBracketsHtml,
+ 'form_group_class' => 'tourism-tax-field tourism-tax-tiered-field',
+ 'desc' => $this->l('Leave blank for no limit; the maximum price is exclusive. Prices outside all ranges are charged the adult’s calculated tax.'),
+ ),
+ array(
+ 'type' => 'switch',
+ 'label' => $this->l('Set valid date ranges'),
+ 'name' => 'has_multiple_valid_ranges',
+ 'is_bool' => true,
+ 'desc' => $this->l('When enabled, define one or more validity date ranges below — the tax only applies within them. When disabled, there is no date restriction at all.'),
+ 'form_group_class' => 'tourism-tax-field',
+ 'values' => array(
+ array('id' => 'multi_valid_on', 'value' => 1, 'label' => $this->l('Yes')),
+ array('id' => 'multi_valid_off', 'value' => 0, 'label' => $this->l('No')),
+ ),
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Valid ranges'),
+ 'name' => 'validity_ranges',
+ 'html_content' => $validityRangesHtml,
+ 'form_group_class' => 'tourism-tax-field tourism-tax-multi-valid-field',
+ 'desc' => $this->l('Leave "From" blank for no start date and "To" for no end date. Ranges cannot overlap.'),
+ ),
+ array(
+ 'type' => 'html',
+ 'label' => $this->l('Valid days'),
+ 'name' => 'valid_days_wrap',
+ 'html_content' => $weekdayCheckboxesHtml
+ . '
'
+ . $this->l('Uncheck days when the tax should not apply within the date range.')
+ . '
',
+ 'form_group_class' => 'tourism-tax-field',
+ ),
),
+ 'submit' => array('title' => $this->l('Save')),
'buttons' => array(
'save-and-stay' => array(
'title' => $this->l('Save and stay'),
- 'name' => 'submitAdd'.$this->table.'AndStay',
+ 'name' => 'submitAdd' . $this->table . 'AndStay',
'type' => 'submit',
'class' => 'btn btn-default pull-right',
'icon' => 'process-icon-save',
@@ -248,25 +816,72 @@ class AdminTaxesControllerCore extends AdminController
return parent::renderForm();
}
+ /**
+ * List column callback: display a Yes/No badge for is_tourism_tax.
+ *
+ * @param mixed $value 1 or 0
+ * @param array $row
+ * @return string
+ */
+ public function displayTourismTaxBadge($value, $row)
+ {
+ if ($value) {
+ return '
' . $this->l('Yes') . ' ';
+ }
+ return '
' . $this->l('No') . ' ';
+ }
+
+ /**
+ * List column callback: display tax rate with the correct symbol.
+ * Tourism-fixed taxes show currency sign; tourism-percent and regular taxes show %.
+ *
+ * @param mixed $value ps_tax.rate
+ * @param array $row Full list row (includes tourism_tax_calc_type, tourism_tax_value from join)
+ * @return string
+ */
+ public function displayRate($value, $row)
+ {
+ return TaxConfiguration::getFormattedRateForDisplay($value, $row, $this->context->currency);
+ }
+
public function postProcess()
{
if ($this->action == 'save') {
+ if ((int) Tools::getValue('is_tourism_tax')) {
+ $_POST['rate'] = '0';
+ }
/* Checking fields validity */
$this->validateRules();
+ $this->validateTourismTaxFields();
if (!count($this->errors)) {
- $id = (int)(Tools::getValue('id_'.$this->table));
+ $existingTaxId = (int)(Tools::getValue('id_'.$this->table));
+ $isTourismTax = (int) Tools::getValue('is_tourism_tax');
/* Object update */
- if (isset($id) && !empty($id)) {
+ if (isset($existingTaxId) && !empty($existingTaxId)) {
/** @var Tax $object */
- $object = new $this->className($id);
+ $object = new $this->className($existingTaxId);
if (Validate::isLoadedObject($object)) {
+ $wasTourismTax = (int) $object->is_tourism_tax;
$this->copyFromPost($object, $this->table);
$result = $object->update(false, false);
if (!$result) {
$this->errors[] = Tools::displayError('An error occurred while updating an object.').'
'.$this->table.' ';
} elseif ($this->postImage($object->id)) {
+ if ($isTourismTax) {
+ self::saveTourismTaxSubtype($object->id);
+ }
+ if ($wasTourismTax && !$isTourismTax) {
+ $this->cleanTaxFromTourismTaxRulesGroups($object->id);
+ $tourismTax = TaxConfiguration::getByTaxId($object->id);
+ if ($tourismTax) {
+ $tourismTax->delete();
+ }
+ }
+ if (!$wasTourismTax && $isTourismTax) {
+ $this->cleanTaxFromVatTaxRulesGroups($object->id);
+ }
if (Tools::isSubmit('submitAdd'.$this->table.'AndStay')) {
Tools::redirectAdmin(self::$currentIndex.'&update'.$this->table.'&id_'.$this->table.'='.$object->id.'&conf=4'.'&token='.$this->token);
} else {
@@ -286,12 +901,14 @@ class AdminTaxesControllerCore extends AdminController
if (!$object->add()) {
$this->errors[] = Tools::displayError('An error occurred while creating an object.').'
'.$this->table.' ';
} elseif ($this->postImage($object->id)) {
+ if ($isTourismTax) {
+ self::saveTourismTaxSubtype($object->id);
+ }
if (Tools::isSubmit('submitAdd'.$this->table.'AndStay')) {
Tools::redirectAdmin(self::$currentIndex.'&update'.$this->table.'&id_'.$this->table.'='.$object->id.'&conf=3'.'&token='.$this->token);
} else {
Tools::redirectAdmin(self::$currentIndex.'&conf=3&token='.$this->token);
}
- Tools::redirectAdmin(self::$currentIndex.'&id_'.$this->table.'='.$object->id.'&conf=3'.'&token='.$this->token);
}
}
} else {
@@ -302,6 +919,317 @@ class AdminTaxesControllerCore extends AdminController
}
}
+ /**
+ * Validate tourism-tax-specific fields and append to $this->errors.
+ */
+ protected function validateTourismTaxFields()
+ {
+ if (!(int) Tools::getValue('is_tourism_tax')) {
+ return;
+ }
+
+ $taxValue = Tools::getValue('tax_value', '');
+ if ($taxValue === '') {
+ $this->errors[] = $this->l('Tax value is required.');
+ $this->invalidCoreFields['tax_value'] = true;
+ } else {
+ if (!is_numeric($taxValue)) {
+ $this->errors[] = $this->l('Tax value must be a valid number.');
+ $this->invalidCoreFields['tax_value'] = true;
+ } elseif ((float) $taxValue < 0) {
+ $this->errors[] = $this->l('Tax value cannot be negative.');
+ $this->invalidCoreFields['tax_value'] = true;
+ }
+ }
+
+ if ((int) Tools::getValue('has_multiple_valid_ranges')) {
+ $submittedRangeFroms = Tools::getValue('validity_from', array());
+ $submittedRangeTos = Tools::getValue('validity_to', array());
+ $filledRanges = array();
+ foreach ($submittedRangeFroms as $rowIndex => $fromRaw) {
+ $toRaw = isset($submittedRangeTos[$rowIndex]) ? $submittedRangeTos[$rowIndex] : '';
+ $fromRaw = trim($fromRaw);
+ $toRaw = trim($toRaw);
+ if ($fromRaw === '0000-00-00') { $fromRaw = ''; }
+ if ($toRaw === '0000-00-00') { $toRaw = ''; }
+ $rowNum = (int) $rowIndex + 1;
+ if ($fromRaw === '' && $toRaw === '') {
+ continue;
+ }
+ if ($fromRaw !== '' && !Validate::isDate($fromRaw)) {
+ $this->errors[] = sprintf($this->l('Valid range #%d: "From" is not a valid date.'), $rowNum);
+ $this->invalidValidityFields[$rowIndex]['from'] = true;
+ }
+ if ($toRaw !== '' && !Validate::isDate($toRaw)) {
+ $this->errors[] = sprintf($this->l('Valid range #%d: "To" is not a valid date.'), $rowNum);
+ $this->invalidValidityFields[$rowIndex]['to'] = true;
+ }
+ if ($fromRaw !== '' && $toRaw !== '' && Validate::isDate($fromRaw) && Validate::isDate($toRaw)
+ && strtotime($toRaw) < strtotime($fromRaw)
+ ) {
+ $this->errors[] = sprintf($this->l('Valid range #%d: "To" must be on or after "From".'), $rowNum);
+ $this->invalidValidityFields[$rowIndex]['from'] = true;
+ $this->invalidValidityFields[$rowIndex]['to'] = true;
+ }
+ $filledRanges[] = array(
+ 'from' => ($fromRaw !== '' && Validate::isDate($fromRaw)) ? strtotime($fromRaw) : null,
+ 'to' => ($toRaw !== '' && Validate::isDate($toRaw)) ? strtotime($toRaw) : null,
+ 'rowNum' => $rowNum,
+ 'rowIndex' => $rowIndex,
+ );
+ }
+ if (empty($filledRanges)) {
+ $this->errors[] = $this->l('Add at least one range or disable "Set valid date ranges".');
+ }
+ $rangeCount = count($filledRanges);
+ for ($outerIdx = 0; $outerIdx < $rangeCount; $outerIdx++) {
+ for ($innerIdx = $outerIdx + 1; $innerIdx < $rangeCount; $innerIdx++) {
+ $outerRange = $filledRanges[$outerIdx];
+ $innerRange = $filledRanges[$innerIdx];
+ $outerMinEffective = ($outerRange['from'] === null) ? -PHP_INT_MAX : $outerRange['from'];
+ $outerMaxEffective = ($outerRange['to'] === null) ? PHP_INT_MAX : $outerRange['to'];
+ $innerMinEffective = ($innerRange['from'] === null) ? -PHP_INT_MAX : $innerRange['from'];
+ $innerMaxEffective = ($innerRange['to'] === null) ? PHP_INT_MAX : $innerRange['to'];
+ if ($outerMinEffective <= $innerMaxEffective && $outerMaxEffective >= $innerMinEffective) {
+ $this->errors[] = sprintf(
+ $this->l('Valid ranges #%d and #%d overlap. Each range must cover a distinct period.'),
+ $outerRange['rowNum'],
+ $innerRange['rowNum']
+ );
+ $this->invalidValidityFields[$outerRange['rowIndex']]['from'] = true;
+ $this->invalidValidityFields[$outerRange['rowIndex']]['to'] = true;
+ $this->invalidValidityFields[$innerRange['rowIndex']]['from'] = true;
+ $this->invalidValidityFields[$innerRange['rowIndex']]['to'] = true;
+ break 2;
+ }
+ }
+ }
+ }
+
+ if ((int) Tools::getValue('has_child_rate') && (int) Tools::getValue('has_child_age_range')) {
+ $infantMaxAge = (int) Configuration::get('QLO_GLOBAL_MAX_INFANT_AGE');
+ $childMaxAge = (int) Configuration::get('WK_GLOBAL_CHILD_MAX_AGE');
+ $submittedBandMins = Tools::getValue('child_min', array());
+ $submittedBandMaxs = Tools::getValue('child_max', array());
+ $submittedBandValues = Tools::getValue('child_value', array());
+ if (empty($submittedBandValues)) {
+ $this->errors[] = $this->l('Add at least one range or disable "Set child age range".');
+ }
+ $filledBands = array();
+ foreach ($submittedBandValues as $rowIndex => $taxValue) {
+ $bandMinRaw = isset($submittedBandMins[$rowIndex]) ? $submittedBandMins[$rowIndex] : '';
+ $bandMaxRaw = isset($submittedBandMaxs[$rowIndex]) ? $submittedBandMaxs[$rowIndex] : '';
+ $rowNum = (int) $rowIndex + 1;
+ if ($taxValue === '' || $taxValue === false) {
+ $this->errors[] = sprintf($this->l('Child range #%d: tax value is required.'), $rowNum);
+ $this->invalidChildFields[$rowIndex]['value'] = true;
+ continue;
+ }
+ $bandMin = ($bandMinRaw !== '') ? (int) $bandMinRaw : 0;
+ $bandMax = ($bandMaxRaw !== '') ? (int) $bandMaxRaw : 0;
+ if (!is_numeric($taxValue)) {
+ $this->errors[] = sprintf($this->l('Child range #%d: tax value must be a valid number.'), $rowNum);
+ $this->invalidChildFields[$rowIndex]['value'] = true;
+ } elseif ((float) $taxValue < 0) {
+ $this->errors[] = sprintf($this->l('Child range #%d: tax value cannot be negative.'), $rowNum);
+ $this->invalidChildFields[$rowIndex]['value'] = true;
+ }
+ if ($bandMax > 0 && $bandMin > $bandMax) {
+ $this->errors[] = sprintf($this->l('Child range #%d: min age cannot be greater than max age.'), $rowNum);
+ $this->invalidChildFields[$rowIndex]['min'] = true;
+ $this->invalidChildFields[$rowIndex]['max'] = true;
+ }
+ if ($bandMin > 0 && $bandMin < $infantMaxAge) {
+ $this->errors[] = sprintf($this->l('Child range #%d: min age cannot be below the infant age (%d).'), $rowNum, $infantMaxAge);
+ $this->invalidChildFields[$rowIndex]['min'] = true;
+ }
+ if ($bandMax > 0 && $bandMax >= $childMaxAge) {
+ $this->errors[] = sprintf($this->l('Child range #%d: max age cannot be %d or above (the general child age setting).'), $rowNum, $childMaxAge);
+ $this->invalidChildFields[$rowIndex]['max'] = true;
+ }
+ $filledBands[] = array('minAge' => $bandMin, 'maxAge' => $bandMax, 'rowNum' => $rowNum, 'rowIndex' => $rowIndex);
+ }
+ $bandCount = count($filledBands);
+ for ($outerIdx = 0; $outerIdx < $bandCount; $outerIdx++) {
+ for ($innerIdx = $outerIdx + 1; $innerIdx < $bandCount; $innerIdx++) {
+ $outerBand = $filledBands[$outerIdx];
+ $innerBand = $filledBands[$innerIdx];
+ $outerMaxEffective = ($outerBand['maxAge'] == 0) ? PHP_INT_MAX : $outerBand['maxAge'];
+ $innerMaxEffective = ($innerBand['maxAge'] == 0) ? PHP_INT_MAX : $innerBand['maxAge'];
+ if ($outerBand['minAge'] <= $innerMaxEffective && $outerMaxEffective >= $innerBand['minAge']) {
+ $this->errors[] = sprintf(
+ $this->l('Child age ranges #%d and #%d overlap. Each range must cover a distinct age range.'),
+ $outerBand['rowNum'],
+ $innerBand['rowNum']
+ );
+ $this->invalidChildFields[$outerBand['rowIndex']]['min'] = true;
+ $this->invalidChildFields[$outerBand['rowIndex']]['max'] = true;
+ $this->invalidChildFields[$innerBand['rowIndex']]['min'] = true;
+ $this->invalidChildFields[$innerBand['rowIndex']]['max'] = true;
+ break 2;
+ }
+ }
+ }
+ }
+
+ if ((int) Tools::getValue('is_tiered')) {
+ $submittedTierMins = Tools::getValue('tier_min', array());
+ $submittedTierMaxs = Tools::getValue('tier_max', array());
+ $submittedTierValues = Tools::getValue('tier_value', array());
+ if (empty($submittedTierValues)) {
+ $this->errors[] = $this->l('Add at least one range or disable "Tiered pricing".');
+ }
+ $filledTiers = array();
+ foreach ($submittedTierValues as $rowIndex => $taxValue) {
+ $tierMinRaw = isset($submittedTierMins[$rowIndex]) ? $submittedTierMins[$rowIndex] : '';
+ $tierMaxRaw = isset($submittedTierMaxs[$rowIndex]) ? $submittedTierMaxs[$rowIndex] : '';
+ $rowNum = (int) $rowIndex + 1;
+ if ($taxValue === '' || $taxValue === false) {
+ $this->errors[] = sprintf($this->l('Tier #%d: tax value is required.'), $rowNum);
+ $this->invalidTierFields[$rowIndex]['value'] = true;
+ continue;
+ }
+ $tierMin = ($tierMinRaw !== '') ? (float) $tierMinRaw : 0.0;
+ $tierMax = ($tierMaxRaw !== '') ? (float) $tierMaxRaw : 0.0;
+ if (!is_numeric($taxValue)) {
+ $this->errors[] = sprintf($this->l('Tier #%d: tax value must be a valid number.'), $rowNum);
+ $this->invalidTierFields[$rowIndex]['value'] = true;
+ } elseif ((float) $taxValue < 0) {
+ $this->errors[] = sprintf($this->l('Tier #%d: tax value cannot be negative.'), $rowNum);
+ $this->invalidTierFields[$rowIndex]['value'] = true;
+ }
+ if ($tierMax > 0 && $tierMin > $tierMax) {
+ $this->errors[] = sprintf($this->l('Tier #%d: min price cannot be greater than max price.'), $rowNum);
+ $this->invalidTierFields[$rowIndex]['min'] = true;
+ $this->invalidTierFields[$rowIndex]['max'] = true;
+ }
+ $filledTiers[] = array('minPrice' => $tierMin, 'maxPrice' => $tierMax, 'rowNum' => $rowNum, 'rowIndex' => $rowIndex);
+ }
+ $tierCount = count($filledTiers);
+ for ($outerIdx = 0; $outerIdx < $tierCount; $outerIdx++) {
+ for ($innerIdx = $outerIdx + 1; $innerIdx < $tierCount; $innerIdx++) {
+ $outerTier = $filledTiers[$outerIdx];
+ $innerTier = $filledTiers[$innerIdx];
+ $outerMaxEffective = ($outerTier['maxPrice'] == 0) ? PHP_FLOAT_MAX : $outerTier['maxPrice'];
+ $innerMaxEffective = ($innerTier['maxPrice'] == 0) ? PHP_FLOAT_MAX : $innerTier['maxPrice'];
+ if ($outerTier['minPrice'] < $innerMaxEffective && $outerMaxEffective > $innerTier['minPrice']) {
+ $this->errors[] = sprintf(
+ $this->l('Price ranges #%d and #%d overlap. Each range must cover a distinct price range.'),
+ $outerTier['rowNum'],
+ $innerTier['rowNum']
+ );
+ $this->invalidTierFields[$outerTier['rowIndex']]['min'] = true;
+ $this->invalidTierFields[$outerTier['rowIndex']]['max'] = true;
+ $this->invalidTierFields[$innerTier['rowIndex']]['min'] = true;
+ $this->invalidTierFields[$innerTier['rowIndex']]['max'] = true;
+ break 2;
+ }
+ }
+ }
+ }
+ }
+
+ public function setMedia()
+ {
+ parent::setMedia();
+ if ($this->display == 'edit' || $this->display == 'add') {
+ $this->addCSS(_MODULE_DIR_ . 'hotelreservationsystem/views/css/HotelReservationAdmin.css');
+ $this->addJs(_MODULE_DIR_ . 'hotelreservationsystem/views/js/TourismTaxForm.js');
+ }
+ }
+
+ /**
+ * @param int $idTax
+ */
+ protected static function saveTourismTaxSubtype($idTax)
+ {
+ $idTax = (int) $idTax;
+ $tourismTax = TaxConfiguration::getByTaxId($idTax);
+ $tourismTaxExists = (bool) $tourismTax;
+ if (!$tourismTax) {
+ $tourismTax = new TaxConfiguration();
+ $tourismTax->id = $idTax;
+ }
+ $tourismTax->calculation_type = (int) Tools::getValue('tax_calc_type', 0);
+ $tourismTax->per_night = (int) Tools::getValue('is_per_night', 1);
+ $tourismTax->per_person = (int) Tools::getValue('is_per_person', 0);
+ $tourismTax->tax_value = (float) Tools::getValue('tax_value', 0);
+ $tourismTax->has_tiered_pricing = (int) Tools::getValue('is_tiered', 0);
+ $tourismTax->apply_on_child = (int) Tools::getValue('has_child_rate', 0);
+ $tourismTax->has_child_age_range = (int) Tools::getValue('has_child_age_range', 0);
+ $tourismTax->child_calculation_type = (int) Tools::getValue('child_calc_type', 0);
+ $tourismTax->has_multiple_valid_ranges = (int) Tools::getValue('has_multiple_valid_ranges', 0);
+
+ $submittedDays = Tools::getValue('valid_days', array());
+ if (!is_array($submittedDays)) {
+ $submittedDays = array();
+ }
+ $submittedDays = array_values(array_intersect(TaxConfiguration::DAY_KEYS, $submittedDays));
+ if (empty($submittedDays) || count($submittedDays) === 7) {
+ $tourismTax->special_days = null;
+ } else {
+ $tourismTax->special_days = json_encode($submittedDays);
+ }
+
+ if ($tourismTaxExists) {
+ $tourismTax->update();
+ } else {
+ $tourismTax->add();
+ }
+
+ TaxPriceTier::saveAll(
+ $idTax,
+ Tools::getValue('tier_min', array()),
+ Tools::getValue('tier_max', array()),
+ Tools::getValue('tier_value', array())
+ );
+ TaxChildRange::saveAll(
+ $idTax,
+ Tools::getValue('child_min', array()),
+ Tools::getValue('child_max', array()),
+ Tools::getValue('child_value', array())
+ );
+ TaxValidityRange::saveAll(
+ $idTax,
+ Tools::getValue('validity_from', array()),
+ Tools::getValue('validity_to', array())
+ );
+ }
+
+ /**
+ * Remove this tax from all tourism tax rules groups when it is demoted from tourism to regular.
+ * Flashes a warning listing how many rule rows were deleted.
+ *
+ * @param int $idTax
+ */
+ protected function cleanTaxFromTourismTaxRulesGroups($idTax)
+ {
+ $count = TaxConfiguration::cleanFromTourismTaxRulesGroups($idTax);
+ if ($count) {
+ $this->warnings[] = sprintf(
+ $this->l('This tax was removed from %d tourism tax rules group(s) because it is no longer marked as a tourism tax.'),
+ $count
+ );
+ }
+ }
+
+ /**
+ * Remove this tax from all standard (non-tourism) tax rules groups when it is promoted to a
+ * tourism tax. Flashes a warning listing how many rule rows were deleted.
+ *
+ * @param int $idTax
+ */
+ protected function cleanTaxFromVatTaxRulesGroups($idTax)
+ {
+ $count = TaxConfiguration::cleanFromVatTaxRulesGroups($idTax);
+ if ($count) {
+ $this->warnings[] = sprintf(
+ $this->l('This tax was removed from %d standard tax rules group(s) because it is now marked as a tourism tax.'),
+ $count
+ );
+ }
+ }
+
public function updateOptionPsUseEcotax($value)
{
$old_value = (int)Configuration::get('PS_USE_ECOTAX');
diff --git a/controllers/front/GuestTrackingController.php b/controllers/front/GuestTrackingController.php
index c7d0d7e27..32b5cdce4 100644
--- a/controllers/front/GuestTrackingController.php
+++ b/controllers/front/GuestTrackingController.php
@@ -249,6 +249,7 @@ class GuestTrackingControllerCore extends FrontController
if ($orderProducts = $order->getProducts()) {
$total_convenience_fee_te = 0;
$total_convenience_fee_ti = 0;
+ $total_tourism_tax = OrderTaxDetail::getOrderTourismTaxTotal($idOrder);
$objOrderReturn = new OrderReturn();
$refundReqBookings = $objOrderReturn->getOrderRefundRequestedBookings($order->id, 0, 1);
@@ -555,6 +556,7 @@ class GuestTrackingControllerCore extends FrontController
'use_tax' => Configuration::get('PS_TAX'),
'guestInformations' => (array)$customer,
'view_on_map' => Configuration::get('WK_GOOGLE_ACTIVE_MAP'),
+ 'total_tourism_tax' => isset($total_tourism_tax) ? $total_tourism_tax : 0,
));
}
diff --git a/controllers/front/OrderConfirmationController.php b/controllers/front/OrderConfirmationController.php
index 49d33db33..074b69f0a 100644
--- a/controllers/front/OrderConfirmationController.php
+++ b/controllers/front/OrderConfirmationController.php
@@ -133,6 +133,7 @@ class OrderConfirmationControllerCore extends FrontController
$orderTotalInfo['total_paid_real'] = 0;
$orderTotalInfo['total_wrapping'] = 0;
$orderTotalInfo['total_order_amount'] = 0;
+ $orderTotalInfo['total_tourism_tax'] = 0.0;
$orders_has_invoice = 1;
if ($cartOrders = Order::getAllOrdersByCartId($order->id_cart)) {
@@ -151,6 +152,7 @@ class OrderConfirmationControllerCore extends FrontController
$idOrder = $cartOrder['id_order'];
$objCartOrder = new Order($idOrder);
$orderProducts = $objCartOrder->getProducts();
+ $roomTourismTaxByBooking = OrderTaxDetail::getAppliedTourismTaxTotals($idOrder, OrderTaxDetail::SCOPE_ROOM);
if (!empty($orderProducts)) {
foreach ($orderProducts as $type_key => $type_value) {
@@ -186,6 +188,7 @@ class OrderConfirmationControllerCore extends FrontController
$order_details_obj = new OrderDetail($data_v['id_order_detail']);
$cart_htl_data[$type_key]['name'] = $order_details_obj->product_name;
$stage_name = '';
+ $roomTotalPriceTaxIncl = (float) $data_v['total_price_tax_incl'];
if (isset($cart_htl_data[$type_key]['date_diff'][$date_join])) {
$cart_htl_data[$type_key]['date_diff'][$date_join]['num_rm'] += 1;
@@ -197,8 +200,8 @@ class OrderConfirmationControllerCore extends FrontController
//// By webkul New way to calculate product prices with feature Prices
$cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'] = $data_v['total_price_tax_excl']/$num_days;
- $cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $data_v['total_price_tax_incl']/$num_days;
- $cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_incl'] += $data_v['total_price_tax_incl'];
+ $cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $roomTotalPriceTaxIncl/$num_days;
+ $cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_incl'] += $roomTotalPriceTaxIncl;
$cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_excl'] += $data_v['total_price_tax_excl'];
$cart_htl_data[$type_key]['date_diff'][$date_join]['is_backorder'] = $data_v['is_back_order'];
if ($data_v['is_back_order']) {
@@ -220,8 +223,8 @@ class OrderConfirmationControllerCore extends FrontController
// By webkul New way to calculate product prices with feature Prices
$cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'] = $data_v['total_price_tax_excl']/$num_days;
- $cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $data_v['total_price_tax_incl']/$num_days;
- $cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_incl'] = $data_v['total_price_tax_incl'];
+ $cart_htl_data[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $roomTotalPriceTaxIncl/$num_days;
+ $cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_incl'] = $roomTotalPriceTaxIncl;
$cart_htl_data[$type_key]['date_diff'][$date_join]['amount_tax_excl'] = $data_v['total_price_tax_excl'];
if ($data_v['is_back_order']) {
$any_back_order = 1;
@@ -392,13 +395,18 @@ class OrderConfirmationControllerCore extends FrontController
$orderTotalInfo['total_tax'] += $objCartOrder->total_paid_tax_incl - $objCartOrder->total_paid_tax_excl;
$orderTotalInfo['total_paid'] += $objCartOrder->total_paid;
$orderTotalInfo['total_paid_real'] += $objCartOrder->total_paid_real;
+
+ $orderTotalInfo['total_tourism_tax'] += OrderTaxDetail::getOrderTourismTaxTotal($idOrder);
}
$totalTaxIncl = $orderTotalInfo['total_rooms_ti'] + $orderTotalInfo['total_services_ti'] + $orderTotalInfo['total_convenience_fee_ti'] + $orderTotalInfo['total_auto_add_services_ti'] + $orderTotalInfo['total_standalone_products_ti'];
$totalTaxExcl = $orderTotalInfo['total_rooms_te'] + $orderTotalInfo['total_services_te'] + $orderTotalInfo['total_convenience_fee_te'] + $orderTotalInfo['total_auto_add_services_te'] + $orderTotalInfo['total_standalone_products_te'];
- $orderTotalInfo['total_tax_without_discount'] = $totalTaxIncl - $totalTaxExcl;
+ $orderTotalInfo['total_tax_without_discount'] = ($totalTaxIncl - $totalTaxExcl) - $orderTotalInfo['total_tourism_tax'];
+ if ($orderTotalInfo['total_tax_without_discount'] < 0) {
+ $orderTotalInfo['total_tax_without_discount'] = 0;
+ }
}
$this->context->smarty->assign('orderTotalInfo', $orderTotalInfo);
diff --git a/controllers/front/OrderDetailController.php b/controllers/front/OrderDetailController.php
index 022f9ded3..6bc12b74f 100644
--- a/controllers/front/OrderDetailController.php
+++ b/controllers/front/OrderDetailController.php
@@ -88,6 +88,7 @@ class OrderDetailControllerCore extends FrontController
$objBookingDetail = new HotelBookingDetail();
$objRoomType = new HotelRoomType();
$objServiceProductOrderDetail = new ServiceProductOrderDetail();
+ $roomTourismTaxByBooking = OrderTaxDetail::getAppliedTourismTaxTotals($order->id, OrderTaxDetail::SCOPE_ROOM);
$anyBackOrder = 0;
$processedProducts = array();
$cartHotelData = array();
@@ -145,6 +146,8 @@ class OrderDetailControllerCore extends FrontController
$bookingRefundDetail = reset($bookingRefundDetail);
}
+ $roomTotalPriceTaxIncl = (float) $data_v['total_price_tax_incl'];
+
if (isset($cartHotelData[$type_key]['date_diff'][$date_join])) {
$cartHotelData[$type_key]['date_diff'][$date_join]['num_rm'] += 1;
@@ -155,10 +158,10 @@ class OrderDetailControllerCore extends FrontController
$cartHotelData[$type_key]['date_diff'][$date_join]['children'] += $data_v['children'];
$cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'] = $data_v['total_price_tax_excl']/$num_days;
- $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $data_v['total_price_tax_incl']/$num_days;
+ $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $roomTotalPriceTaxIncl/$num_days;
$cartHotelData[$type_key]['date_diff'][$date_join]['avg_paid_unit_price_tax_excl'] += $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'];
$cartHotelData[$type_key]['date_diff'][$date_join]['avg_paid_unit_price_tax_incl'] += $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'];
- $cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_incl'] += $data_v['total_price_tax_incl'];
+ $cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_incl'] += $roomTotalPriceTaxIncl;
$cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_excl'] += $data_v['total_price_tax_excl'];
$cartHotelData[$type_key]['date_diff'][$date_join]['is_backorder'] = $data_v['is_back_order'];
if ($data_v['is_back_order']) {
@@ -182,10 +185,10 @@ class OrderDetailControllerCore extends FrontController
$cartHotelData[$type_key]['date_diff'][$date_join]['children'] = $data_v['children'];
$cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'] = $data_v['total_price_tax_excl']/$num_days;
- $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $data_v['total_price_tax_incl']/$num_days;
+ $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'] = $roomTotalPriceTaxIncl/$num_days;
$cartHotelData[$type_key]['date_diff'][$date_join]['avg_paid_unit_price_tax_excl'] = $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_excl'];
$cartHotelData[$type_key]['date_diff'][$date_join]['avg_paid_unit_price_tax_incl'] = $cartHotelData[$type_key]['date_diff'][$date_join]['paid_unit_price_tax_incl'];
- $cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_incl'] = $data_v['total_price_tax_incl'];
+ $cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_incl'] = $roomTotalPriceTaxIncl;
$cartHotelData[$type_key]['date_diff'][$date_join]['amount_tax_excl'] = $data_v['total_price_tax_excl'];
$cartHotelData[$type_key]['date_diff'][$date_join]['is_backorder'] = $data_v['is_back_order'];
if ($data_v['is_back_order']) {
@@ -382,6 +385,7 @@ class OrderDetailControllerCore extends FrontController
}
}
+ $totalTourismTax = OrderTaxDetail::getOrderTourismTaxTotal((int) $id_order);
$this->context->smarty->assign(
array(
@@ -389,6 +393,7 @@ class OrderDetailControllerCore extends FrontController
'THEME_DIR' => _THEME_DIR_,
'total_convenience_fee_ti' => $total_convenience_fee_ti,
'total_convenience_fee_te' => $total_convenience_fee_te,
+ 'total_tourism_tax' => $totalTourismTax,
'any_back_order' => $anyBackOrder,
'shw_bo_msg' => Configuration::get('WK_SHOW_MSG_ON_BO'),
'back_ord_msg' => Configuration::get('WK_BO_MESSAGE'),
diff --git a/controllers/front/OrderOpcController.php b/controllers/front/OrderOpcController.php
index 9fa3e72f9..a5aa6a352 100644
--- a/controllers/front/OrderOpcController.php
+++ b/controllers/front/OrderOpcController.php
@@ -949,6 +949,14 @@ class OrderOpcControllerCore extends ParentOrderController
$objRoomTypeServiceProduct = new RoomTypeServiceProduct();
$roomTypeServiceProducts = $objRoomTypeServiceProduct->getServiceProductsData($idProduct, 1, 0, true, 1);
$cartBookings = $objCartBookingData->getHotelCartRoomsInfoByRoomType($this->context->cart->id, $idProduct,$dateFrom, $dateTo);
+ $availableServicePricing = $objRoomTypeServiceProduct->getServiceProductsPricingForCartBookings(
+ $roomTypeServiceProducts,
+ $cartBookings,
+ $idProduct,
+ $dateFrom,
+ $dateTo
+ );
+
foreach($cartBookings as &$cartBookingData) {
$cartBookingData['selected_service'] = $objServiceProductCartDetail->getServiceProductsInCart(
$cartBookingData['id_cart'],
@@ -964,7 +972,22 @@ class OrderOpcControllerCore extends ParentOrderController
null,
1
);
+ foreach ($cartBookingData['selected_service'] as &$selectedService) {
+ if ($selectedService['tourism_tax'] > 0 && TaxConfiguration::isGrossedUp($selectedService['tourism_tax'])) {
+ $selectedService['total_price_tax_incl'] += $selectedService['tourism_tax'];
+ $quantity = $selectedService['quantity'] ?: 1;
+ $impliedNumDays = $selectedService['unit_price_tax_excl'] > 0 ? $selectedService['total_price_tax_excl'] / ($selectedService['unit_price_tax_excl'] * $quantity) : 1;
+ $unitDivisor = $impliedNumDays * $quantity;
+ $selectedService['unit_price_tax_incl'] += $unitDivisor > 0 ? ($selectedService['tourism_tax'] / $unitDivisor) : 0;
+ }
+ }
+ unset($selectedService);
+
+ $cartBookingData['available_service_pricing'] = isset($availableServicePricing[$cartBookingData['id']])
+ ? $availableServicePricing[$cartBookingData['id']]
+ : array();
}
+ unset($cartBookingData);
$this->context->smarty->assign(array(
'roomTypeServiceProducts' => $roomTypeServiceProducts,
'cartRooms' => $cartBookings
diff --git a/controllers/front/ParentOrderController.php b/controllers/front/ParentOrderController.php
index a519c3305..e49dfc4ae 100644
--- a/controllers/front/ParentOrderController.php
+++ b/controllers/front/ParentOrderController.php
@@ -345,6 +345,14 @@ class ParentOrderControllerCore extends FrontController
[Product::SELLING_PREFERENCE_STANDALONE]
);
+ foreach ($standaloneProducts as &$standaloneProduct) {
+ if (TaxConfiguration::isGrossedUp($standaloneProduct['tourism_tax_amount'])) {
+ $standaloneProduct['unit_price_tax_incl'] += $standaloneProduct['tourism_tax_amount'] / max(1, (int) $standaloneProduct['quantity']);
+ $standaloneProduct['total_price_tax_incl'] += $standaloneProduct['tourism_tax_amount'];
+ }
+ }
+ unset($standaloneProduct);
+
if (count($standaloneProducts)) {
$this->context->smarty->assign('standalone_products', $standaloneProducts);
}
diff --git a/controllers/front/ProductController.php b/controllers/front/ProductController.php
index 427191234..22c89bba8 100644
--- a/controllers/front/ProductController.php
+++ b/controllers/front/ProductController.php
@@ -467,7 +467,6 @@ class ProductControllerCore extends FrontController
);
}
}
-
$this->assignBookingFormVars($this->product->id, $date_from, $date_to, $occupancy_value);
$this->assignRoomServiceProductVars();
@@ -480,6 +479,36 @@ class ProductControllerCore extends FrontController
$feature_price = HotelRoomTypeFeaturePricing::getRoomTypeFeaturePricesPerDay($this->product->id, $date_from, $date_to, false, 0, 0, 0, 0, 1, 1, $occupancy_value);
}
$productPriceWithoutReduction = $this->product->getPriceWithoutReduct(!$useTax);
+ if ($useTax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP')) {
+ $totalExclTourismTax = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
+ $this->product->id,
+ $date_from,
+ $date_to,
+ $occupancy_value,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0
+ );
+ $totalInclTourismTax = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
+ $this->product->id,
+ $date_from,
+ $date_to,
+ $occupancy_value,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ true
+ );
+ $tourismTaxTotal = $totalInclTourismTax['total_price_tax_incl'] - $totalExclTourismTax['total_price_tax_incl'];
+ $numDaysInDuration = HotelHelper::getNumberOfDays($date_from, $date_to);
+ $productPriceWithoutReduction += $tourismTaxTotal / $numDaysInDuration;
+ }
$feature_price_diff = (float)($productPriceWithoutReduction - $feature_price);
$this->context->smarty->assign('feature_price', $feature_price);
$this->context->smarty->assign('feature_price_diff', $feature_price_diff);
@@ -711,6 +740,7 @@ class ProductControllerCore extends FrontController
// calculate room type price first
$useTax = HotelBookingDetail::useTax();
$totalPrice = 0;
+ $includeTourismTax = $useTax && Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$priceWithoutDiscount = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$idProduct,
$dateFrom,
@@ -721,13 +751,21 @@ class ProductControllerCore extends FrontController
0,
0,
1,
- 0
+ 0,
+ $includeTourismTax
);
$roomTypeDateRangePrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$idProduct,
$dateFrom,
$dateTo,
- $occupancy
+ $occupancy,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ $includeTourismTax
);
$featurePrice = 0;
@@ -745,9 +783,8 @@ class ProductControllerCore extends FrontController
1,
$occupancy
);
- $roomTypeDateRangePrice = $roomTypeDateRangePrice['total_price_tax_incl'];
$totalPriceWithoutDiscount = $priceWithoutDiscount['total_price_tax_incl'];
-
+ $roomTypeDateRangePrice = $roomTypeDateRangePrice['total_price_tax_incl'];
} else {
$featurePrice = HotelRoomTypeFeaturePricing::getRoomTypeFeaturePricesPerDay(
$idProduct,
@@ -775,6 +812,10 @@ class ProductControllerCore extends FrontController
if ($roomServiceProducts = json_decode($roomServiceProducts, true)) {
$objRoomTypeServiceProductPrice = new RoomTypeServiceProductPrice();
$objRoomTypeServiceProduct = new RoomTypeServiceProduct();
+ $serviceOccupancy = (is_array($occupancy) && isset($occupancy[0])) ? $occupancy[0] : array('adults' => 1, 'child_ages' => array());
+ $serviceNumAdults = isset($serviceOccupancy['adults']) ? (int) $serviceOccupancy['adults'] : 1;
+ $serviceChildAges = !empty($serviceOccupancy['child_ages']) ? (array) $serviceOccupancy['child_ages'] : array();
+
foreach ($roomServiceProducts as &$product) {
if (!$objRoomTypeServiceProduct->isRoomTypeLinkedWithProduct($idProduct, $product['id_product'])) {
unset($product);
@@ -799,8 +840,17 @@ class ProductControllerCore extends FrontController
$useTax,
$product['quantity'],
$dateFrom,
- $dateTo
+ $dateTo,
+ false,
+ null,
+ 1,
+ null,
+ 0,
+ $useTax && Configuration::get('QLO_TOURISM_TAX_GROSSED_UP'),
+ $serviceNumAdults,
+ $serviceChildAges
);
+
$product['price'] = $productPrice;
$serviceProductsPrice += $productPrice;
}
@@ -865,43 +915,75 @@ class ProductControllerCore extends FrontController
}
}
$smartyVars['associated_hotels'] = $associatedHotels;
+ if (!$idHotel) {
+ // no hotel picked yet (e.g. initial page load) — price/tourism tax must match
+ // the hotel the dropdown defaults to, which is simply its first option
+ $idHotel = reset($associatedHotels)['id_hotel'];
+ }
}
}
if ($idHotel) {
$smartyVars['service_id_hotel'] = $idHotel;
}
$useTax = HotelBookingDetail::useTax();
+ $includeTourismTax = $useTax && Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$objServiceProductOption = new ServiceProductOption();
if ($serviceProductOptions = $objServiceProductOption->getProductOptions($this->product->id)) {
foreach ($serviceProductOptions as &$serviceProductOption) {
if ($idProductOption == null) {
$idProductOption = $serviceProductOption['id_product_option'];
}
- $serviceProductOption['price'] = RoomTypeServiceProductPrice::getPrice(
+ $serviceProductOption['price'] = Product::getServiceProductPrice(
$this->product->id,
- $idHotel,
$serviceProductOption['id_product_option'],
+ $idHotel,
+ false,
$useTax,
- 1
+ 1,
+ null,
+ null,
+ false,
+ null,
+ 1,
+ null,
+ 0,
+ $includeTourismTax
);
}
}
$smartyVars['product_option'] = $serviceProductOptions;
- $smartyVars['service_price'] = RoomTypeServiceProductPrice::getPrice(
+ $smartyVars['service_price'] = Product::getServiceProductPrice(
$this->product->id,
- $idHotel,
$idProductOption,
- $useTax,
- $quantity
- );
- $smartyVars['service_price_without_reduction'] = RoomTypeServiceProductPrice::getPrice(
- $this->product->id,
$idHotel,
- $idProductOption,
+ false,
$useTax,
$quantity,
- false
+ null,
+ null,
+ false,
+ null,
+ 1,
+ null,
+ 0,
+ $includeTourismTax
+ );
+ $smartyVars['service_price_without_reduction'] = Product::getServiceProductPrice(
+ $this->product->id,
+ $idProductOption,
+ $idHotel,
+ false,
+ $useTax,
+ $quantity,
+ null,
+ null,
+ false,
+ null,
+ false,
+ null,
+ 0,
+ $includeTourismTax
);
if ($quantity) {
$smartyVars['quantity'] = $quantity;
diff --git a/install/data/db_structure.sql b/install/data/db_structure.sql
index 26a298d0e..69e4c3bef 100644
--- a/install/data/db_structure.sql
+++ b/install/data/db_structure.sql
@@ -1206,13 +1206,79 @@ CREATE TABLE `PREFIX_orders` (
INDEX `date_add`(`date_add`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
-CREATE TABLE `PREFIX_order_detail_tax` (
- `id_order_detail` int(11) NOT NULL,
- `id_tax` int(11) NOT NULL,
- `unit_amount` DECIMAL(16, 6) NOT NULL DEFAULT '0.00',
- `total_amount` DECIMAL(16, 6) NOT NULL DEFAULT '0.00',
- KEY (`id_order_detail`),
- KEY `id_tax` (`id_tax`)
+CREATE TABLE `PREFIX_tax_configuration` (
+ `id_tax` int(11) unsigned NOT NULL,
+ `tax_value` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ `calculation_type` tinyint(1) NOT NULL DEFAULT '0',
+ `per_night` tinyint(1) NOT NULL DEFAULT '1',
+ `per_person` tinyint(1) NOT NULL DEFAULT '0',
+ `has_tiered_pricing` tinyint(1) NOT NULL DEFAULT '0',
+ `apply_on_child` tinyint(1) NOT NULL DEFAULT '0',
+ `has_child_age_range` tinyint(1) NOT NULL DEFAULT '0',
+ `child_calculation_type` tinyint(1) NOT NULL DEFAULT '0',
+ `has_multiple_valid_ranges` tinyint(1) NOT NULL DEFAULT '0',
+ `special_days` text,
+ PRIMARY KEY (`id_tax`)
+) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
+
+CREATE TABLE `PREFIX_tax_price_tier` (
+ `id_tax_price_tier` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `id_tax` int(11) unsigned NOT NULL,
+ `min_amount` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ `max_amount` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ `tax_value` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ PRIMARY KEY (`id_tax_price_tier`),
+ KEY `id_tax` (`id_tax`)
+) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
+
+CREATE TABLE `PREFIX_tax_child_range` (
+ `id_tax_child_range` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `id_tax` int(11) unsigned NOT NULL,
+ `min_age` tinyint(3) unsigned NOT NULL DEFAULT '0',
+ `max_age` tinyint(3) unsigned NOT NULL DEFAULT '17',
+ `tax_value` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ PRIMARY KEY (`id_tax_child_range`),
+ KEY `id_tax` (`id_tax`)
+) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
+
+CREATE TABLE `PREFIX_tax_validity_range` (
+ `id_tax_validity_range` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `id_tax` int(11) unsigned NOT NULL,
+ `valid_from` date DEFAULT NULL,
+ `valid_to` date DEFAULT NULL,
+ PRIMARY KEY (`id_tax_validity_range`),
+ KEY `id_tax` (`id_tax`)
+) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
+
+CREATE TABLE `PREFIX_order_tax_detail` (
+ `id_order_tax_detail` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `id_order` int(11) unsigned NOT NULL,
+ `id_order_detail` int(11) unsigned NOT NULL,
+ `id_htl_booking` int(11) unsigned NOT NULL,
+ `id_service_product_order_detail` int(11) unsigned NOT NULL DEFAULT '0',
+ `id_tax` int(11) unsigned NOT NULL,
+ `unit_amount` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ `total_amount` decimal(20,6) NOT NULL DEFAULT '0.000000',
+ `date_add` datetime NOT NULL,
+ PRIMARY KEY (`id_order_tax_detail`),
+ KEY `id_order` (`id_order`),
+ KEY `id_order_detail` (`id_order_detail`),
+ KEY `id_htl_booking` (`id_htl_booking`),
+ KEY `id_service_product_order_detail` (`id_service_product_order_detail`)
+) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
+
+CREATE TABLE `PREFIX_order_tax_exemption` (
+ `id_order_tax_exemption` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `id_htl_booking` int(11) unsigned NOT NULL DEFAULT '0',
+ `id_service_product_order_detail` int(11) unsigned NOT NULL DEFAULT '0',
+ `id_order` int(11) unsigned NOT NULL,
+ `id_employee` int(11) unsigned NOT NULL,
+ `note` text,
+ `date_add` datetime NOT NULL,
+ PRIMARY KEY (`id_order_tax_exemption`),
+ KEY `id_htl_booking` (`id_htl_booking`),
+ KEY `id_service_product_order_detail` (`id_service_product_order_detail`),
+ KEY `id_order` (`id_order`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION;
CREATE TABLE `PREFIX_order_invoice` (
@@ -1282,6 +1348,7 @@ CREATE TABLE `PREFIX_order_detail` (
`product_supplier_reference` varchar(32) DEFAULT NULL,
`product_weight` DECIMAL(20,6) NOT NULL,
`id_tax_rules_group` INT(11) UNSIGNED DEFAULT '0',
+ `id_tourism_tax_rule_group` INT(11) UNSIGNED DEFAULT '0',
`tax_computation_method` tinyint(1) unsigned NOT NULL DEFAULT '0',
`tax_name` varchar(16) NOT NULL,
`tax_rate` DECIMAL(10,3) NOT NULL DEFAULT '0.000',
@@ -1538,6 +1605,7 @@ CREATE TABLE `PREFIX_product` (
`id_category_default` int(10) unsigned DEFAULT NULL,
`id_shop_default` int(10) unsigned NOT NULL DEFAULT 1,
`id_tax_rules_group` INT(11) UNSIGNED NOT NULL,
+ `id_tourism_tax_rules_group` INT(11) UNSIGNED NOT NULL DEFAULT '0',
`on_sale` tinyint(1) unsigned NOT NULL DEFAULT '0',
`online_only` tinyint(1) unsigned NOT NULL DEFAULT '0',
`ean13` varchar(13) DEFAULT NULL,
@@ -1601,6 +1669,7 @@ CREATE TABLE IF NOT EXISTS `PREFIX_product_shop` (
`id_shop` int(10) unsigned NOT NULL,
`id_category_default` int(10) unsigned DEFAULT NULL,
`id_tax_rules_group` INT(11) UNSIGNED NOT NULL,
+ `id_tourism_tax_rules_group` INT(11) UNSIGNED NOT NULL DEFAULT '0',
`on_sale` tinyint(1) unsigned NOT NULL DEFAULT '0',
`online_only` tinyint(1) unsigned NOT NULL DEFAULT '0',
`ecotax` decimal(17,6) NOT NULL DEFAULT '0.000000',
@@ -2041,6 +2110,7 @@ CREATE TABLE `PREFIX_tag_count` (
CREATE TABLE `PREFIX_tax` (
`id_tax` int(10) unsigned NOT NULL auto_increment,
`rate` DECIMAL(10, 3) NOT NULL,
+ `is_tourism_tax` TINYINT(1) NOT NULL DEFAULT '0',
`active` tinyint(1) unsigned NOT NULL DEFAULT '1',
`deleted` tinyint(1) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id_tax`)
@@ -2166,6 +2236,7 @@ CREATE TABLE `PREFIX_tax_rules_group` (
`id_tax_rules_group` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR( 50 ) NOT NULL,
`active` INT NOT NULL,
+`is_tourism_tax_rule_group` TINYINT(1) NOT NULL DEFAULT '0',
`deleted` TINYINT(1) UNSIGNED NOT NULL,
`date_add` DATETIME NOT NULL,
`date_upd` DATETIME NOT NULL
diff --git a/install/models/install.php b/install/models/install.php
index 550a78be2..c665e028f 100644
--- a/install/models/install.php
+++ b/install/models/install.php
@@ -631,15 +631,15 @@ class InstallModelInstall extends InstallAbstractModel
public function setNotification($notificationData)
{
if (function_exists('curl_version')) {
+ $url = _QLO_API_URL_.'/getNotification.php';
$params = [
- 'url' => 'https://prestashop.webkul.com/hotel-reservation-clients/getNotification.php',
'method' => 'POST',
'headers' => array('Content-Type: application/json'),
'postdata' => json_encode($notificationData),
];
$curlInit = curl_init();
- curl_setopt($curlInit, CURLOPT_URL, $params['url']);
+ curl_setopt($curlInit, CURLOPT_URL, $url);
curl_setopt($curlInit, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
curl_setopt($curlInit, CURLOPT_HTTPHEADER, $params['headers']);
curl_setopt($curlInit, CURLOPT_SSL_VERIFYPEER, false);
diff --git a/js/admin/price.js b/js/admin/price.js
index 977499823..872028722 100644
--- a/js/admin/price.js
+++ b/js/admin/price.js
@@ -100,6 +100,48 @@ function getEcotaxTaxIncluded()
return ps_round(ecotax_tax_excl * (1 + ecotaxTaxRate), 2);
}
+
+function getTourismTaxIncluded(priceTE)
+{
+ var CALCULATION_TYPE_PERCENTAGE = 1;
+
+ if (typeof tourismTaxRatesByGroup === 'undefined' || isNaN(priceTE)) {
+ return 0;
+ }
+
+ var selectedTourismTax = document.getElementById('id_tourism_tax_rules_group');
+ if (!selectedTourismTax) {
+ return 0;
+ }
+
+ var taxes = tourismTaxRatesByGroup[selectedTourismTax.options[selectedTourismTax.selectedIndex].value] || [];
+ var tourismAmount = 0;
+
+ for (var i = 0; i < taxes.length; i++) {
+ var t = taxes[i];
+ var baseValue = null;
+
+ if (t.is_tiered) {
+ for (var j = 0; j < t.tiers.length; j++) {
+ var tier = t.tiers[j];
+ if (priceTE >= tier.min_amount && (tier.max_amount == 0 || priceTE <= tier.max_amount)) {
+ baseValue = tier.tax_value;
+ break;
+ }
+ }
+ if (baseValue === null) {
+ continue;
+ }
+ } else {
+ baseValue = t.tax_value;
+ }
+
+ tourismAmount += (t.tax_calc_type == CALCULATION_TYPE_PERCENTAGE) ? (priceTE * baseValue / 100) : baseValue;
+ }
+
+ return tourismAmount;
+}
+
function getEcotaxTaxExcluded()
{
return ecotax_tax_excl;
@@ -138,7 +180,7 @@ function calcPriceTI()
}
else
{
- $('#priceTI').val((parseFloat($('#priceTI').val()) + getEcotaxTaxIncluded()).toFixed(priceDisplayPrecision));
+ $('#priceTI').val((parseFloat($('#priceTI').val()) + getEcotaxTaxIncluded() + getTourismTaxIncluded(priceTE)).toFixed(priceDisplayPrecision));
$('#finalPrice').html(parseFloat($('#priceTI').val()).toFixed(priceDisplayPrecision));
}
}
@@ -148,6 +190,10 @@ function calcPriceTE()
ecotax_tax_excl = $('#ecotax').val() / (1 + ecotaxTaxRate);
var priceTI = parseFloat($('#priceTI').val().replace(/,/g, '.'));
var newPrice = removeTaxes(ps_round(priceTI - getEcotaxTaxIncluded(), priceDisplayPrecision));
+ var tourismTaxIncluded = getTourismTaxIncluded(newPrice);
+ if (tourismTaxIncluded > 0) {
+ newPrice = removeTaxes(ps_round(priceTI - getEcotaxTaxIncluded() - tourismTaxIncluded, priceDisplayPrecision));
+ }
$('#priceTE').val((isNaN(newPrice) == true || newPrice < 0) ? '' : ps_round(newPrice, 6).toFixed(6));
$('#priceTEReal').val((isNaN(newPrice) == true || newPrice < 0) ? 0 : ps_round(newPrice, 9));
diff --git a/mails/en/order_conf_total_info.tpl b/mails/en/order_conf_total_info.tpl
index 7c094a1aa..996bc3300 100644
--- a/mails/en/order_conf_total_info.tpl
+++ b/mails/en/order_conf_total_info.tpl
@@ -41,12 +41,22 @@
{/if}
- {l s="Total Tax"}
+ {l s="Room and Service Tax"}
{$list['total_order_tax']}
+ {if isset($list['has_tourism_tax']) && $list['has_tourism_tax']}
+
+
+ {l s="Total Tourism Tax"}
+
+
+ {$list['total_tourism_tax']}
+
+
+ {/if}
{l s="Discounts"}
diff --git a/mails/en/order_conf_total_info_text.tpl b/mails/en/order_conf_total_info_text.tpl
index ca048b8d0..17ecd4848 100644
--- a/mails/en/order_conf_total_info_text.tpl
+++ b/mails/en/order_conf_total_info_text.tpl
@@ -14,7 +14,11 @@
{l s="Total Convenience Fees"} {$list['total_convenience_fee_te']}
{/if}
-{l s="Total Tax"} {$list['total_order_tax']}
+{l s="Room and Service Tax"} {$list['total_order_tax']}
+
+{if isset($list['has_tourism_tax']) && $list['has_tourism_tax']}
+{l s="Total Tourism Tax"} {$list['total_tourism_tax']}
+{/if}
{l s="Discounts"} {$list['total_discounts']}
diff --git a/modules/blockcart/blockcart.php b/modules/blockcart/blockcart.php
index 64c922a68..7566b6d1b 100644
--- a/modules/blockcart/blockcart.php
+++ b/modules/blockcart/blockcart.php
@@ -138,9 +138,12 @@ class Blockcart extends Module
foreach ($serviceProducts as $key => $serviceProduct) {
if ($serviceProduct['id_product'] == $product['id_product']) {
$product['options'][] = $serviceProduct;
- $product['total_price_tax_incl'] += $serviceProduct['total_price_tax_incl'];
+ $tourismTaxGrossUp = ($useTax && TaxConfiguration::isGrossedUp($serviceProduct['tourism_tax_amount']))
+ ? $serviceProduct['tourism_tax_amount']
+ : 0.0;
+ $product['total_price_tax_incl'] += $serviceProduct['total_price_tax_incl'] + $tourismTaxGrossUp;
$product['total_price_tax_excl'] += $serviceProduct['total_price_tax_excl'];
- $product['amount'] += $useTax ? $serviceProduct['total_price_tax_incl'] : $serviceProduct['total_price_tax_excl'];
+ $product['amount'] += $useTax ? ($serviceProduct['total_price_tax_incl'] + $tourismTaxGrossUp) : $serviceProduct['total_price_tax_excl'];
}
}
}
@@ -207,11 +210,16 @@ class Blockcart extends Module
$shipping_cost_float = Tools::convertPrice($base_shipping, $currency);
$wrappingCost = (float) ($params['cart']->getOrderTotal($useTax, Cart::ONLY_WRAPPING));
$totalToPay = $params['cart']->getOrderTotal($useTax);
+ $tourismTaxTotals = $params['cart']->getOrderTotal(true, Cart::ONLY_TOURISM_TAX);
+ $tourismTax = (float) $tourismTaxTotals['tourism_tax'];
+ $tourismTaxRoom = (float) $tourismTaxTotals['tourism_tax_room'];
$tax_cost = 0;
+ $tourismTaxCost = Tools::displayPrice(0, $currency);
if ($useTax && $useTax) {
$totalToPayWithoutTaxes = $params['cart']->getOrderTotal(false);
- $tax_cost = Tools::displayPrice($totalToPay - $totalToPayWithoutTaxes, $currency);
+ $tax_cost = Tools::displayPrice(($totalToPay - $totalToPayWithoutTaxes) - $tourismTax, $currency);
+ $tourismTaxCost = Tools::displayPrice($tourismTax, $currency);
}
// The cart content is altered for display
$orderProcess = Configuration::get('PS_ORDER_PROCESS_TYPE') ? 'order-opc' : 'order';
@@ -297,28 +305,46 @@ class Blockcart extends Module
);
$addedProduct['booking_product'] = $objProduct->booking_product;
if ($objProduct->booking_product) {
+ $includeTourismTax = ($priceDisplayMethod != PS_TAX_EXC) && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$price = $addedProduct['price'] = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$objProduct->id,
$addedProduct['date_from'],
$addedProduct['date_to'],
- $addedProduct['occupancy']
+ $addedProduct['occupancy'],
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ $includeTourismTax
);
if ($priceDisplayMethod == PS_TAX_EXC) {
$addedProduct['price'] = Tools::displayPrice($price['total_price_tax_excl']);
} else {
- $addedProduct['price'] = Tools::displayPrice($price['total_price_tax_incl']);
+ $addedProduct['price'] = Tools::displayPrice((float) $price['total_price_tax_incl']);
}
$fullDate = ($this->context->controller->show_full_date && (date('Y-m-d', strtotime($addedProduct['date_from'])) == date('Y-m-d', strtotime($addedProduct['date_to']))) ? true : false);
$addedProduct['date_from'] = Tools::displayDate($addedProduct['date_from'], null, $fullDate);
$addedProduct['date_to'] = Tools::displayDate($addedProduct['date_to'], null, $fullDate);
} else {
+ $includeTourismTax = $useTax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
if ($objProduct->selling_preference_type == Product::SELLING_PREFERENCE_STANDALONE) {
- $addedProduct['unit_price'] = RoomTypeServiceProductPrice::getPrice(
+ $addedProduct['unit_price'] = Product::getServiceProductPrice(
$objProduct->id,
- 0,
isset($addedProduct['id_product_option']) ? $addedProduct['id_product_option'] : null,
+ 0,
+ false,
$useTax,
- $addedProduct['qty']
+ 1,
+ null,
+ null,
+ false,
+ null,
+ 1,
+ null,
+ 0,
+ $includeTourismTax
);
if (isset($addedProduct['id_product_option'])
@@ -330,12 +356,21 @@ class Blockcart extends Module
} elseif ($objProduct->selling_preference_type == Product::SELLING_PREFERENCE_HOTEL_STANDALONE
|| $objProduct->selling_preference_type == Product::SELLING_PREFERENCE_HOTEL_STANDALONE_AND_WITH_ROOM_TYPE
) {
- $addedProduct['unit_price'] = RoomTypeServiceProductPrice::getPrice(
+ $addedProduct['unit_price'] = Product::getServiceProductPrice(
$objProduct->id,
- $addedProduct['id_hotel'],
isset($addedProduct['id_product_option']) ? $addedProduct['id_product_option'] : null,
+ $addedProduct['id_hotel'],
+ false,
$useTax,
- $addedProduct['qty']
+ 1,
+ null,
+ null,
+ false,
+ null,
+ 1,
+ null,
+ 0,
+ $includeTourismTax
);
$objHotel = new HotelBranchInformation($addedProduct['id_hotel'], $this->context->language->id);
$addedProduct['hotel_name'] = $objHotel->hotel_name;
@@ -354,10 +389,21 @@ class Blockcart extends Module
}
$totalAdditionalServicesWithoutAutoAddPrice = $params['cart']->getOrderTotal($useTax, Cart::ONLY_ROOM_SERVICES_WITHOUT_AUTO_ADD);
- $totalAdditionalServicesWithAutoAddPrice = $params['cart']->getOrderTotal($useTax, Cart::ONLY_ROOM_SERVICES);
+ $totalAdditionalServicesWithAutoAddPrice = $params['cart']->getOrderTotal($useTax, Cart::ONLY_ROOM_SERVICES_WITH_AUTO_ADD_WITHOUT_CONVENIENCE_FEE);
$totalConvenienceFee = $params['cart']->getOrderTotal($useTax, Cart::ONLY_CONVENIENCE_FEE);
$totalRoomsPrice = $params['cart']->getOrderTotal($useTax, Cart::ONLY_ROOMS);
+ $tourismTaxConvenienceFee = (float) $tourismTaxTotals['tourism_tax_convenience_fee'];
+ if ($useTax && TaxConfiguration::isGrossedUp($tourismTaxRoom)) {
+ $totalRoomsPrice += $tourismTaxRoom;
+ }
+ if ($useTax && TaxConfiguration::isGrossedUp($tourismTaxConvenienceFee)) {
+ $totalConvenienceFee += $tourismTaxConvenienceFee;
+ }
$totalNormalProductPrice = $params['cart']->getOrderTotal($useTax, Cart::ONLY_STANDALONE_PRODUCTS);
+ $tourismTaxStandaloneProducts = (float) $tourismTaxTotals['tourism_tax_standalone_products'];
+ if ($useTax && TaxConfiguration::isGrossedUp($tourismTaxStandaloneProducts)) {
+ $totalNormalProductPrice += $tourismTaxStandaloneProducts;
+ }
$response = array(
'products' => $products,
@@ -373,6 +419,8 @@ class Blockcart extends Module
'show_tax' => $showTax,
'use_tax' => $useTax,
'tax_cost' => $tax_cost,
+ 'tourism_tax_cost' => $tourismTaxCost,
+ 'tourism_tax' => $tourismTax,
'wrapping_cost' => Tools::displayPrice($wrappingCost, $currency),
'product_total' => Tools::displayPrice($params['cart']->getOrderTotal($useTax, Cart::ONLY_PRODUCTS), $currency),
'room_total' => ($totalRoomsPrice + $totalAdditionalServicesWithAutoAddPrice),
diff --git a/modules/blockcart/blockcart.tpl b/modules/blockcart/blockcart.tpl
index af73ec530..780140e15 100644
--- a/modules/blockcart/blockcart.tpl
+++ b/modules/blockcart/blockcart.tpl
@@ -138,7 +138,15 @@
{if $show_tax && $use_tax}
{$tax_cost}
- {l s='Tax' mod='blockcart'}
+ {l s='Room and Service Tax' mod='blockcart'}
+
+ {/if}
+ {/block}
+ {block name='blockcart_shopping_cart_total_tourism_tax'}
+ {if $show_tax && $use_tax}
+
+ {$tourism_tax_cost}
+ {l s='Total Tourism Tax' mod='blockcart'}
{/if}
{/block}
@@ -266,7 +274,7 @@
{if $cart_qties > 0}
- {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)}
+ {convertPrice price=$cart->getOrderTotal(($priceDisplay != 1), Cart::ONLY_ROOMS)}
{/if}
@@ -285,7 +293,7 @@
{if $cart_qties > 0}
- {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)}
+ {convertPrice price=$normal_products_total}
{/if}
@@ -344,11 +352,19 @@
{block name='blockcart_layer_cart_total_tax'}
{if $show_tax && $use_tax}
- {l s='Tax' mod='blockcart'}
+ {l s='Room and Service Tax' mod='blockcart'}
{$tax_cost}
{/if}
{/block}
+ {block name='blockcart_layer_cart_total_tourism_tax'}
+ {if $show_tax && $use_tax}
+
+ {l s='Total Tourism Tax' mod='blockcart'}
+ {$tourism_tax_cost}
+
+ {/if}
+ {/block}
{block name='blockcart_layer_cart_total_price'}
diff --git a/modules/hotelreservationsystem/classes/HotelBookingDetail.php b/modules/hotelreservationsystem/classes/HotelBookingDetail.php
index 52c452d08..cc4405b9a 100644
--- a/modules/hotelreservationsystem/classes/HotelBookingDetail.php
+++ b/modules/hotelreservationsystem/classes/HotelBookingDetail.php
@@ -2195,6 +2195,31 @@ class HotelBookingDetail extends ObjectModel
$reallocatedBookingId = $objBookingDetail->id;
$objectHotelBookingTo = $objBookingDetail;
+ OrderTaxDetail::updateVatScoping((int) $idNewOrderDetail, (int) $objBookingDetail->id, 0);
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $roomTourismTaxParams = OrderTaxDetail::buildRoomTaxParams((int) $objBookingDetail->id);
+ if ($roomTourismTaxParams) {
+ OrderTaxDetail::saveTourismTax(
+ $roomTourismTaxParams['idTaxRulesGroup'],
+ $roomTourismTaxParams['address'],
+ $roomTourismTaxParams['unitPriceTaxExcl'],
+ $roomTourismTaxParams['checkInDate'],
+ $roomTourismTaxParams['numNights'],
+ $roomTourismTaxParams['numAdults'],
+ $roomTourismTaxParams['childrenAges'],
+ $roomTourismTaxParams['idCurrency'],
+ $roomTourismTaxParams['collectionType'],
+ $roomTourismTaxParams['idLang'],
+ $roomTourismTaxParams['quantity'],
+ $roomTourismTaxParams['idOrder'],
+ $roomTourismTaxParams['idOrderDetail'],
+ $roomTourismTaxParams['idHtlBooking'],
+ $roomTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+ }
+
// Get Booking services of the old booking to add in the new booking creation
$objServiceProductOrderDetail = new ServiceProductOrderDetail();
if ($oldAdditonalServices = $objServiceProductOrderDetail->getRoomTypeServiceProducts(
@@ -2217,6 +2242,29 @@ class HotelBookingDetail extends ObjectModel
$objServiceProductOrderDetail = new ServiceProductOrderDetail($service['id_service_product_order_detail']);
$objServiceProductOrderDetail->id_htl_booking_detail = $objBookingDetail->id;
$objServiceProductOrderDetail->save();
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $serviceTourismTaxParams = OrderTaxDetail::buildServiceLineTaxParams((int) $objServiceProductOrderDetail->id);
+ if ($serviceTourismTaxParams) {
+ OrderTaxDetail::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail']
+ );
+ }
+ }
}
}
}
@@ -2365,6 +2413,10 @@ class HotelBookingDetail extends ObjectModel
if ($objOldHotelBooking->delete()) {
// delete refund request of the room if exists.
OrderReturnDetail::deleteReturnDetailByIdBookingDetail($objOldHotelBooking->id_order, $idHotelBooking);
+
+ // attached services were already re-pointed to the new booking above, so only
+ // the room's own tax rows belong to this scope now.
+ OrderTaxDetail::hardDeleteForBooking($idHotelBooking, array());
}
$totalPaid = (float)$objOrder->getTotalPaid();
@@ -2577,6 +2629,7 @@ class HotelBookingDetail extends ObjectModel
}
} else {
$result = Db::getInstance()->update($table, $data, 'id_order='.(int)$id_order);
+ Db::getInstance()->update('service_product_order_detail', array('is_refunded' => (int) $is_refunded), 'id_order='.(int)$id_order);
}
// if automatic overbooking resolution is enabled
diff --git a/modules/hotelreservationsystem/classes/HotelBranchInformation.php b/modules/hotelreservationsystem/classes/HotelBranchInformation.php
index 61083fde1..b5a4640e4 100644
--- a/modules/hotelreservationsystem/classes/HotelBranchInformation.php
+++ b/modules/hotelreservationsystem/classes/HotelBranchInformation.php
@@ -23,6 +23,9 @@
class HotelBranchInformation extends ObjectModel
{
+ const TAX_COLLECTION_TYPE_ONLINE = 0;
+ const TAX_COLLECTION_TYPE_AT_HOTEL = 1;
+
public $id_category;
public $hotel_name;
public $email;
@@ -39,6 +42,7 @@ class HotelBranchInformation extends ObjectModel
public $map_input_text;
public $active_refund;
public $fax;
+ public $tourism_tax_collection_type = 0;
public $date_add;
public $date_upd;
@@ -61,6 +65,7 @@ class HotelBranchInformation extends ObjectModel
'map_input_text' => array('type' => self::TYPE_STRING, 'validate' => 'isString'),
'active_refund' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'),
'fax' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName'),
+ 'tourism_tax_collection_type' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'),
'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate', 'copy_post' => false),
'date_upd' => array('type' => self::TYPE_DATE, 'validate' => 'isDate', 'copy_post' => false),
diff --git a/modules/hotelreservationsystem/classes/HotelCartBookingData.php b/modules/hotelreservationsystem/classes/HotelCartBookingData.php
index c5d715d09..31fb17a4d 100644
--- a/modules/hotelreservationsystem/classes/HotelCartBookingData.php
+++ b/modules/hotelreservationsystem/classes/HotelCartBookingData.php
@@ -1621,6 +1621,7 @@ class HotelCartBookingData extends ObjectModel
$cartHotelData[$prodKey]['date_diff'][$dateJoin]['child_ages'] = array_merge($cartHotelData[$prodKey]['date_diff'][$dateJoin]['child_ages'], json_decode($data_v['child_ages']));
}
$varQty = (int) $cartHotelData[$prodKey]['date_diff'][$dateJoin]['num_rm'];
+ $includeTourismTax = $price_tax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$roomTypeDateRangePrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
$data_v['date_from'],
@@ -1629,7 +1630,10 @@ class HotelCartBookingData extends ObjectModel
0,
$context->cart->id,
$context->cart->id_guest,
- $data_v['id_room']
+ $data_v['id_room'],
+ 1,
+ 1,
+ $includeTourismTax
);
$roomTypeDateRangePriceWithoutAutoAdd = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
@@ -1640,7 +1644,9 @@ class HotelCartBookingData extends ObjectModel
$context->cart->id,
$context->cart->id_guest,
$data_v['id_room'],
- 0
+ 0,
+ 1,
+ $includeTourismTax
);
$priceWithoutDiscount = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
@@ -1652,7 +1658,8 @@ class HotelCartBookingData extends ObjectModel
$context->cart->id_guest,
$data_v['id_room'],
1,
- 0
+ 0,
+ $includeTourismTax
);
if (!$price_tax) {
$amount = $roomTypeDateRangePrice['total_price_tax_excl'];
@@ -1683,6 +1690,7 @@ class HotelCartBookingData extends ObjectModel
$cartHotelData[$prodKey]['date_diff'][$dateJoin]['children'] = $data_v['children'];
$cartHotelData[$prodKey]['date_diff'][$dateJoin]['child_ages'] = json_decode($data_v['child_ages']);
+ $includeTourismTax = $price_tax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$roomTypeDateRangePrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
$data_v['date_from'],
@@ -1691,7 +1699,10 @@ class HotelCartBookingData extends ObjectModel
0,
$context->cart->id,
$context->cart->id_guest,
- $data_v['id_room']
+ $data_v['id_room'],
+ 1,
+ 1,
+ $includeTourismTax
);
$roomTypeDateRangePriceWithoutAutoAdd = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
@@ -1702,7 +1713,9 @@ class HotelCartBookingData extends ObjectModel
$context->cart->id,
$context->cart->id_guest,
$data_v['id_room'],
- 0
+ 0,
+ 1,
+ $includeTourismTax
);
$priceWithoutDiscount = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$product['id_product'],
@@ -1714,7 +1727,8 @@ class HotelCartBookingData extends ObjectModel
$context->cart->id_guest,
$data_v['id_room'],
1,
- 0
+ 0,
+ $includeTourismTax
);
if (!$price_tax) {
$amount = $roomTypeDateRangePrice['total_price_tax_excl'];
diff --git a/modules/hotelreservationsystem/classes/HotelHelper.php b/modules/hotelreservationsystem/classes/HotelHelper.php
index 68c477b84..e30a33715 100644
--- a/modules/hotelreservationsystem/classes/HotelHelper.php
+++ b/modules/hotelreservationsystem/classes/HotelHelper.php
@@ -1081,6 +1081,8 @@ class HotelHelper
Configuration::updateValue('PS_MIN_BOOKING_OFFSET', 0);
Configuration::updateValue('HTL_FEATURE_PRICING_PRIORITY', 'specific_date;special_day;date_range');
+ Configuration::updateValue('QLO_USE_TOURISM_TAX', 0);
+ Configuration::updateValue('QLO_TOURISM_TAX_GROSSED_UP', 0);
Configuration::updateValue('WK_GOOGLE_ACTIVE_MAP', 0);
Configuration::updateValue('WK_MAP_HOTEL_ACTIVE_ONLY', 1);
diff --git a/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php b/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php
index 49ac30c2f..377a86f69 100644
--- a/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php
+++ b/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php
@@ -69,6 +69,7 @@ class HotelReservationSystemDb
`map_input_text` text NOT NULL,
`active_refund` tinyint(1) unsigned NOT NULL DEFAULT '0',
`fax` varchar(255) DEFAULT NULL,
+ `tourism_tax_collection_type` tinyint(1) unsigned NOT NULL DEFAULT '0',
`date_add` datetime NOT NULL,
`date_upd` datetime NOT NULL,
PRIMARY KEY (`id`)
diff --git a/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php b/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php
index 87dca0f6e..9f9f4b51f 100644
--- a/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php
+++ b/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php
@@ -275,7 +275,8 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
$id_guest = 0,
$id_room = 0,
$with_auto_room_services = 1,
- $use_reduc = 1
+ $use_reduc = 1,
+ $includeTourismTax = false
) {
$totalPrice = array();
$totalPrice['total_price_tax_incl'] = 0;
@@ -378,6 +379,26 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
)
);
+ if ($includeTourismTax && Configuration::get('QLO_USE_TOURISM_TAX') && is_array($occupancy) && !empty($occupancy) && isset($occupancy[0]['adults'])) {
+ if ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $id_product)) {
+ $hotelBranch = new HotelBranchInformation((int) $objAddress->id_hotel);
+ $collectionType = (int) $hotelBranch->tourism_tax_collection_type;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($date_from, $date_to));
+ $unitPriceTe = (float) $totalPrice['total_price_tax_excl'] / $numNights;
+ $taxCalculator = TaxManagerFactory::getManager($objAddress, $idTourismTaxRulesGroup)->getTaxCalculator();
+ $childAges = !empty($occupancy[0]['child_ages']) ? (array) $occupancy[0]['child_ages'] : array();
+ $totalPrice['total_price_tax_incl'] += $taxCalculator->getTaxesTotalAmount(
+ $unitPriceTe,
+ $date_from,
+ $numNights,
+ $occupancy[0]['adults'],
+ $childAges,
+ $collectionType,
+ 1,
+ $id_currency
+ );
+ }
+ }
if ($with_auto_room_services) {
if ($id_cart && $id_room) {
@@ -406,6 +427,9 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
foreach($roomServicesServices as $selectedService) {
$totalPrice['total_price_tax_incl'] += $selectedService['total_price_tax_incl'];
$totalPrice['total_price_tax_excl'] += $selectedService['total_price_tax_excl'];
+ if ($includeTourismTax && isset($selectedService['tourism_tax'])) {
+ $totalPrice['total_price_tax_incl'] += $selectedService['tourism_tax'];
+ }
}
}
}
@@ -417,7 +441,8 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
$date_to,
Product::PRICE_ADDITION_TYPE_WITH_ROOM,
true,
- $use_reduc
+ $use_reduc,
+ $includeTourismTax
)) {
foreach($servicesWithTax as $service) {
$totalPrice['total_price_tax_incl'] += $service['price'];
@@ -469,6 +494,7 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
) {
$dateFrom = date('Y-m-d H:i:s', strtotime($date_from));
$dateTo = date('Y-m-d H:i:s', strtotime($date_to));
+ $includeTourismTax = $use_tax && Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$totalDurationPrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$id_product,
$dateFrom,
@@ -479,7 +505,8 @@ class HotelRoomTypeFeaturePricing extends ObjectModel
$id_guest,
$id_room,
$with_auto_room_services,
- $use_reduc
+ $use_reduc,
+ $includeTourismTax
);
$totalDurationPriceTI = $totalDurationPrice['total_price_tax_incl'];
diff --git a/modules/hotelreservationsystem/classes/RoomTypeServiceProduct.php b/modules/hotelreservationsystem/classes/RoomTypeServiceProduct.php
index 9bac1f873..6c74776a1 100644
--- a/modules/hotelreservationsystem/classes/RoomTypeServiceProduct.php
+++ b/modules/hotelreservationsystem/classes/RoomTypeServiceProduct.php
@@ -139,7 +139,7 @@ class RoomTypeServiceProduct extends ObjectModel
return Db::getInstance()->getValue($sql);
}
- public static function getAutoAddServices($idProduct, $dateFrom = null, $dateTo = null, $priceAdditionType = null, $useTax = null, $use_reduc = 1)
+ public static function getAutoAddServices($idProduct, $dateFrom = null, $dateTo = null, $priceAdditionType = null, $useTax = null, $use_reduc = 1, $includeTourismTax = false)
{
if (Product::isBookingProduct($idProduct)) {
$context = Context::getContext();
@@ -168,7 +168,10 @@ class RoomTypeServiceProduct extends ObjectModel
$dateTo,
false,
null,
- $use_reduc
+ $use_reduc,
+ null,
+ 0,
+ $includeTourismTax
);
}
@@ -222,7 +225,12 @@ class RoomTypeServiceProduct extends ObjectModel
1,
null,
null,
- $context->cart->id
+ $context->cart->id,
+ null,
+ 1,
+ null,
+ 0,
+ Configuration::get('QLO_TOURISM_TAX_GROSSED_UP')
);
$useTax = Product::$_taxCalculationMethod == PS_TAX_EXC ? false : true;
@@ -246,6 +254,99 @@ class RoomTypeServiceProduct extends ObjectModel
return $serviceProducts;
}
+ /**
+ *
+ * @param array $roomTypeServiceProducts Catalog rows from getServiceProductsData()
+ * @param array $cartBookings Rows from HotelCartBookingData::getHotelCartRoomsInfoByRoomType()
+ * @param int $idProductRoomType
+ * @param string $dateFrom
+ * @param string $dateTo
+ * @return array id_cart_booking => id_product => [unit_price_tax_incl, unit_price_tax_excl, total_price_tax_incl, total_price_tax_excl]
+ */
+ public function getServiceProductsPricingForCartBookings($roomTypeServiceProducts, $cartBookings, $idProductRoomType, $dateFrom, $dateTo)
+ {
+ $pricingByBookingAndProduct = array();
+ if (!$roomTypeServiceProducts || !$cartBookings) {
+ return $pricingByBookingAndProduct;
+ }
+
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($dateFrom, $dateTo));
+ $grossedUp = (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
+ $idCart = Context::getContext()->cart->id;
+
+ $serviceProductsById = array();
+ $totalExclByProduct = array();
+ $totalInclVatOnlyByProduct = array();
+ $unitInclVatOnlyByProduct = array();
+ $taxContextByProduct = array();
+ $hotelContext = null;
+
+ foreach ($roomTypeServiceProducts as $serviceProduct) {
+ $idServiceProduct = (int) $serviceProduct['id_product'];
+ $serviceProductsById[$idServiceProduct] = $serviceProduct;
+ $totalExclByProduct[$idServiceProduct] = Product::getServiceProductPrice($idServiceProduct, 0, false, $idProductRoomType, false, 1, $dateFrom, $dateTo, $idCart);
+ $totalInclVatOnlyByProduct[$idServiceProduct] = Product::getServiceProductPrice($idServiceProduct, 0, false, $idProductRoomType, true, 1, $dateFrom, $dateTo, $idCart);
+ $unitInclVatOnlyByProduct[$idServiceProduct] = $grossedUp ? Product::getServiceProductPrice($idServiceProduct, 0, false, $idProductRoomType, true, 1, null, null, $idCart) : (float) $serviceProduct['price_tax_incl'];
+
+ if (!$grossedUp) {
+ continue;
+ }
+ if (!$idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idServiceProduct)) {
+ continue;
+ }
+ if ($hotelContext === null) {
+ $idHotel = (int) $cartBookings[0]['id_hotel'];
+ $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, new Address((int) Cart::getIdAddressForTaxCalculation($idProductRoomType)));
+ }
+ $taxContextByProduct[$idServiceProduct] = array(
+ 'calculator' => TaxManagerFactory::getManager($hotelContext['address'], $idTourismTaxRulesGroup)->getTaxCalculator(),
+ 'collectionType' => $hotelContext['collectionType'],
+ );
+ }
+
+ $occupancySignatures = array();
+ foreach ($cartBookings as $cartBooking) {
+ $childrenAges = !empty($cartBooking['child_ages']) ? (array) json_decode($cartBooking['child_ages'], true) : array();
+ $occupancySignature = (int) $cartBooking['adults'] . '|' . implode(',', $childrenAges);
+ if (!isset($occupancySignatures[$occupancySignature])) {
+ $occupancySignatures[$occupancySignature] = array('adults' => (int) $cartBooking['adults'], 'childrenAges' => $childrenAges);
+ }
+ }
+
+ $totalTourismTaxByProductAndSignature = array();
+ $unitTourismTaxByProductAndSignature = array();
+ foreach ($taxContextByProduct as $idServiceProduct => $taxContext) {
+ $unitExcl = (float) $serviceProductsById[$idServiceProduct]['price_tax_exc'];
+ foreach ($occupancySignatures as $occupancySignature => $occupancy) {
+ $totalTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature] = $taxContext['calculator']->getTaxesTotalAmount(
+ $unitExcl, $dateFrom, $numNights, $occupancy['adults'], $occupancy['childrenAges'], $taxContext['collectionType'], 1
+ );
+ $unitTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature] = $taxContext['calculator']->getTaxesTotalAmount(
+ $unitExcl, $dateFrom, 1, $occupancy['adults'], $occupancy['childrenAges'], $taxContext['collectionType'], 1
+ );
+ }
+ }
+
+ foreach ($cartBookings as $cartBooking) {
+ $idCartBooking = (int) $cartBooking['id'];
+ $childrenAges = !empty($cartBooking['child_ages']) ? (array) json_decode($cartBooking['child_ages'], true) : array();
+ $occupancySignature = (int) $cartBooking['adults'] . '|' . implode(',', $childrenAges);
+ foreach ($roomTypeServiceProducts as $serviceProduct) {
+ $idServiceProduct = (int) $serviceProduct['id_product'];
+ $totalTourismTax = isset($totalTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature]) ? $totalTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature] : 0;
+ $unitTourismTax = isset($unitTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature]) ? $unitTourismTaxByProductAndSignature[$idServiceProduct][$occupancySignature] : 0;
+ $pricingByBookingAndProduct[$idCartBooking][$idServiceProduct] = array(
+ 'total_price_tax_excl' => $totalExclByProduct[$idServiceProduct],
+ 'total_price_tax_incl' => $totalInclVatOnlyByProduct[$idServiceProduct] + $totalTourismTax,
+ 'unit_price_tax_excl' => (float) $serviceProduct['price_tax_exc'],
+ 'unit_price_tax_incl' => $unitInclVatOnlyByProduct[$idServiceProduct] + $unitTourismTax,
+ );
+ }
+ }
+
+ return $pricingByBookingAndProduct;
+ }
+
public function getServiceProductsGroupByCategory($idProduct, $p = 1, $n = 0, $front = false, $available_for_order = 2, $auto_add_to_cart = 0, $idLang = false)
{
if (!$idLang) {
diff --git a/modules/hotelreservationsystem/classes/RoomTypeServiceProductPrice.php b/modules/hotelreservationsystem/classes/RoomTypeServiceProductPrice.php
index 9778a7f55..65e031b91 100644
--- a/modules/hotelreservationsystem/classes/RoomTypeServiceProductPrice.php
+++ b/modules/hotelreservationsystem/classes/RoomTypeServiceProductPrice.php
@@ -120,32 +120,5 @@ class RoomTypeServiceProductPrice extends ObjectModel
);
}
- public static function getPrice(
- $idProduct,
- $idHotel,
- $idProductOption = null,
- $useTax = null,
- $quantity = 1,
- $useReduc = true
- ) {
- $idHotelAddress = Cart::getIdAddressForTaxCalculation($idProduct, $idHotel);
- $price = Product::getPriceStatic(
- $idProduct,
- $useTax,
- $idProductOption,
- 6,
- null,
- false,
- $useReduc,
- $quantity,
- false,
- null,
- null,
- $idHotelAddress
- );
-
- $price = $price * (int)$quantity;
- return Product::applyGroupDiscount($price, $idProduct, (int)Group::getCurrent()->id);
- }
}
diff --git a/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php b/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php
index e2d08d14a..2f9c50dae 100644
--- a/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php
+++ b/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php
@@ -229,32 +229,54 @@ class ServiceProductCartDetail extends ObjectModel
foreach ($serviceProducts as $product) {
$objProduct = new Product($product['id_product'], false, $language->id);
if (!$objProduct->booking_product) {
+ $quantity = $product['quantity'] ? (int) $product['quantity'] : 1;
+
+ $numDays = 1;
+ // If price type is per day but the dates are not valid.
+ if (($objProduct->price_calculation_method == Product::PRICE_CALCULATION_METHOD_PER_DAY)
+ && (!$numDays = HotelHelper::getNumberOfDays($product['date_from'], $product['date_to']))
+ ) {
+ $numDays = 1;
+ }
+
+ $objHotelCartBooking = $product['htl_cart_booking_id']
+ ? new HotelCartBookingData($product['htl_cart_booking_id'])
+ : null;
+
+ $priceTaxExcl = Product::getServiceProductPrice(
+ $objProduct->id,
+ $product['id_product_option'],
+ $product['id_hotel'],
+ $product['id_product_room_type'],
+ false,
+ 1,
+ $product['date_from'],
+ $product['date_to'],
+ $idCart,
+ null,
+ 1,
+ null,
+ $product['htl_cart_booking_id']
+ ) / $numDays;
+
if ($getTotalPrice) {
- $qty = $product['quantity'] ? (int)$product['quantity'] : 1;
$totalPrice += Product::getServiceProductPrice(
$objProduct->id,
$product['id_product_option'],
$product['id_hotel'],
$product['id_product_room_type'],
$useTax,
- $qty,
+ $quantity,
$product['date_from'],
$product['date_to'],
$idCart,
null,
1,
null,
- $product['htl_cart_booking_id']
+ $product['htl_cart_booking_id'],
+ $useTax && Configuration::get('QLO_TOURISM_TAX_GROSSED_UP')
);
} else {
- $numDays = 1;
- // If price type is per day but the dates are not valid.
- if (($objProduct->price_calculation_method == Product::PRICE_CALCULATION_METHOD_PER_DAY)
- && (!$numDays = HotelHelper::getNumberOfDays($product['date_from'], $product['date_to']))
- ) {
- $numDays = 1;
- }
-
$priceTaxIncl = Product::getServiceProductPrice(
$objProduct->id,
$product['id_product_option'],
@@ -270,21 +292,42 @@ class ServiceProductCartDetail extends ObjectModel
null,
$product['htl_cart_booking_id']
)/$numDays;
- $priceTaxExcl = Product::getServiceProductPrice(
- $objProduct->id,
- $product['id_product_option'],
- $product['id_hotel'],
- $product['id_product_room_type'],
- false,
- 1,
- $product['date_from'],
- $product['date_to'],
- $idCart,
- null,
- 1,
- null,
- $product['htl_cart_booking_id']
- )/$numDays;
+
+ $tourismTaxAmount = 0.0;
+ if (Product::getIdTourismTaxRulesGroupByIdProduct($objProduct->id)) {
+ $totalExclTourismTax = Product::getServiceProductPrice(
+ $objProduct->id,
+ $product['id_product_option'],
+ $product['id_hotel'],
+ $product['id_product_room_type'],
+ false,
+ $quantity,
+ $product['date_from'],
+ $product['date_to'],
+ $idCart,
+ null,
+ 1,
+ null,
+ $product['htl_cart_booking_id']
+ );
+ $totalInclTourismTax = Product::getServiceProductPrice(
+ $objProduct->id,
+ $product['id_product_option'],
+ $product['id_hotel'],
+ $product['id_product_room_type'],
+ false,
+ $quantity,
+ $product['date_from'],
+ $product['date_to'],
+ $idCart,
+ null,
+ 1,
+ null,
+ $product['htl_cart_booking_id'],
+ true
+ );
+ $tourismTaxAmount = $totalInclTourismTax - $totalExclTourismTax;
+ }
$optionDetails = false;
if (ServiceProductOption::productHasOptions($product['id_product'])) {
@@ -331,10 +374,10 @@ class ServiceProductCartDetail extends ObjectModel
'auto_add_to_cart' => $product['auto_add_to_cart'],
'price_addition_type' => $product['price_addition_type'],
'total_price' => ($useTax ? $priceTaxIncl : $priceTaxExcl) * $numDays * (int)$product['quantity'],
+ 'tourism_tax_amount' => $tourismTaxAmount,
);
- if ($product['htl_cart_booking_id']) {
- $objHotelCartBooking = new HotelCartBookingData($product['htl_cart_booking_id']);
+ if ($objHotelCartBooking) {
$productInfo['date_from'] = $objHotelCartBooking->date_from;
$productInfo['date_to'] = $objHotelCartBooking->date_to;
$productInfo['id_room_type_hotel'] = $objHotelCartBooking->id_hotel;
@@ -348,6 +391,8 @@ class ServiceProductCartDetail extends ObjectModel
$productInfo['id_room'] = 0;
}
+ $productInfo['tourism_tax'] = $tourismTaxAmount;
+
if ($detailedInfo) {
$objHotelBranchInformation = new HotelBranchInformation();
$hotelInfo = $objHotelBranchInformation->hotelBranchesInfo($language->id, 2, 1, $product['id_hotel']);
diff --git a/modules/hotelreservationsystem/classes/ServiceProductOrderDetail.php b/modules/hotelreservationsystem/classes/ServiceProductOrderDetail.php
index 77a39d457..443fd7577 100644
--- a/modules/hotelreservationsystem/classes/ServiceProductOrderDetail.php
+++ b/modules/hotelreservationsystem/classes/ServiceProductOrderDetail.php
@@ -479,6 +479,45 @@ class ServiceProductOrderDetail extends ObjectModel
return $result;
}
+ /**
+ * Active (non-refunded, non-cancelled) service line ids attached to a room booking.
+ * Used by the tourism tax apply/exempt flow to also act on a room's own service lines.
+ *
+ * @param int $idHtlBookingDetail
+ * @return array int[]
+ */
+ public static function getActiveIdsByHtlBookingDetail($idHtlBookingDetail)
+ {
+ $rows = Db::getInstance()->executeS(
+ 'SELECT `id_service_product_order_detail` FROM `' . _DB_PREFIX_ . 'service_product_order_detail`
+ WHERE `id_htl_booking_detail` = ' . (int) $idHtlBookingDetail . '
+ AND `is_refunded` = 0
+ AND `is_cancelled` = 0'
+ );
+
+ return $rows ? array_map('intval', array_column($rows, 'id_service_product_order_detail')) : array();
+ }
+
+ /**
+ * Active (not refunded/cancelled) standalone service line ids for an order — lines with no room
+ * attachment (id_htl_booking_detail = 0), the counterpart to getActiveIdsByHtlBookingDetail().
+ *
+ * @param int $idOrder
+ * @return array int[]
+ */
+ public static function getActiveStandaloneIdsByOrder($idOrder)
+ {
+ $rows = Db::getInstance()->executeS(
+ 'SELECT `id_service_product_order_detail` FROM `' . _DB_PREFIX_ . 'service_product_order_detail`
+ WHERE `id_order` = ' . (int) $idOrder . '
+ AND `id_htl_booking_detail` = 0
+ AND `is_refunded` = 0
+ AND `is_cancelled` = 0'
+ );
+
+ return $rows ? array_map('intval', array_column($rows, 'id_service_product_order_detail')) : array();
+ }
+
// process the tables changes when a product refund/cancellation is processed
public function processRefundInTables()
{
diff --git a/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php b/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php
index fcb976763..e2a59d4d5 100644
--- a/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php
+++ b/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php
@@ -219,6 +219,8 @@ class AdminAddHotelController extends ModuleAdminController
$smartyVars['PS_MAX_CHECKOUT_OFFSET'] = (int) Configuration::get('PS_MAX_CHECKOUT_OFFSET');
$smartyVars['PS_MIN_BOOKING_OFFSET'] = (int) Configuration::get('PS_MIN_BOOKING_OFFSET');
$smartyVars['WK_ORDER_REFUND_ALLOWED'] = Configuration::get('WK_ORDER_REFUND_ALLOWED');
+ $smartyVars['tourism_tax_collection_type'] = (int) $this->object->tourism_tax_collection_type;
+ $smartyVars['use_tourism_tax'] = (int) Configuration::get('QLO_USE_TOURISM_TAX');
$this->context->smarty->assign($smartyVars);
@@ -519,6 +521,7 @@ class AdminAddHotelController extends ModuleAdminController
}
$objHotelBranch->active = $active;
$objHotelBranch->active_refund = $activeRefund;
+ $objHotelBranch->tourism_tax_collection_type = (int) Tools::getValue('tourism_tax_collection_type', 0);
$objHotelBranch->fax = $fax;
// lang fields
diff --git a/modules/hotelreservationsystem/hotelreservationsystem.php b/modules/hotelreservationsystem/hotelreservationsystem.php
index 5a77ec049..a1319eab5 100644
--- a/modules/hotelreservationsystem/hotelreservationsystem.php
+++ b/modules/hotelreservationsystem/hotelreservationsystem.php
@@ -566,7 +566,9 @@ class HotelReservationSystem extends Module
'WK_HOTEL_NAME_ENABLE',
'WK_CUSTOMER_SUPPORT_PHONE_NUMBER',
'WK_CUSTOMER_SUPPORT_EMAIL',
- 'WK_DISPLAY_CONTACT_PAGE_HOTEL_LIST'
+ 'WK_DISPLAY_CONTACT_PAGE_HOTEL_LIST',
+ 'QLO_USE_TOURISM_TAX',
+ 'QLO_TOURISM_TAX_GROSSED_UP',
);
foreach ($configKeys as $key) {
if (!Configuration::deleteByName($key)) {
diff --git a/modules/hotelreservationsystem/views/css/HotelReservationAdmin.css b/modules/hotelreservationsystem/views/css/HotelReservationAdmin.css
index 3014d3ea2..592be76be 100644
--- a/modules/hotelreservationsystem/views/css/HotelReservationAdmin.css
+++ b/modules/hotelreservationsystem/views/css/HotelReservationAdmin.css
@@ -653,4 +653,8 @@ p.room_cat_data
margin-top: 15px;}
.error-border {
outline: rgb(210, 124, 130) solid 1px;
- border-radius: 2px;}
\ No newline at end of file
+ border-radius: 2px;}
+.tourism-tax-field {
+ display: none;}
+.tourism-tax-non-tourism {
+ display: none;}
diff --git a/modules/hotelreservationsystem/views/js/TourismTaxForm.js b/modules/hotelreservationsystem/views/js/TourismTaxForm.js
new file mode 100644
index 000000000..80e478c60
--- /dev/null
+++ b/modules/hotelreservationsystem/views/js/TourismTaxForm.js
@@ -0,0 +1,155 @@
+/**
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License version 3.0
+ * that is bundled with this package in the file LICENSE.md
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/license/osl-3-0-php
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to support@qloapps.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade this module to a newer
+ * versions in the future. If you wish to customize this module for your needs
+ * please refer to https://store.webkul.com/customisation-guidelines for more information.
+ *
+ * @author Webkul IN
+ * @copyright Since 2010 Webkul
+ * @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
+ */
+
+$(document).ready(function () {
+ var CALCULATION_TYPE_PERCENTAGE = 1;
+
+ var tierIdx = $('#tourism-tax-tiers-body tr').length;
+ var childIdx = $('#tourism-tax-child-body tr').length;
+ var validityIdx = $('#tourism-tax-validity-body tr').length;
+
+ function bindValidityDatepickers($scope) {
+ $scope.find('.tourism-tax-validity-date').datepicker({ dateFormat: 'yy-mm-dd' });
+ }
+ bindValidityDatepickers($('#tourism-tax-validity-body'));
+
+ function isTourismOn() { return $('input[name="is_tourism_tax"]:checked').val() == '1'; }
+ function isChildRateOn() { return $('input[name="has_child_rate"]:checked').val() == '1'; }
+ function isChildAgeRangeOn() { return $('input[name="has_child_age_range"]:checked').val() == '1'; }
+ function isTieredOn() { return $('input[name="is_tiered"]:checked').val() == '1'; }
+ function isMultiValidOn() { return $('input[name="has_multiple_valid_ranges"]:checked').val() == '1'; }
+
+ function setVisible($el, visible, animate) {
+ var duration = animate ? 200 : 0;
+ visible ? $el.show(duration) : $el.hide(duration);
+ }
+
+ function ensureOneRow(bodyId, tplId, idxRef, signClass, signFn) {
+ if ($('#' + bodyId + ' tr').length === 0) {
+ var tpl = $('#' + tplId).html().replace(/__IDX__/g, idxRef.val);
+ var $row = $(tpl);
+ if (signClass && signFn) {
+ $row.find('.' + signClass).text(signFn());
+ }
+ bindValidityDatepickers($row);
+ $('#' + bodyId).append($row);
+ idxRef.val++;
+ }
+ }
+
+ var tierIdxRef = { val: tierIdx };
+ var childIdxRef = { val: childIdx };
+ var validityIdxRef = { val: validityIdx };
+
+ function updateAllVisibility(animate) {
+ var t = isTourismOn();
+ setVisible($('.tourism-tax-field').not('.tourism-tax-child-field, .tourism-tax-child-band-field, .tourism-tax-tiered-field, .tourism-tax-multi-valid-field'), t, animate);
+ setVisible($('.tourism-tax-non-tourism'), !t, animate);
+
+ var showChild = t && isChildRateOn();
+ setVisible($('.tourism-tax-child-field'), showChild, animate);
+
+ var showChildBands = showChild && isChildAgeRangeOn();
+ setVisible($('.tourism-tax-child-band-field'), showChildBands, animate);
+ if (showChildBands) {
+ ensureOneRow('tourism-tax-child-body', 'tourism-tax-child-row-tpl', childIdxRef, 'tourism-tax-child-row-type-sign', currentChildTypeSign);
+ }
+
+ var showTiered = t && isTieredOn();
+ setVisible($('.tourism-tax-tiered-field'), showTiered, animate);
+ if (showTiered) {
+ ensureOneRow('tourism-tax-tiers-body', 'tourism-tax-tier-row-tpl', tierIdxRef, 'tourism-tax-row-type-sign', currentTypeSign);
+ }
+
+ var showMultiValid = t && isMultiValidOn();
+ setVisible($('.tourism-tax-multi-valid-field'), showMultiValid, animate);
+ if (showMultiValid) {
+ ensureOneRow('tourism-tax-validity-body', 'tourism-tax-validity-row-tpl', validityIdxRef, null, null);
+ }
+ }
+
+ updateAllVisibility(false);
+ updateTypeSign();
+ updateChildTypeSign();
+
+ $('input[name="is_tourism_tax"], input[name="has_child_rate"], input[name="has_child_age_range"], input[name="is_tiered"], input[name="has_multiple_valid_ranges"]').on('change', function () {
+ setTimeout(function () { updateAllVisibility(true); }, 0);
+ });
+
+ $(document).on('click', '.prestashop-switch .slide-button', function () {
+ setTimeout(function () { updateAllVisibility(true); }, 0);
+ });
+
+ function currentTypeSign() {
+ var $sign = $('#tourism-tax-type-sign');
+ return $('select[name="tax_calc_type"]').val() == CALCULATION_TYPE_PERCENTAGE ? '%' : $sign.data('currency');
+ }
+
+ function updateTypeSign() {
+ var sign = currentTypeSign();
+ $('#tourism-tax-type-sign').text(sign);
+ $('.tourism-tax-row-type-sign').text(sign);
+ }
+
+ function currentChildTypeSign() {
+ return $('select[name="child_calc_type"]').val() == CALCULATION_TYPE_PERCENTAGE ? '%' : $('#tourism-tax-type-sign').data('currency');
+ }
+
+ function updateChildTypeSign() {
+ $('.tourism-tax-child-row-type-sign').text(currentChildTypeSign());
+ }
+
+ $('select[name="tax_calc_type"]').on('change', updateTypeSign);
+ $('select[name="child_calc_type"]').on('change', updateChildTypeSign);
+
+ $(document).on('click', '#tourism-tax-add-tier', function (e) {
+ e.preventDefault();
+ var tpl = $('#tourism-tax-tier-row-tpl').html().replace(/__IDX__/g, tierIdxRef.val);
+ var $row = $(tpl);
+ $row.find('.tourism-tax-row-type-sign').text(currentTypeSign());
+ $('#tourism-tax-tiers-body').append($row);
+ tierIdxRef.val++;
+ });
+
+ $(document).on('click', '#tourism-tax-add-child', function (e) {
+ e.preventDefault();
+ var tpl = $('#tourism-tax-child-row-tpl').html().replace(/__IDX__/g, childIdxRef.val);
+ var $row = $(tpl);
+ $row.find('.tourism-tax-child-row-type-sign').text(currentChildTypeSign());
+ $('#tourism-tax-child-body').append($row);
+ childIdxRef.val++;
+ });
+
+ $(document).on('click', '#tourism-tax-add-validity', function (e) {
+ e.preventDefault();
+ var tpl = $('#tourism-tax-validity-row-tpl').html().replace(/__IDX__/g, validityIdxRef.val);
+ var $row = $(tpl);
+ bindValidityDatepickers($row);
+ $('#tourism-tax-validity-body').append($row);
+ validityIdxRef.val++;
+ });
+
+ $(document).on('click', '.tourism-tax-remove-row', function (e) {
+ e.preventDefault();
+ $(this).closest('tr').remove();
+ });
+});
diff --git a/modules/hotelreservationsystem/views/templates/admin/add_hotel/helpers/form/form.tpl b/modules/hotelreservationsystem/views/templates/admin/add_hotel/helpers/form/form.tpl
index fbbaf8fc1..09fcad127 100644
--- a/modules/hotelreservationsystem/views/templates/admin/add_hotel/helpers/form/form.tpl
+++ b/modules/hotelreservationsystem/views/templates/admin/add_hotel/helpers/form/form.tpl
@@ -99,6 +99,14 @@
{l s='Amenities' mod='hotelreservationsystem'}
+ {if isset($use_tourism_tax) && $use_tourism_tax}
+
+
+
+ {l s='Tourism Tax' mod='hotelreservationsystem'}
+
+
+ {/if}
{hook h='displayAdminAddHotelFormTab' id_hotel=$hook_arg_id_hotel}
@@ -661,6 +669,25 @@
{hook h='displayAdminAddHotelFormFeaturesTabAfter' id_hotel=$hook_arg_id_hotel}
+ {if isset($use_tourism_tax) && $use_tourism_tax}
+
+ {/if}
{hook h='displayAdminAddHotelFormTabContent' id_hotel=$hook_arg_id_hotel}
diff --git a/modules/hotelreservationsystem/views/templates/admin/order_refund_requests/helpers/view/view.tpl b/modules/hotelreservationsystem/views/templates/admin/order_refund_requests/helpers/view/view.tpl
index 79ea77d21..000940747 100644
--- a/modules/hotelreservationsystem/views/templates/admin/order_refund_requests/helpers/view/view.tpl
+++ b/modules/hotelreservationsystem/views/templates/admin/order_refund_requests/helpers/view/view.tpl
@@ -160,6 +160,8 @@
{/if}
{foreach $refundReqBookings as $booking}
+ {assign var="tourismTaxAppliedAmount" value=$booking['tourism_tax_applied_amount']|default:0}
+ {assign var="tourismTaxPaidAmount" value=$booking['tourism_tax_paid_amount']|default:0}
{if !$isRefundCompleted}
@@ -180,6 +182,12 @@
{l s='Services cost:' mod='hotelreservationsystem'}
{displayPrice price=$booking['extra_service_total_price_tax_incl'] currency=$orderCurrency['id']}
+ {if $tourismTaxAppliedAmount > 0}
+
+ {l s='Tourism Tax:' mod='hotelreservationsystem'}
+ {displayPrice price=$tourismTaxAppliedAmount currency=$orderCurrency['id']}
+
+ {/if}
{/capture}
{include file='helpers/tooltip.tpl' tooltip_content=$smarty.capture.refund_total_tooltip}
@@ -194,6 +202,12 @@
{l s='Services paid amount:' mod='hotelreservationsystem'}
{displayPrice price=$booking['extra_service_total_paid_amount'] currency=$orderCurrency['id']}
+ {if $tourismTaxPaidAmount > 0}
+
+ {l s='Tourism Tax paid:' mod='hotelreservationsystem'}
+ {displayPrice price=$tourismTaxPaidAmount currency=$orderCurrency['id']}
+
+ {/if}
{/capture}
{include file='helpers/tooltip.tpl' tooltip_content=$smarty.capture.refund_paid_tooltip }
diff --git a/modules/qlocleaner/qlocleaner.php b/modules/qlocleaner/qlocleaner.php
index 71115308a..bd9a7f21b 100644
--- a/modules/qlocleaner/qlocleaner.php
+++ b/modules/qlocleaner/qlocleaner.php
@@ -622,7 +622,7 @@ class QloCleaner extends Module
array('order_carrier', 'id_order', 'orders', 'id_order'),
array('order_cart_rule', 'id_order', 'orders', 'id_order'),
array('order_detail', 'id_order', 'orders', 'id_order'),
- array('order_detail_tax', 'id_order_detail', 'order_detail', 'id_order_detail'),
+ array('order_tax_detail', 'id_order_detail', 'order_detail', 'id_order_detail'),
array('order_history', 'id_order', 'orders', 'id_order'),
array('order_invoice', 'id_order', 'orders', 'id_order'),
array('order_invoice_payment', 'id_order', 'orders', 'id_order'),
@@ -844,7 +844,8 @@ class QloCleaner extends Module
'order_carrier',
'order_cart_rule',
'order_detail',
- 'order_detail_tax',
+ 'order_tax_detail',
+ 'order_tax_exemption',
'order_history',
'order_invoice',
'order_invoice_payment',
diff --git a/modules/wkhotelroom/wkhotelroom.php b/modules/wkhotelroom/wkhotelroom.php
index 8c87c15d2..b6d56fa61 100644
--- a/modules/wkhotelroom/wkhotelroom.php
+++ b/modules/wkhotelroom/wkhotelroom.php
@@ -68,17 +68,19 @@ class WkHotelRoom extends Module
ImageType::getFormatedName('large')
);
}
+ $includeTourismTax = $useTax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
$productPriceWithoutReduction = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
$idProduct,
$dateFrom,
$dateTo,
- 1,
+ array(array('adults' => 1, 'children' => 0, 'child_ages' => array())),
0,
0,
0,
0,
1,
- 0
+ 0,
+ $includeTourismTax
);
if ($useTax) {
@@ -98,7 +100,14 @@ class WkHotelRoom extends Module
$idProduct,
$dateFrom,
$dateTo,
- $useTax
+ $useTax,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ array(array('adults' => 1, 'children' => 0, 'child_ages' => array()))
);
$htlRoom['feature_price'] = $featurePrice;
$htlRoom['feature_price_diff'] = (float)($priceWithoutReduction - $featurePrice);
diff --git a/pdf/booking-voucher.tpl b/pdf/booking-voucher.tpl
index 15cfe229e..9ff85b2a5 100644
--- a/pdf/booking-voucher.tpl
+++ b/pdf/booking-voucher.tpl
@@ -257,10 +257,16 @@
{if $footer.total_tax_without_discount > 0}
- {l s='Total Tax' pdf='true'}
+ {l s='Room and Service Tax' pdf='true'}
{displayPrice currency=$order->id_currency price=$footer.total_tax_without_discount}
{/if}
+ {if isset($footer.total_tourism_tax) && $footer.total_tourism_tax > 0}
+
+ {l s='Total Tourism Tax' pdf='true'}
+ {displayPrice currency=$order->id_currency price=$footer.total_tourism_tax}
+
+ {/if}
{if isset($footer.product_discounts_tax_incl) && $footer.product_discounts_tax_incl > 0}
{l s='Total Discounts' pdf='true'}
diff --git a/pdf/invoice.tax-tab.tpl b/pdf/invoice.tax-tab.tpl
index d2d7fb11e..acf599a90 100644
--- a/pdf/invoice.tax-tab.tpl
+++ b/pdf/invoice.tax-tab.tpl
@@ -57,6 +57,30 @@
{foreach $tax_breakdowns as $label => $bd}
{assign var=label_printed value=false}
+ {* Tourism tax rows have no percentage rate — render them as a fixed-amount section *}
+ {if $label == 'tourism_tax'}
+ {foreach $bd as $line}
+ {assign var=has_line value=true}
+
+ {if !$label_printed}
+ {l s='Tourism Tax' pdf='true'}
+ {assign var=label_printed value=true}
+ {/if}
+ {if isset($showTaxName) && $showTaxName}
+ {$line.tax_name|escape:'html':'UTF-8'}
+ {/if}
+ —
+ {if $display_tax_bases_in_breakdowns}
+ —
+ {/if}
+
+ {displayPrice currency=$order->id_currency price=$line.total_amount}
+
+
+ {/foreach}
+ {continue}
+ {/if}
+
{foreach $bd as $line}
{if $line.rate == 0}
{continue}
diff --git a/pdf/invoice.total-tab.tpl b/pdf/invoice.total-tab.tpl
index 5e97aa077..51f4a5455 100644
--- a/pdf/invoice.total-tab.tpl
+++ b/pdf/invoice.total-tab.tpl
@@ -109,13 +109,23 @@
{if $footer.total_tax_without_discount > 0}
- {l s='Total Tax' pdf='true'}
+ {l s='Room and Service Tax' pdf='true'}
{displayPrice currency=$order->id_currency price=$footer.total_tax_without_discount}
{/if}
+ {if isset($footer.total_tourism_tax) && $footer.total_tourism_tax > 0}
+
+
+ {l s='Total Tourism Tax' pdf='true'}
+
+
+ {displayPrice currency=$order->id_currency price=$footer.total_tourism_tax}
+
+
+ {/if}
{if $footer.product_discounts_tax_incl > 0}
diff --git a/themes/hotel-reservation-theme/cart-total-block.tpl b/themes/hotel-reservation-theme/cart-total-block.tpl
index 7034fd5f5..e9f88356b 100644
--- a/themes/hotel-reservation-theme/cart-total-block.tpl
+++ b/themes/hotel-reservation-theme/cart-total-block.tpl
@@ -91,10 +91,16 @@
{/block}
{if $show_taxes}
- {l s='Total tax'}
+ {l s='Room and Service Tax'}
{displayPrice price=($total_tax_without_discount)}
{/if}
+ {if $show_taxes && isset($total_tourism_tax) && $total_tourism_tax > 0}
+
+ {l s='Total Tourism Tax'}
+ {displayPrice price=$total_tourism_tax}
+
+ {/if}
{if $display_tax_label}
diff --git a/themes/hotel-reservation-theme/css/modules/blockcart/blockcart.css b/themes/hotel-reservation-theme/css/modules/blockcart/blockcart.css
index 62651c4c9..fbc3166f9 100644
--- a/themes/hotel-reservation-theme/css/modules/blockcart/blockcart.css
+++ b/themes/hotel-reservation-theme/css/modules/blockcart/blockcart.css
@@ -228,6 +228,7 @@
.cart_block .cart_block_shipping_cost,
.cart_block .cart_block_tax_cost,
+.cart_block .cart_block_tourism_tax_cost,
.cart_block .cart_block_total,
.cart_block .cart_block_wrapping_cost,
.cart_block .cart_block_convenience_fee {
diff --git a/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js b/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js
index db4fd5712..b31c5d05a 100644
--- a/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js
+++ b/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js
@@ -1293,6 +1293,12 @@ var ajaxCart = {
$('.ajax_cart_shipping_cost').parent().find('.unvisible').show();
$('.ajax_cart_tax_cost').text(jsonData.tax_cost);
+ $('.ajax_cart_tourism_tax_cost').text(jsonData.tourism_tax_cost);
+ if (parseFloat(jsonData.tourism_tax) > 0) {
+ $('.ajax_cart_tourism_tax_line').show();
+ } else {
+ $('.ajax_cart_tourism_tax_line').hide();
+ }
$('.cart_block_wrapping_cost').text(jsonData.wrapping_cost);
$('.ajax_block_cart_total').text(jsonData.total);
diff --git a/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl b/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl
index af73ec530..d82e75f9a 100644
--- a/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl
+++ b/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl
@@ -138,7 +138,15 @@
{if $show_tax && $use_tax}
{$tax_cost}
- {l s='Tax' mod='blockcart'}
+ {l s='Room and Service Tax' mod='blockcart'}
+
+ {/if}
+ {/block}
+ {block name='blockcart_shopping_cart_total_tourism_tax'}
+ {if $show_tax && $use_tax}
+
+ {$tourism_tax_cost}
+ {l s='Total Tourism Tax' mod='blockcart'}
{/if}
{/block}
@@ -266,7 +274,7 @@
{if $cart_qties > 0}
- {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)}
+ {convertPrice price=$cart->getOrderTotal(($priceDisplay != 1), Cart::ONLY_ROOMS)}
{/if}
@@ -285,7 +293,7 @@
{if $cart_qties > 0}
- {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)}
+ {convertPrice price=$normal_products_total}
{/if}
@@ -344,11 +352,21 @@
{block name='blockcart_layer_cart_total_tax'}
{if $show_tax && $use_tax}
- {l s='Tax' mod='blockcart'}
+ {l s='Room and Service Tax' mod='blockcart'}
{$tax_cost}
{/if}
{/block}
+ {block name='blockcart_layer_cart_total_tourism_tax'}
+ {if $show_tax && $use_tax}
+ {* Always rendered — see the matching dropdown block above for why a value-gated
+ {if} would break on later AJAX-only cart updates. *}
+
+ {l s='Total Tourism Tax' mod='blockcart'}
+ {$tourism_tax_cost}
+
+ {/if}
+ {/block}
{block name='blockcart_layer_cart_total_price'}
diff --git a/themes/hotel-reservation-theme/order-confirmation.tpl b/themes/hotel-reservation-theme/order-confirmation.tpl
index 0812922f4..371afd513 100644
--- a/themes/hotel-reservation-theme/order-confirmation.tpl
+++ b/themes/hotel-reservation-theme/order-confirmation.tpl
@@ -475,12 +475,22 @@
{/if}
- {l s='Total Tax'}
+ {l s='Room and Service Tax'}
{displayWtPriceWithCurrency price=$orderTotalInfo['total_tax_without_discount'] currency=$objOrderCurrency convert=1}
+ {if isset($orderTotalInfo['total_tourism_tax']) && $orderTotalInfo['total_tourism_tax'] > 0}
+
+
+ {l s='Total Tourism Tax'}
+
+
+ {displayWtPriceWithCurrency price=$orderTotalInfo['total_tourism_tax'] currency=$objOrderCurrency}
+
+
+ {/if}
{if $order->total_discounts > 0}
diff --git a/themes/hotel-reservation-theme/order-detail.tpl b/themes/hotel-reservation-theme/order-detail.tpl
index 4dbcddc64..65d0f00cf 100644
--- a/themes/hotel-reservation-theme/order-detail.tpl
+++ b/themes/hotel-reservation-theme/order-detail.tpl
@@ -331,7 +331,7 @@
{assign var=room_services_price_tax_excl value=($order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_WITH_ROOM_TYPE))}
{assign var=room_services_price_tax_incl value=($order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_WITH_ROOM_TYPE))}
- {assign var=total_tax_without_discount value=(($room_price_tax_incl - $room_price_tax_excl) + ($room_services_price_tax_incl - $room_services_price_tax_excl ))}
+ {assign var=total_tax_without_discount value=(($room_price_tax_incl - $room_price_tax_excl) + ($room_services_price_tax_incl - $room_services_price_tax_excl) - ($total_tourism_tax|default:0))}
{assign var=total_standard_products_tax_incl value=($order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_STANDALONE) + $order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_HOTEL_STANDALONE))}
{assign var=total_standard_products_tax_excl value=($order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_STANDALONE) + $order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_HOTEL_STANDALONE))}
@@ -374,12 +374,21 @@
{/if}
- {l s='Total Tax'}
+ {l s='Room and Service Tax'}
{displayWtPriceWithCurrency price=($total_tax_without_discount) currency=$currency}
+ {if isset($total_tourism_tax) && $total_tourism_tax > 0}
+
+ {l s='Total Tourism Tax'}
+
+ {displayWtPriceWithCurrency price=$total_tourism_tax currency=$currency}
+
+
+ {/if}
+
{if $order->total_discounts > 0}
{l s='Total Vouchers'}
@@ -752,7 +761,7 @@
{assign var=total_standard_products_tax_incl value=($order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_STANDALONE) + $order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_HOTEL_STANDALONE))}
{assign var=total_standard_products_tax_excl value=($order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_STANDALONE) + $order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_HOTEL_STANDALONE))}
- {assign var=total_tax_without_discount value=(($room_price_tax_incl - $room_price_tax_excl) + ($room_services_price_tax_incl - $room_services_price_tax_excl) + ($total_standard_products_tax_incl - $total_standard_products_tax_excl))}
+ {assign var=total_tax_without_discount value=(($room_price_tax_incl - $room_price_tax_excl) + ($room_services_price_tax_incl - $room_services_price_tax_excl) + ($total_standard_products_tax_incl - $total_standard_products_tax_excl) - ($total_tourism_tax|default:0))}
{if isset($cart_htl_data) && $cart_htl_data}
@@ -793,11 +802,19 @@
{/if}
- {l s='Total Tax'}
+ {l s='Room and Service Tax'}
{displayWtPriceWithCurrency price=($total_tax_without_discount) currency=$currency}
+ {if isset($total_tourism_tax) && $total_tourism_tax > 0}
+
+ {l s='Total Tourism Tax'}
+
+ {displayWtPriceWithCurrency price=$total_tourism_tax currency=$currency}
+
+
+ {/if}
{if $order->total_discounts > 0}
{l s='Total Vouchers'}