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"} + {*
auto_add_to_cart && $product->price_addition_type == Product::PRICE_ADDITION_TYPE_WITH_ROOM}style="display:none"{/if}>
diff --git a/admin/themes/default/template/controllers/orders/_hotel_service_products_table.tpl b/admin/themes/default/template/controllers/orders/_hotel_service_products_table.tpl index 3e5b1cc48..eb4a22fd2 100644 --- a/admin/themes/default/template/controllers/orders/_hotel_service_products_table.tpl +++ b/admin/themes/default/template/controllers/orders/_hotel_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)} + +   + + + {/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/_product_line.tpl b/admin/themes/default/template/controllers/orders/_product_line.tpl index 8a5f88444..cd5728889 100644 --- a/admin/themes/default/template/controllers/orders/_product_line.tpl +++ b/admin/themes/default/template/controllers/orders/_product_line.tpl @@ -105,6 +105,21 @@ {convertPriceWithCurrency price=($data['total_room_tax']) currency=$currency->id} + +   + + {convertPriceWithCurrency price=($data['total_room_price_ti']) currency=$currency->id} @@ -181,6 +196,23 @@ {l s='Delete'} + {if isset($use_tourism_tax) && $use_tourism_tax && isset($data.tourism_tax_status) && $data.tourism_tax_status != $tourism_tax_status_not_applicable && !$data.is_cancelled && !$data.is_refunded} + {if $data.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/_rooms_informaion_table.tpl b/admin/themes/default/template/controllers/orders/_rooms_informaion_table.tpl index 02d3d267b..a0ed4817e 100644 --- a/admin/themes/default/template/controllers/orders/_rooms_informaion_table.tpl +++ b/admin/themes/default/template/controllers/orders/_rooms_informaion_table.tpl @@ -40,8 +40,8 @@ {if (isset($refundReqBookings) && $refundReqBookings)} {l s='Refund'} {/if} - {if ($can_edit && !$order->hasBeenDelivered())} - {l s='Actions'} + {if (isset($can_edit) && $can_edit && !$order->hasBeenDelivered())} + {l s='Actions'} {/if} @@ -62,4 +62,6 @@ {l s='Room information not available.'} -{/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)} + +   + + + {/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. *} + + + + + + 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"}
    @@ -465,6 +468,7 @@ $(document).ready(function () {
    + {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 = ' + + + + + + + + '; + foreach ($tiers as $tierRowIndex => $tier) { + $tierMinRaw = isset($tier['min_amount']) ? $tier['min_amount'] : ''; + $tierMin = (is_numeric($tierMinRaw) && (float) $tierMinRaw == 0) ? '' : $tierMinRaw; + $tierMaxRaw = isset($tier['max_amount']) ? $tier['max_amount'] : ''; + $tierMax = (is_numeric($tierMaxRaw) && (float) $tierMaxRaw == 0) ? '' : $tierMaxRaw; + $tierVal = isset($tier['tax_value']) ? $tier['tax_value'] : ''; + $tierId = isset($tier['id_tier']) ? (int) $tier['id_tier'] : 0; + $tierRowErrors = isset($this->invalidTierFields[$tierRowIndex]) ? $this->invalidTierFields[$tierRowIndex] : array(); + + $priceBracketsHtml .= ' + + + + + + '; + } + $priceBracketsHtml .= ' + +
    + + + + + + --
    + + + + +
    + + +
    +
    + + + + +
    +
    +
    + +
    +
    + '; + + $infantMaxAge = (int) Configuration::get('QLO_GLOBAL_MAX_INFANT_AGE'); + $childMaxAge = (int) Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'); + + $ageBandsHtml = ' + + + + + + + + '; + foreach ($childRanges as $rangeRowIndex => $range) { + $rangeMinRaw = isset($range['min_age']) ? $range['min_age'] : ''; + $rangeMin = (is_numeric($rangeMinRaw) && (int) $rangeMinRaw == 0) ? '' : $rangeMinRaw; + $rangeMaxRaw = isset($range['max_age']) ? $range['max_age'] : ''; + $rangeMax = (is_numeric($rangeMaxRaw) && (int) $rangeMaxRaw == 0) ? '' : $rangeMaxRaw; + $rangeVal = isset($range['tax_value']) ? $range['tax_value'] : ''; + $rangeId = isset($range['id_child_range']) ? (int) $range['id_child_range'] : 0; + $rangeRowErrors = isset($this->invalidChildFields[$rangeRowIndex]) ? $this->invalidChildFields[$rangeRowIndex] : array(); + + $ageBandsHtml .= ' + + + + + + '; + } + $ageBandsHtml .= ' + +
    + + + + + + --
    + + + + +
    + + +
    +
    + + + + +
    +
    +
    + +
    +
    + '; + + $validityRangesHtml = ' + + + + + + + '; + foreach ($validityRanges as $rangeRowIndex => $range) { + $rangeFrom = isset($range['valid_from']) ? $range['valid_from'] : ''; + if ($rangeFrom === '0000-00-00') { + $rangeFrom = ''; + } + $rangeTo = isset($range['valid_to']) ? $range['valid_to'] : ''; + if ($rangeTo === '0000-00-00') { + $rangeTo = ''; + } + $rangeId = isset($range['id_validity_range']) ? (int) $range['id_validity_range'] : 0; + $validityRowErrors = isset($this->invalidValidityFields[$rangeRowIndex]) ? $this->invalidValidityFields[$rangeRowIndex] : array(); + + $validityRangesHtml .= ' + + + + + '; + } + $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 .= ' + '; + } + $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} + {/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} + + {/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 @@ {displayPrice price=$booking['extra_service_total_price_tax_incl'] currency=$orderCurrency['id']} + {if $tourismTaxAppliedAmount > 0} +
    + + {displayPrice price=$tourismTaxAppliedAmount currency=$orderCurrency['id']} +
    + {/if} {/capture} {include file='helpers/tooltip.tpl' tooltip_content=$smarty.capture.refund_total_tooltip} @@ -194,6 +202,12 @@ {displayPrice price=$booking['extra_service_total_paid_amount'] currency=$orderCurrency['id']} + {if $tourismTaxPaidAmount > 0} +
    + + {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} + {/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. *} + + {/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'}