diff --git a/admin/themes/default/template/controllers/normal_products/prices.tpl b/admin/themes/default/template/controllers/normal_products/prices.tpl index 115c78469..375f291a4 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 dd62a5880..f0d4fe766 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/admin/themes/default/template/controllers/sources/helpers/list/list_header.tpl b/admin/themes/default/template/controllers/sources/helpers/list/list_header.tpl new file mode 100644 index 000000000..41dd3b0a3 --- /dev/null +++ b/admin/themes/default/template/controllers/sources/helpers/list/list_header.tpl @@ -0,0 +1,18 @@ +{* +* 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 +* +* @author Webkul IN +* @copyright Since 2010 Webkul +* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0 +*} +{extends file="helpers/list/list_header.tpl"} +{block name="override_form_extra"} + {if isset($smarty.get.id_business_source)} + + {/if} +{/block} diff --git a/admin/themes/default/template/controllers/sources/modals/_merge_source_form.tpl b/admin/themes/default/template/controllers/sources/modals/_merge_source_form.tpl new file mode 100644 index 000000000..00a139193 --- /dev/null +++ b/admin/themes/default/template/controllers/sources/modals/_merge_source_form.tpl @@ -0,0 +1,33 @@ +{* +* 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 +* +* @author Webkul IN +* @copyright Since 2010 Webkul +* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0 +*} +
    + + +
    diff --git a/classes/BoOrder.php b/classes/BoOrder.php index f08c766f6..adc4d423a 100644 --- a/classes/BoOrder.php +++ b/classes/BoOrder.php @@ -32,5 +32,6 @@ class BoOrder extends PaymentModule $this->validateOrderAmount = false; // Default payment type for backoffice order should be PAY_AT_HOTEL $this->payment_type = OrderPayment::PAYMENT_TYPE_PAY_AT_HOTEL; + $this->idBookingSource = Source::getIdByCode('WALKIN'); } } \ No newline at end of file diff --git a/classes/BusinessSource.php b/classes/BusinessSource.php new file mode 100644 index 000000000..d9d072ed5 --- /dev/null +++ b/classes/BusinessSource.php @@ -0,0 +1,239 @@ + 'business_source', + 'primary' => 'id_business_source', + 'multilang' => true, + 'fields' => array( + 'code' => array('type' => self::TYPE_STRING, 'validate' => 'isModuleName', 'required' => true, 'size' => 64), + 'position' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'unremovable' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + '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, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 64), + ), + ); + + public function add($autodate = true, $nullValues = false) + { + if ($this->position <= 0) { + $this->position = BusinessSource::getHigherPosition() + 1; + } + + return parent::add($autodate, $nullValues); + } + + public function update($nullValues = false) + { + if (!$this->active && $this->hasUnremovableSource()) { + return false; + } + + $result = parent::update($nullValues); + + if ($result && !$this->active) { + $this->disableSources(); + } + + return $result; + } + + public function disableSources() + { + return (bool)Db::getInstance()->execute( + 'UPDATE `'._DB_PREFIX_.'source` SET `active` = 0 + WHERE `id_business_source` = '.(int)$this->id.' AND `deleted` = 0' + ); + } + + public static function getHigherPosition() + { + $position = Db::getInstance()->getValue( + 'SELECT MAX(`position`) FROM `'._DB_PREFIX_.'business_source` WHERE `deleted` = 0' + ); + + return is_numeric($position) ? (int)$position : -1; + } + + public function updatePosition($way, $position) + { + if (!$res = Db::getInstance()->executeS(' + SELECT `position`, `id_business_source` + FROM `'._DB_PREFIX_.'business_source` + WHERE `deleted` = 0 + ORDER BY `position` ASC' + )) { + return false; + } + + $moved_item = null; + foreach ($res as $row) { + if ((int)$row['id_business_source'] == (int)$this->id) { + $moved_item = $row; + } + } + + if (!$moved_item) { + return false; + } + + return (Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'business_source` + SET `position` = `position` '.($way ? '- 1' : '+ 1').' + WHERE `deleted` = 0 AND `position` + '.($way + ? '> '.(int)$moved_item['position'].' AND `position` <= '.(int)$position + : '< '.(int)$moved_item['position'].' AND `position` >= '.(int)$position) + ) && Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'business_source` + SET `position` = '.(int)$position.' + WHERE `id_business_source` = '.(int)$moved_item['id_business_source'])); + } + + public static function cleanPositions() + { + $return = true; + + $result = Db::getInstance()->executeS(' + SELECT `id_business_source` + FROM `'._DB_PREFIX_.'business_source` + WHERE `deleted` = 0 + ORDER BY `position` ASC' + ); + + $i = 0; + foreach ($result as $row) { + $return = Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'business_source` + SET `position` = '.(int)$i++.' + WHERE `id_business_source` = '.(int)$row['id_business_source'] + ) && $return; + } + + return $return; + } + + public static function codeExists($code, $idExclude = 0) + { + return (bool)Db::getInstance()->getValue( + 'SELECT `id_business_source` FROM `'._DB_PREFIX_.'business_source` + WHERE `code` = \''.pSQL($code).'\' AND `id_business_source` != '.(int)$idExclude + ); + } + + public static function getUnremovableIds() + { + $rows = Db::getInstance()->executeS( + 'SELECT `id_business_source` FROM `'._DB_PREFIX_.'business_source` WHERE `unremovable` = 1 AND `deleted` = 0' + ); + + return array_map('intval', array_column($rows, 'id_business_source')); + } + + public static function getActiveList($idLang) + { + return Db::getInstance()->executeS(' + SELECT bs.`id_business_source`, bsl.`name` + FROM `'._DB_PREFIX_.'business_source` bs + LEFT JOIN `'._DB_PREFIX_.'business_source_lang` bsl + ON (bsl.`id_business_source` = bs.`id_business_source` AND bsl.`id_lang` = '.(int)$idLang.') + WHERE bs.`deleted` = 0 + ORDER BY bs.`position` ASC + '); + } + + public function isRemovable() + { + return !$this->unremovable; + } + + public function hasUnremovableSource() + { + return (bool)Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `'._DB_PREFIX_.'source` + WHERE `id_business_source` = '.(int)$this->id.' AND `unremovable` = 1 AND `deleted` = 0' + ); + } + + public function hasSourceUsedByOrder() + { + return (bool)Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `'._DB_PREFIX_.'source` s + INNER JOIN `'._DB_PREFIX_.'orders` o ON o.`id_source` = s.`id_source` + WHERE s.`id_business_source` = '.(int)$this->id + ); + } + + public function getOrderCount() + { + return (int)Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `'._DB_PREFIX_.'source` s + INNER JOIN `'._DB_PREFIX_.'orders` o ON o.`id_source` = s.`id_source` + WHERE s.`id_business_source` = '.(int)$this->id + ); + } + + public function delete() + { + if (!$this->isRemovable()) { + return false; + } + + $keepSoft = $this->hasSourceUsedByOrder(); + + $sources = Db::getInstance()->executeS( + 'SELECT `id_source` FROM `'._DB_PREFIX_.'source` WHERE `id_business_source` = '.(int)$this->id.' AND `deleted` = 0' + ); + foreach ($sources as $row) { + $objSource = new Source((int)$row['id_source']); + if (!$objSource->delete()) { + return false; + } + } + + if ($keepSoft) { + $this->deleted = 1; + if (!$this->update()) { + return false; + } + } elseif (!parent::delete()) { + return false; + } + + return BusinessSource::cleanPositions(); + } +} diff --git a/classes/Cart.php b/classes/Cart.php index 8087b1b12..76cd45ce9 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(); @@ -1805,7 +1812,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 += $serviceProduct['tourism_tax']; + $tourism_tax_standalone_products += $serviceProduct['tourism_tax']; + } } } @@ -1860,6 +1871,11 @@ class CartCore extends ObjectModel $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 += $serviceProduct['tourism_tax']; + $tourism_tax_standalone_products += $serviceProduct['tourism_tax']; + } } } } @@ -1893,6 +1909,17 @@ class CartCore extends ObjectModel $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']; + } + } } } } @@ -1902,8 +1929,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'], @@ -1949,10 +1974,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; } @@ -2078,6 +2137,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; } @@ -3222,16 +3285,18 @@ class CartCore extends ObjectModel $id_address = $htlAddress['id_address']; } } - } - - if (!$id_address && $canSellAsStandalone) { - $context = Context::getContext(); - $idCustomer = 0; - if (isset($context->customer) && $context->customer) { - $idCustomer = (int) $context->customer->id; + } elseif ($canSellAsStandalone) { + $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 ($idCustomer) { - $id_address = (int) Address::getFirstCustomerAddressId($idCustomer); + if (!$id_address && ($htlAddress = HotelBranchInformation::getAddress(Configuration::get('WK_PRIMARY_HOTEL')))) { + $id_address = $htlAddress['id_address']; } } } @@ -4022,6 +4087,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), @@ -4066,6 +4148,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 884d46ec7..88ab1dfa3 100644 --- a/classes/PaymentModule.php +++ b/classes/PaymentModule.php @@ -357,6 +357,13 @@ abstract class PaymentModuleCore extends Module $order->source = Configuration::get('PS_SHOP_DOMAIN'); } + // Booking Source (business categorization, e.g. Direct Website/Walk-in/OTA) — + if (isset($this->idBookingSource) && $this->idBookingSource) { + $order->id_source = (int)$this->idBookingSource; + } else { + $order->id_source = (int)Source::getDefaultSourceId(); + } + if (self::DEBUG_MODE) { PrestaShopLogger::addLog('PaymentModule::validateOrder - Order is about to be added', 1, null, 'Cart', (int)$id_cart, true); } @@ -510,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 */ @@ -917,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; @@ -961,7 +997,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_WITH_HOTEL == $product['selling_preference_type']) { @@ -994,7 +1053,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 + ); + } + } } } @@ -1022,7 +1104,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 + ); + } + } } } @@ -1079,7 +1184,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 + ); + } + } } } } @@ -1215,7 +1345,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); @@ -1241,6 +1372,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 297efa256..f683b0a67 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'), @@ -5434,6 +5438,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. * @@ -6759,21 +6788,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, @@ -6783,8 +6815,8 @@ class ProductCore extends ObjectModel true, null, true, - (int)$idHotel, - (int)$idProductRoomType, + $idHotel, + $idProductRoomType, $idGroup, $idCartBooking ); @@ -6812,7 +6844,48 @@ class ProductCore extends ObjectModel ); $price = $price * $numdays; - $price = $price * (int)$quantity; + $price = $price * $quantity; + + $tourismTaxHotelId = $idHotel; + if (!$tourismTaxHotelId && $idProductRoomType) { + $objRoomType = new HotelRoomType(); + if ($roomTypeInfo = $objRoomType->getRoomTypeInfoByIdProduct($idProductRoomType)) { + $tourismTaxHotelId = $roomTypeInfo['id_hotel']; + } + } + + 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 + ); + } if (!$specificPrice || ($specificPrice['id_cart'] == 0 && $specificPrice['id_htl_cart_booking'] == 0)) { if (!$idGroup || !Validate::isLoadedObject(new Group((int)$idGroup))) { @@ -6821,7 +6894,6 @@ class ProductCore extends ObjectModel $price = Product::applyGroupDiscount($price, $idProduct, $idGroup); } return $price; - } diff --git a/classes/Source.php b/classes/Source.php new file mode 100644 index 000000000..4f453e96b --- /dev/null +++ b/classes/Source.php @@ -0,0 +1,241 @@ + 'source', + 'primary' => 'id_source', + 'multilang' => true, + 'fields' => array( + 'id_business_source' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'code' => array('type' => self::TYPE_STRING, 'validate' => 'isModuleName', 'required' => true, 'size' => 64), + 'position' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'unremovable' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + '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, 'lang' => true, 'validate' => 'isGenericName', 'required' => true, 'size' => 64), + ), + ); + + public function add($autodate = true, $nullValues = false) + { + if ($this->position <= 0) { + $this->position = Source::getHigherPosition($this->id_business_source) + 1; + } + + return parent::add($autodate, $nullValues); + } + + public static function getHigherPosition($idBusinessSource) + { + $position = Db::getInstance()->getValue( + 'SELECT MAX(`position`) FROM `'._DB_PREFIX_.'source` + WHERE `id_business_source` = '.(int)$idBusinessSource.' AND `deleted` = 0' + ); + + return is_numeric($position) ? (int)$position : -1; + } + + public function updatePosition($way, $position) + { + if (!$res = Db::getInstance()->executeS(' + SELECT `position`, `id_source` + FROM `'._DB_PREFIX_.'source` + WHERE `id_business_source` = '.(int)$this->id_business_source.' AND `deleted` = 0 + ORDER BY `position` ASC' + )) { + return false; + } + + $moved_item = null; + foreach ($res as $row) { + if ((int)$row['id_source'] == (int)$this->id) { + $moved_item = $row; + } + } + + if (!$moved_item) { + return false; + } + + return (Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'source` + SET `position` = `position` '.($way ? '- 1' : '+ 1').' + WHERE `id_business_source` = '.(int)$this->id_business_source.' AND `deleted` = 0 AND `position` + '.($way + ? '> '.(int)$moved_item['position'].' AND `position` <= '.(int)$position + : '< '.(int)$moved_item['position'].' AND `position` >= '.(int)$position) + ) && Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'source` + SET `position` = '.(int)$position.' + WHERE `id_source` = '.(int)$moved_item['id_source'])); + } + + public static function codeExists($code, $idExclude = 0) + { + return (bool)Db::getInstance()->getValue( + 'SELECT `id_source` FROM `'._DB_PREFIX_.'source` + WHERE `code` = \''.pSQL($code).'\' AND `id_source` != '.(int)$idExclude + ); + } + + public static function getIdByCode($code) + { + return (int)Db::getInstance()->getValue( + 'SELECT `id_source` FROM `'._DB_PREFIX_.'source` + WHERE `code` = \''.pSQL($code).'\' AND `deleted` = 0' + ); + } + + public static function getUnremovableIds() + { + $rows = Db::getInstance()->executeS( + 'SELECT `id_source` FROM `'._DB_PREFIX_.'source` WHERE `unremovable` = 1 AND `deleted` = 0' + ); + + return array_map('intval', array_column($rows, 'id_source')); + } + + public static function getActiveSource($idBusinessSource, $idSource, $idLang) + { + return Db::getInstance()->executeS(' + SELECT a.`id_source`, al.`name` + FROM `'._DB_PREFIX_.'source` a + LEFT JOIN `'._DB_PREFIX_.'source_lang` al ON (al.`id_source` = a.`id_source` AND al.`id_lang` = '.(int)$idLang.') + WHERE a.`id_business_source` = '.(int)$idBusinessSource.' AND a.`deleted` = 0 AND a.`id_source` NOT IN ('.(int)$idSource.') AND a.`active` = 1 + ORDER BY a.`position` ASC + '); + } + + public static function getAllSource($active = null, $idLang = null) + { + $idLang = $idLang ?: (int)Context::getContext()->language->id; + + $where = ' WHERE a.`deleted` = 0'; + if ($active !== null) { + $where .= ' AND a.`active` = '.(int)(bool)$active; + } + + return Db::getInstance()->executeS(' + SELECT a.`id_source`, a.`id_business_source`, a.`code`, al.`name`, bsl.`name` AS `business_source_name` + FROM `'._DB_PREFIX_.'source` a + LEFT JOIN `'._DB_PREFIX_.'source_lang` al ON (al.`id_source` = a.`id_source` AND al.`id_lang` = '.(int)$idLang.') + LEFT JOIN `'._DB_PREFIX_.'business_source_lang` bsl ON (bsl.`id_business_source` = a.`id_business_source` AND bsl.`id_lang` = '.(int)$idLang.') + '.$where.' + ORDER BY a.`id_business_source` ASC, a.`position` ASC + '); + } + + public static function getSourcesUsedInOrders($idLang) + { + return Db::getInstance()->executeS(' + SELECT DISTINCT o.`id_source`, sl.`name` + FROM `'._DB_PREFIX_.'orders` o + INNER JOIN `'._DB_PREFIX_.'source` s ON (s.`id_source` = o.`id_source`) + LEFT JOIN `'._DB_PREFIX_.'source_lang` sl ON (sl.`id_source` = s.`id_source` AND sl.`id_lang` = '.(int)$idLang.') + '); + } + + public static function cleanPositions($idBusinessSource) + { + $return = true; + + $result = Db::getInstance()->executeS(' + SELECT `id_source` + FROM `'._DB_PREFIX_.'source` + WHERE `id_business_source` = '.(int)$idBusinessSource.' AND `deleted` = 0 + ORDER BY `position` ASC' + ); + + $i = 0; + foreach ($result as $row) { + $return = Db::getInstance()->execute(' + UPDATE `'._DB_PREFIX_.'source` + SET `position` = '.(int)$i++.' + WHERE `id_source` = '.(int)$row['id_source'] + ) && $return; + } + + return $return; + } + + public function isRemovable() + { + return !$this->unremovable; + } + + public function isUsedByOrder() + { + return (bool)Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `'._DB_PREFIX_.'orders` WHERE `id_source` = '.(int)$this->id + ); + } + + public function mergeOrdersInto($idTargetSource) + { + return (bool)Db::getInstance()->execute( + 'UPDATE `'._DB_PREFIX_.'orders` SET `id_source` = '.(int)$idTargetSource.' WHERE `id_source` = '.(int)$this->id + ); + } + + public static function getTotalOrderCount() + { + return (int)Db::getInstance()->getValue('SELECT COUNT(*) FROM `'._DB_PREFIX_.'orders`'); + } + + public function delete() + { + if (!$this->isRemovable()) { + return false; + } + + if ($this->isUsedByOrder()) { + $this->deleted = 1; + if (!$this->update()) { + return false; + } + } elseif (!parent::delete()) { + return false; + } + + return Source::cleanPositions($this->id_business_source); + } + + public static function getDefaultSourceId() + { + return (int)Configuration::get('PS_DEFAULT_BOOKING_SOURCE'); + } +} diff --git a/classes/helper/HelperKpiRow.php b/classes/helper/HelperKpiRow.php index f47022bf7..b10bce643 100644 --- a/classes/helper/HelperKpiRow.php +++ b/classes/helper/HelperKpiRow.php @@ -44,7 +44,7 @@ class HelperKpiRowCore extends Helper if (isset($this->context->cookie->$cookieKey)) { $kpi->visible = (bool) $this->context->cookie->$cookieKey; } else { - $kpi->visible = true; + $kpi->visible = isset($kpi->visible) ? (bool) $kpi->visible : true; } $countVisible = $kpi->visible ? ++$countVisible : $countVisible; diff --git a/classes/order/Order.php b/classes/order/Order.php index 4c80c0b30..5dd2a9453 100644 --- a/classes/order/Order.php +++ b/classes/order/Order.php @@ -272,6 +272,7 @@ class OrderCore extends ObjectModel 'invoice_date' => array('type' => self::TYPE_DATE), 'delivery_date' => array('type' => self::TYPE_DATE), 'source' => array('type' => self::TYPE_STRING), + 'id_source' => array('type' => self::TYPE_INT), 'valid' => array('type' => self::TYPE_BOOL), 'reference' => array('type' => self::TYPE_STRING), 'is_advance_payment' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'default' => 0), @@ -1163,15 +1164,24 @@ class OrderCore extends ObjectModel $product_auto_add = null, $product_price_addition_type = null, $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 +1832,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 +2164,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 +2495,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 a5d0c2176..683cc7dfd 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,25 +461,31 @@ class OrderDetailCore extends ObjectModel 0, $taxGroupInfo['id_room_type'], )) { - $serviceProductData = array_shift($serviceProductData); - $numDays = Product::getServicePriceBillableDays( - $this->product_price_calculation_method, - $serviceProductData['date_from'], - $serviceProductData['date_to'] - ); + $quantity = 0; + $totalPriceTaxExcl = 0.0; + $firstServiceProduct = null; + foreach ($serviceProductData as $bookingServiceData) { + $bookingNumDays = Product::getServicePriceBillableDays( + $this->product_price_calculation_method, + $bookingServiceData['date_from'], + $bookingServiceData['date_to'] + ); + 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(), @@ -476,21 +494,22 @@ class OrderDetailCore extends ObjectModel $taxGroupInfo['id_room_type'], $this->product_id )) { + // 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 = Product::getServicePriceBillableDays( + $this->product_price_calculation_method, + $cartServiceItem['date_from'], + $cartServiceItem['date_to'] + ); + $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 = 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 = Product::getServicePriceBillableDays( - $this->product_price_calculation_method, - $serviceProductData['date_from'], - $serviceProductData['date_to'] - ); - - $quantity = $quantity * $numDays; + $unit_price_tax_excl = $quantity > 0 ? ($totalPriceTaxExcl / $quantity) : 0; } if ($this->tax_calculator == null) { @@ -510,7 +529,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()),'; } } } @@ -544,7 +563,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()),'; } } @@ -552,10 +571,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); @@ -571,7 +595,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; } /** @@ -591,9 +618,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; } /* @@ -727,6 +770,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; @@ -894,10 +942,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 562799d61..29f66f590 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'); } @@ -797,6 +820,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 0fb2fa35d..a05a632b2 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 5abc7ee88..aae120d8a 100644 --- a/classes/pdf/HTMLTemplateInvoice.php +++ b/classes/pdf/HTMLTemplateInvoice.php @@ -358,7 +358,31 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate $order_bk_data = $obj_htl_bk_dtl->getOnlyOrderBookingData($this->order->id, 0, $type_value['product_id'], $idCustomer, $type_value['id_order_detail']); $cart_htl_data[$type_key]['id_product'] = $type_value['product_id']; foreach ($order_bk_data as $data_k => $data_v) { - $date_join = strtotime($data_v['date_from']).strtotime($data_v['date_to']); + $display_date_from = $data_v['date_from']; + $display_date_to = $data_v['date_to']; + if ($data_v['id_status'] === HotelBookingDetail::STATUS_CHECKED_OUT) { + if ($data_v['check_in']) { + $display_date_from = $data_v['check_in']; + } + if ($data_v['check_out']) { + $display_date_to = $data_v['check_out']; + } + } elseif ($data_v['id_status'] === HotelBookingDetail::STATUS_CHECKED_IN) { + if ($data_v['check_in']) { + $display_date_from = $data_v['check_in']; + } + if ($data_v['check_out_time']) { + $display_date_to = date('Y-m-d', strtotime($data_v['date_to'])) . ' ' . date('H:i:s', strtotime($data_v['check_out_time'])); + } + } else { + if ($data_v['check_in_time']) { + $display_date_from = date('Y-m-d', strtotime($data_v['date_from'])) . ' ' . date('H:i:s', strtotime($data_v['check_in_time'])); + } + if ($data_v['check_out_time']) { + $display_date_to = date('Y-m-d', strtotime($data_v['date_to'])) . ' ' . date('H:i:s', strtotime($data_v['check_out_time'])); + } + } + $date_join = strtotime($data_v['date_from']).strtotime($data_v['date_to']).strtotime($display_date_from).strtotime($display_date_to); /*Product price when order was created*/ $order_details_obj = new OrderDetail($data_v['id_order_detail']); @@ -445,31 +469,6 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate $num_days = HotelHelper::getNumberOfDays($data_v['date_from'], $data_v['date_to']); $cart_htl_data[$type_key]['date_diff'][$date_join]['num_rm'] = 1; - $fullDate = (isset($context->controller->show_full_date) && $context->controller->show_full_date && (date('Y-m-d', strtotime($data_v['date_from'])) == date('Y-m-d', strtotime($data_v['date_to'])))) ? true : false; - $display_date_from = $data_v['date_from']; - $display_date_to = $data_v['date_to']; - if ((int)$data_v['id_status'] === HotelBookingDetail::STATUS_CHECKED_OUT) { - if ($data_v['check_in']) { - $display_date_from = $data_v['check_in']; - } - if ($data_v['check_out']) { - $display_date_to = $data_v['check_out']; - } - } elseif ((int)$data_v['id_status'] === HotelBookingDetail::STATUS_CHECKED_IN) { - if ($data_v['check_in']) { - $display_date_from = $data_v['check_in']; - } - if ($data_v['check_out_time']) { - $display_date_to = date('Y-m-d', strtotime($data_v['date_to'])) . ' ' . date('H:i:s', strtotime($data_v['check_out_time'])); - } - } else { - if ($data_v['check_in_time']) { - $display_date_from = date('Y-m-d', strtotime($data_v['date_from'])) . ' ' . date('H:i:s', strtotime($data_v['check_in_time'])); - } - if ($data_v['check_out_time']) { - $display_date_to = date('Y-m-d', strtotime($data_v['date_to'])) . ' ' . date('H:i:s', strtotime($data_v['check_out_time'])); - } - } $cart_htl_data[$type_key]['date_diff'][$date_join]['data_form'] = Tools::displayDate($display_date_from, null, true); $cart_htl_data[$type_key]['date_diff'][$date_join]['data_to'] = Tools::displayDate($display_date_to, null, true); $cart_htl_data[$type_key]['date_diff'][$date_join]['num_days'] = $num_days; @@ -610,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; } @@ -733,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 b0f24bf22..c9856be17 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/WebserviceOrder.php b/classes/webservice/WebserviceOrder.php index 9fedace95..ac47fb3fb 100644 --- a/classes/webservice/WebserviceOrder.php +++ b/classes/webservice/WebserviceOrder.php @@ -31,5 +31,6 @@ class WebserviceOrderCore extends PaymentModule $this->displayName = $this->l('Order from API'); $this->validateOrderAmount = false; $this->payment_type = OrderPayment::PAYMENT_TYPE_ONLINE; + $this->idBookingSource = Source::getIdByCode('API_ORDER'); } } diff --git a/classes/webservice/WebserviceSpecificManagementBookings.php b/classes/webservice/WebserviceSpecificManagementBookings.php index 24bb74a9b..5347d911a 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); @@ -1210,7 +1219,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; @@ -1239,7 +1252,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); } } } @@ -2247,7 +2260,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; } @@ -2306,7 +2323,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); } } @@ -3182,7 +3199,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_); } @@ -3235,7 +3256,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); } } } @@ -3286,7 +3307,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 9ea5c906e..69010afe9 100644 --- a/controllers/admin/AdminNormalProductsController.php +++ b/controllers/admin/AdminNormalProductsController.php @@ -613,6 +613,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) { @@ -624,6 +630,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; + } } } @@ -3351,6 +3382,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 c7b362e3e..48e38c75e 100644 --- a/controllers/admin/AdminOrdersController.php +++ b/controllers/admin/AdminOrdersController.php @@ -33,6 +33,7 @@ class AdminOrdersControllerCore extends AdminController protected $statuses_array = array(); protected $all_order_sources = array(); + protected $bookingSourcesArray = array(); protected $hotelsArray = array(); protected $roomTypesArray = array(); protected $roomsArray = array(); @@ -75,6 +76,10 @@ class AdminOrdersControllerCore extends AdminController (SELECT GROUP_CONCAT(CONCAT(ps.period, \'~\', ps.cnt) ORDER BY ps.period SEPARATOR \'::\') FROM (SELECT CONCAT(hbd.`date_from`, \'|\', hbd.`date_to`) AS period, COUNT(*) AS cnt FROM `'._DB_PREFIX_.'htl_booking_detail` hbd WHERE hbd.`id_order` = a.`id_order` GROUP BY hbd.`date_from`, hbd.`date_to`) AS ps) AS stay_periods, (SELECT COUNT(spod.`id_service_product_order_detail`) FROM `'._DB_PREFIX_.'service_product_order_detail` spod WHERE spod.`id_order` = a.`id_order` AND spod.`id_htl_booking_detail`=0) as num_products'; + $this->_select .= ', + (SELECT sl.`name` FROM `'._DB_PREFIX_.'source` s LEFT JOIN `'._DB_PREFIX_.'source_lang` sl ON (sl.`id_source` = s.`id_source` AND sl.`id_lang` = '.(int)$this->context->language->id.') WHERE s.`id_source` = a.`id_source`) AS `booking_source_name`, + (SELECT s.`code` FROM `'._DB_PREFIX_.'source` s WHERE s.`id_source` = a.`id_source`) AS `booking_source_code`'; + $this->_join = ' LEFT JOIN `'._DB_PREFIX_.'customer` c ON (c.`id_customer` = a.`id_customer`) LEFT JOIN `'._DB_PREFIX_.'currency` cu ON (cu.`id_currency` = a.`id_currency`) @@ -114,6 +119,9 @@ class AdminOrdersControllerCore extends AdminController foreach ($all_order_sources as $source) { $this->all_order_sources[$source['source']] = $source['source']; } + foreach (Source::getSourcesUsedInOrders((int)$this->context->language->id) as $bookingSource) { + $this->bookingSourcesArray[$bookingSource['id_source']] = $bookingSource['name']; + } $hotelsArray = HotelBranchInformation::getProfileAccessedHotels($this->context->employee->id_profile, 1); foreach ($hotelsArray as $hotel) { @@ -280,6 +288,15 @@ class AdminOrdersControllerCore extends AdminController 'filter_key' => 'a!source', 'list' => $this->all_order_sources, 'optional' => true, + ), + 'booking_source_name' => array( + 'title' => $this->l('Booking Source'), + 'type' => 'select', + 'filter_key' => 'a!id_source', + 'list' => $this->bookingSourcesArray, + 'orderby' => false, + 'callback' => 'formatBookingSource', + 'optional' => true, 'visible_default' => true ), 'osname' => array( @@ -369,6 +386,15 @@ class AdminOrdersControllerCore extends AdminController return Tools::displayPrice($echo, (int)$idCurrency); } + public function formatBookingSource($echo, $row) + { + if (!$echo) { + return '--'; + } + + return Tools::safeOutput($echo); + } + public function formatStayPeriods($value, $row) { if (empty($value)) { @@ -484,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 @@ -567,6 +594,7 @@ class AdminOrdersControllerCore extends AdminController 'max_child_in_room' => Configuration::get('WK_GLOBAL_MAX_CHILD_IN_ROOM'), 'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'), 'occupancy_required_for_booking' => $occupancyRequiredForBooking, + 'booking_sources' => Source::getAllSource(true, (int)$this->context->language->id), )); } else { @@ -957,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( @@ -2209,6 +2238,14 @@ class AdminOrdersControllerCore extends AdminController $advancePaymentAmount = $objCart->getOrderTotal(true, Cart::ADVANCE_PAYMENT); } + $idBookingSource = (int)Tools::getValue('id_booking_source'); + if ($idBookingSource) { + $objBookingSource = new Source($idBookingSource); + if (!Validate::isLoadedObject($objBookingSource) || !$objBookingSource->active) { + $this->errors[] = Tools::displayError('Please select a valid Booking Source.'); + } + } + // Validate data if required if ($orderTotal > 0) { $moduleName = trim(Tools::getValue('payment_module_name')); @@ -2229,15 +2266,13 @@ class AdminOrdersControllerCore extends AdminController $paymentAmount = $orderTotal; } - if ($paymentAmount >= 0) { + if ($paymentAmount > 0) { if (!$moduleName) { $this->errors[] = Tools::displayError('Please enter Payment method.'); } elseif ($moduleName && !Validate::isGenericName($moduleName)) { $this->errors[] = Tools::displayError('Payment method is invalid. Please enter a valid payment method.'); } - } - if ($paymentAmount > 0) { if (!$paymentType) { $this->errors[] = Tools::displayError('Please select a Payment source.'); } elseif ($paymentType && !Validate::isUnsignedInt($paymentType)) { @@ -2254,10 +2289,14 @@ class AdminOrdersControllerCore extends AdminController // Set payment module details $objPaymentModule = new BoOrder(); - $objPaymentModule->displayName = $moduleName; + if ($moduleName) { + $objPaymentModule->displayName = $moduleName; + } if ($orderTotal > 0) { - $objPaymentModule->payment_type = $paymentType; + if ($paymentType) { + $objPaymentModule->payment_type = $paymentType; + } // Set order state if ($isFullPayment) { @@ -2297,6 +2336,10 @@ class AdminOrdersControllerCore extends AdminController $extraVars = null; } + if ($idBookingSource) { + $objPaymentModule->idBookingSource = $idBookingSource; + } + $amountPaid = Tools::ps_round($amountPaid, 6); $objEmployee = new Employee($this->context->cookie->id_employee); $sendMails = Tools::getValue('send_mails', true); @@ -2640,6 +2683,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); @@ -2808,6 +2855,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 @@ -2994,12 +3046,25 @@ class AdminOrdersControllerCore extends AdminController $helper->id = 'box-order-source'; $helper->icon = 'icon-globe'; $helper->color = 'color1'; + $helper->visible = false; $helper->title = $this->l('Order Source'); $helper->tooltip = $this->l('Order source shows from which source the order was placed.'); $helper->subtitle = $orderHistory[0]['id_employee'] ? $this->l('Back office') : $this->l('Front office'); $helper->value = $objOrder->source; $this->kpis[] = $helper; + $objBookingSource = new Source((int)$objOrder->id_source, $this->context->language->id); + if (Validate::isLoadedObject($objBookingSource)) { + $helper = new HelperKpi(); + $helper->id = 'box-booking-source'; + $helper->icon = 'icon-tag'; + $helper->color = 'color2'; + $helper->title = $this->l('Booking Source'); + $helper->tooltip = $this->l('Booking source shows the source from which this booking originated, e.g. Walk-in, Website, OTA.'); + $helper->value = $objBookingSource->name; + $this->kpis[] = $helper; + } + $objCustomerThread = new CustomerThread(); $idCustomerThread = $objCustomerThread->getIdCustomerThreadByIdOrder($objOrder->id); $helper = new HelperKpi(); @@ -3331,6 +3396,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 @@ -3379,12 +3447,38 @@ class AdminOrdersControllerCore extends AdminController if ($product['selling_preference_type'] == Product::SELLING_PREFERENCE_WITH_HOTEL) { $hotelProducts = $objServiceProductOrderDetail->getServiceProductsInOrder($order->id, $product['id_order_detail'], $product['product_id'], Product::SELLING_PREFERENCE_WITH_HOTEL); 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_WITH_STANDALONE) { $standaloneProducts = $objServiceProductOrderDetail->getServiceProductsInOrder($order->id, $product['id_order_detail'], $product['product_id'], Product::SELLING_PREFERENCE_WITH_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); } } @@ -3416,11 +3510,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, @@ -3554,7 +3653,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; @@ -3562,6 +3661,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); @@ -3578,6 +3683,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, @@ -3706,6 +3845,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, @@ -4650,6 +4800,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; @@ -5230,6 +5381,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; @@ -5371,6 +5523,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; @@ -5445,6 +5598,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(); @@ -5521,13 +5724,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); @@ -5789,6 +6031,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; @@ -5837,9 +6080,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; @@ -6078,9 +6355,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(); @@ -6202,6 +6476,7 @@ class AdminOrdersControllerCore extends AdminController $new_total_price, $obj_booking_detail->id )) { + $res &= $order_detail->updateTaxAmount($order); if (isset($orderServiceProducts) && is_array($orderServiceProducts) @@ -6260,12 +6535,10 @@ 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; $order_invoice->total_paid_tax_incl -= $objServiceProductOrderDetail->total_price_tax_incl; - $order_invoice->total_paid_tax_excl += (float)$newTotalPriceTaxExcl; $order_invoice->total_paid_tax_incl += (float)$newTotalPriceTaxIncl; } @@ -6274,15 +6547,66 @@ 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)); @@ -6404,15 +6728,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; @@ -6420,6 +6740,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; @@ -6439,6 +6788,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 +7049,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*/ @@ -6755,6 +7110,10 @@ class AdminOrdersControllerCore extends AdminController } } + 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, @@ -6847,6 +7206,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) { @@ -7600,6 +7963,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); @@ -7619,6 +8005,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(); + } } } @@ -7875,6 +8265,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_); @@ -7888,6 +8300,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 @@ -8177,6 +8590,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_); @@ -8190,6 +8625,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 @@ -8303,6 +8739,8 @@ class AdminOrdersControllerCore extends AdminController } if ($res &= $objServiceProductOrderDetail->delete()) { + OrderTaxDetail::hardDeleteForBooking(0, array((int) $objServiceProductOrderDetail->id)); + $order = new Order($objServiceProductOrderDetail->id_order); $numDays = Product::getServicePriceBillableDays( $objOrderDetail->product_price_calculation_method, @@ -8352,6 +8790,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( @@ -8586,4 +9028,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 3d46ec20e..bc40e9e52 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 '; @@ -648,7 +648,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'; @@ -683,24 +683,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 + ); + } } } @@ -3269,7 +3297,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']; @@ -3279,7 +3307,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; } @@ -3293,7 +3321,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; } @@ -4028,6 +4056,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/AdminSourcesController.php b/controllers/admin/AdminSourcesController.php new file mode 100644 index 000000000..c51d5dd36 --- /dev/null +++ b/controllers/admin/AdminSourcesController.php @@ -0,0 +1,850 @@ +bootstrap = true; + $this->table = 'business_source'; + $this->className = 'BusinessSource'; + $this->identifier = 'id_business_source'; + $this->list_id = 'business_source'; + $this->lang = true; + $this->deleted = false; + $this->_defaultOrderBy = 'position'; + $this->_defaultOrderWay = 'ASC'; + $this->context = Context::getContext(); + $this->_new_list_header_design = true; + $this->bulk_actions = array( + 'delete' => array( + 'text' => $this->l('Delete selected'), + 'icon' => 'icon-trash', + 'confirm' => $this->l('Delete selected items?') + ) + ); + + parent::__construct(); + } + + public function setMedia() + { + parent::setMedia(); + + $this->addJS(_PS_JS_DIR_.'admin/sources.js'); + } + + public function init() + { + if (Tools::isSubmit('addsource') || Tools::isSubmit('addbusiness_source')) { + $this->display = 'add'; + } + if (Tools::isSubmit('updatesource') || Tools::isSubmit('updatebusiness_source')) { + $this->display = 'edit'; + } + if (Tools::isSubmit('submitAddsource') || Tools::isSubmit('submitAddsourceAndStay')) { + $this->display = Tools::getValue('id_source') ? 'edit' : 'add'; + } + if (Tools::isSubmit('submitAddbusiness_source') || Tools::isSubmit('submitAddbusiness_sourceAndStay')) { + $this->display = Tools::getValue('id_business_source') ? 'edit' : 'add'; + } + + return parent::init(); + } + + public function initContent() + { + if (Tools::isSubmit('submitFiltersource') || Tools::isSubmit('submitResetsource')) { + $this->list_id = 'source'; + $this->display = 'view'; + + $idBusinessSource = (int) Tools::getValue('id_business_source'); + self::$currentIndex .= '&id_business_source=' . $idBusinessSource . '&viewbusiness_source'; + if (Tools::isSubmit('submitResetsource')) { + $this->processResetFilters('source'); + } + } + parent::initContent(); + } + + /** + * @see AdminController::initPageHeaderToolbar() + */ + public function initPageHeaderToolbar() + { + if (empty($this->display)) { + $this->page_header_toolbar_btn['new_business_source'] = array( + 'href' => self::$currentIndex.'&addbusiness_source&token='.$this->token, + 'desc' => $this->l('Add new Business Source', null, null, false), + 'icon' => 'process-icon-new' + ); + } elseif ($this->display == 'view') { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + if ($idBusinessSource) { + $this->page_header_toolbar_btn['new_source'] = array( + 'href' => self::$currentIndex.'&addsource&id_business_source='.$idBusinessSource.'&token='.$this->token, + 'desc' => $this->l('Add new Booking Source', null, null, false), + 'icon' => 'process-icon-new' + ); + $this->page_header_toolbar_btn['back_to_business'] = array( + 'href' => self::$currentIndex.'&token='.$this->token, + 'desc' => $this->l('Back To Business Source', null, null, false), + 'icon' => 'process-icon-back' + ); + } + } + + unset($this->toolbar_btn['back']); + parent::initPageHeaderToolbar(); + } + + + + protected function initBusinessSourcesList() + { + $this->fields_list = array( + 'id_business_source' => array( + 'title' => $this->l('ID'), + 'align' => 'text-center', + 'class' => 'fixed-width-xs' + ), + 'name' => array( + 'title' => $this->l('Name'), + 'width' => 'auto', + 'filter_key' => 'b!name' + ), + 'code' => array( + 'title' => $this->l('Code'), + 'align' => 'text-center' + ), + 'position' => array( + 'title' => $this->l('Position'), + 'filter_key' => 'a!position', + 'align' => 'text-center', + 'class' => 'fixed-width-xs', + 'position' => 'position' + ), + 'orders_count' => array( + 'title' => $this->l('Orders (%)'), + 'align' => 'text-center', + 'orderby' => false, + 'search' => false, + 'callback' => 'formatOrdersPercentage', + ), + 'active' => array( + 'title' => $this->l('Enabled'), + 'active' => 'status', + 'type' => 'bool', + 'align' => 'text-center', + 'class' => 'fixed-width-xs', + 'orderby' => false + ), + ); + } + + protected function initSourcesList() + { + $this->fields_list = array( + 'id_source' => array( + 'title' => $this->l('ID'), + 'align' => 'text-center', + 'class' => 'fixed-width-xs' + ), + 'name' => array( + 'title' => $this->l('Booking Source'), + 'width' => 'auto', + 'filter_key' => 'b!name' + ), + 'source_type_name' => array( + 'title' => $this->l('Business Source'), + 'orderby' => false, + 'search' => false, + ), + 'code' => array( + 'title' => $this->l('Code'), + 'align' => 'text-center' + ), + 'position' => array( + 'title' => $this->l('Position'), + 'filter_key' => 'a!position', + 'align' => 'text-center', + 'class' => 'fixed-width-xs', + 'position' => 'position' + ), + 'orders_count' => array( + 'title' => $this->l('Orders (%)'), + 'align' => 'text-center', + 'orderby' => false, + 'search' => false, + 'callback' => 'formatOrdersPercentage', + ), + 'active' => array( + 'title' => $this->l('Enabled'), + 'active' => 'status', + 'type' => 'bool', + 'align' => 'text-center', + 'class' => 'fixed-width-xs', + 'orderby' => false + ), + ); + } + + public function renderList() + { + $this->addRowAction('view'); + $this->addRowAction('edit'); + $this->addRowAction('delete'); + + $this->addRowActionSkipList('delete', BusinessSource::getUnremovableIds()); + $this->_where = 'AND a.`deleted` = 0'; + $this->_select = '(SELECT COUNT(*) FROM `'._DB_PREFIX_.'orders` o + INNER JOIN `'._DB_PREFIX_.'source` s ON s.`id_source` = o.`id_source` + WHERE s.`id_business_source` = a.`id_business_source`) AS `orders_count`'; + $this->ordersTotal = Source::getTotalOrderCount(); + $this->initBusinessSourcesList(); + $this->toolbar_title = $this->l('Business Source'); + + return parent::renderList(); + } + + public function renderView() + { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + $objBusinessSource = new BusinessSource($idBusinessSource, (int)$this->context->language->id); + if (!Validate::isLoadedObject($objBusinessSource)) { + Tools::redirectAdmin(self::$currentIndex.'&token='.$this->token); + } + $this->viewBusinessSource = $objBusinessSource; + + $this->addRowAction('edit'); + $this->addRowAction('merge'); + $this->addRowAction('delete'); + $this->addRowActionSkipList('delete', Source::getUnremovableIds()); + + $this->table = 'source'; + $this->className = 'Source'; + $this->identifier = 'id_source'; + $this->list_id = 'source'; + $this->_select = 'bsl.`name` AS `source_type_name`, + (SELECT COUNT(*) FROM `'._DB_PREFIX_.'orders` o WHERE o.`id_source` = a.`id_source`) AS `orders_count`'; + $this->ordersTotal = $objBusinessSource->getOrderCount(); + $this->_join = 'LEFT JOIN `'._DB_PREFIX_.'business_source` bs ON (bs.`id_business_source` = a.`id_business_source`)'; + $this->_join .= 'LEFT JOIN `'._DB_PREFIX_.'business_source_lang` bsl ON (bsl.`id_business_source` = bs.`id_business_source` AND bsl.`id_lang` = '.(int)$this->context->language->id.')'; + $this->_where = 'AND a.`deleted` = 0 AND a.`id_business_source` = '.(int)$objBusinessSource->id; + $this->initSourcesList(); + + $this->processFilter(); + $list = parent::renderList(); + + $this->page_header_toolbar_title = $this->l('Business Source:').' '.$objBusinessSource->name; + $this->toolbar_title = $this->getSourcesListTitle(); + + return $list; + } + + protected function getSourcesListTitle() + { + if (!Validate::isLoadedObject($this->viewBusinessSource)) { + return $this->l('Booking Sources'); + } + + return $this->viewBusinessSource->name.' '.$this->_listTotal.''; + } + + public function getTemplateListVars() + { + $vars = parent::getTemplateListVars(); + + if ($this->display == 'view' && $this->table == 'source') { + $vars['title'] = $this->getSourcesListTitle(); + } + + return $vars; + } + + public function renderForm() + { + if (Tools::isSubmit('updatesource') + || Tools::isSubmit('addsource') + || Tools::isSubmit('submitAddsource') + || Tools::isSubmit('submitAddsourceAndStay') + ) { + return $this->renderSourceForm(); + } + + return $this->renderBusinessSourceForm(); + } + + protected function renderBusinessSourceForm() + { + if (!($objBusinessSource = $this->loadObject(true))) { + return; + } + + $this->fields_form = array( + 'legend' => array( + 'title' => $this->l('Business Source'), + 'icon' => 'icon-edit' + ), + 'input' => array( + array( + 'type' => 'text', + 'label' => $this->l('Name'), + 'name' => 'name', + 'lang' => true, + 'required' => true, + 'hint' => $this->l('Invalid characters:').' <>;=#{}' + ), + array( + 'type' => 'text', + 'label' => $this->l('Code'), + 'name' => 'code', + 'required' => true, + 'class' => 'fixed-width-xl', + 'readonly' => (bool)$objBusinessSource->id, + 'hint' => $objBusinessSource->id + ? $this->l('The code cannot be changed after creation.') + : $this->l('A short unique machine code, e.g. "OTA". Letters, numbers, underscores and hyphens only.') + ), + array( + 'type' => 'switch', + 'label' => $this->l('Enabled'), + 'name' => 'active', + 'required' => false, + 'class' => 't', + 'is_bool' => true, + 'default_value' => 1, + 'values' => array( + array('id' => 'active_on', 'value' => 1, 'label' => $this->l('Enabled')), + array('id' => 'active_off', 'value' => 0, 'label' => $this->l('Disabled')), + ) + ), + ), + 'submit' => array( + 'title' => $this->l('Save'), + 'name' => 'submitAddbusiness_source' + ), + 'buttons' => array( + 'save-and-stay' => array( + 'title' => $this->l('Save and stay'), + 'name' => 'submitAddbusiness_sourceAndStay', + 'type' => 'submit', + 'class' => 'btn btn-default pull-right', + 'icon' => 'process-icon-save', + ), + ), + ); + + return parent::renderForm(); + } + + protected function renderSourceForm() + { + $this->table = 'source'; + $this->className = 'Source'; + $this->identifier = 'id_source'; + $this->object = null; + + if (!($objSource = $this->loadObject(true))) { + return; + } + + $businessSources = BusinessSource::getActiveList((int)$this->context->language->id); + $isUnremovableSource = $objSource->id && in_array($objSource->id, Source::getUnremovableIds()); + + $this->fields_form = array( + 'legend' => array( + 'title' => $this->l('Booking Source'), + 'icon' => 'icon-edit' + ), + 'input' => array( + array( + 'type' => 'text', + 'label' => $this->l('Name'), + 'name' => 'name', + 'lang' => true, + 'required' => true, + 'hint' => $this->l('Invalid characters:').' <>;=#{}' + ), + array( + 'type' => 'select', + 'label' => $this->l('Business Source'), + 'name' => 'id_business_source', + 'required' => true, + 'options' => array( + 'query' => $businessSources, + 'id' => 'id_business_source', + 'name' => 'name', + ), + ), + array( + 'type' => 'text', + 'label' => $this->l('Source Code'), + 'name' => 'code', + 'required' => true, + 'class' => 'fixed-width-xl', + 'readonly' => (bool)$objSource->id, + 'hint' => $objSource->id + ? $this->l('The code cannot be changed after creation.') + : $this->l('A short unique machine code, e.g. "BOOKING_COM". Letters, numbers, underscores and hyphens only.') + ), + array( + 'type' => 'switch', + 'label' => $this->l('Enabled'), + 'name' => 'active', + 'required' => false, + 'class' => 't', + 'is_bool' => true, + 'default_value' => 1, + 'disabled' => $isUnremovableSource, + 'hint' => $isUnremovableSource ? $this->l('Built-in Booking Sources cannot be disabled.') : null, + 'values' => array( + array('id' => 'active_on', 'value' => 1, 'label' => $this->l('Enabled')), + array('id' => 'active_off', 'value' => 0, 'label' => $this->l('Disabled')), + ) + ), + ), + 'submit' => array( + 'title' => $this->l('Save'), + 'name' => 'submitAddsource' + ), + 'buttons' => array( + 'save-and-stay' => array( + 'title' => $this->l('Save and stay'), + 'name' => 'submitAddsourceAndStay', + 'type' => 'submit', + 'class' => 'btn btn-default pull-right', + 'icon' => 'process-icon-save', + ), + ), + ); + + return parent::renderForm(); + } + + + public function postProcess() + { + if (Tools::isSubmit('submitAddsource') || Tools::isSubmit('submitAddsourceAndStay')) { + return $this->postProcessSource(); + } elseif (Tools::isSubmit('submitMergeSource')) { + return $this->postProcessMergeSource(); + } elseif (Tools::isSubmit('deletesource')) { + return $this->postProcessDeleteSource(); + } elseif (Tools::isSubmit('statussource')) { + return $this->postProcessStatusSource(); + } elseif (Tools::isSubmit('submitBulkenableSelectionsource')) { + return $this->processUpdateStatusBulk(1); + } elseif (Tools::isSubmit('submitBulkdisableSelectionsource')) { + return $this->processUpdateStatusBulk(0); + } elseif (Tools::isSubmit('submitBulkdeletesource')) { + return $this->postProcessBulkDeleteSource(); + } elseif (Tools::isSubmit('submitAddbusiness_source') || Tools::isSubmit('submitAddbusiness_sourceAndStay')) { + return $this->postProcessBusinessSource(); + } elseif (Tools::isSubmit('status'.$this->table)) { + return $this->postProcessStatusBusinessSource(); + } elseif (Tools::isSubmit('delete'.$this->table)) { + return $this->postProcessDeleteBusinessSource(); + } elseif (Tools::isSubmit('submitBulkdelete'.$this->table)) { + return $this->postProcessBulkDeleteBusinessSource(); + } else { + return parent::postProcess(); + } + } + + protected function postProcessDeleteSource() + { + $objSource = new Source((int)Tools::getValue('id_source')); + if (!$objSource->isRemovable()) { + $this->errors[] = $this->l('For security reasons, you cannot delete a built-in Booking Source.'); + } elseif (!$objSource->delete()) { + $this->errors[] = Tools::displayError('An error occurred while deleting the Booking Source.'); + } else { + Tools::redirectAdmin(self::$currentIndex.'&viewbusiness_source&id_business_source='.(int)$objSource->id_business_source.'&conf=1&token='.$this->token); + } + } + + protected function postProcessStatusSource() + { + if (Validate::isLoadedObject($objSource = new Source((int)Tools::getValue('id_source')))) { + $idBusinessSource = $objSource->id_business_source; + $newActive = !$objSource->active; + $objBusinessSource = new BusinessSource($idBusinessSource); + if ($newActive && (!Validate::isLoadedObject($objBusinessSource) || !$objBusinessSource->active)) { + $this->errors[] = $this->l('You cannot enable this Booking Source because its Business Source is disabled.'); + } else { + $objSource->active = $newActive; + if ($objSource->save()) { + $this->redirect_after = self::$currentIndex.'&viewbusiness_source&id_business_source='.(int)$idBusinessSource.'&conf=5&token='.$this->token; + } + } + } + } + + protected function postProcessStatusBusinessSource() + { + if (Validate::isLoadedObject($objBusinessSource = new BusinessSource((int)Tools::getValue('id_business_source')))) { + $newActive = !$objBusinessSource->active; + if (!$newActive && $objBusinessSource->hasUnremovableSource()) { + $this->errors[] = $this->l('You cannot disable this Business Source because it has one or more permanent (built-in) Booking Sources under it.'); + } else { + $objBusinessSource->active = $newActive; + if ($objBusinessSource->save()) { + $this->redirect_after = self::$currentIndex.'&token='.$this->token.'&conf=5'; + } + } + } + } + + protected function postProcessBulkDeleteSource() + { + $idBusinessSource = 0; + foreach (Tools::getValue('sourceBox') as $selection) { + $objSource = new Source((int)$selection); + $idBusinessSource = $objSource->id_business_source; + if (!$objSource->isRemovable()) { + $this->errors[] = $this->l('For security reasons, you cannot delete a built-in Booking Source.'); + break; + } + } + + if (!count($this->errors)) { + $this->className = 'Source'; + $this->table = 'source'; + $this->boxes = Tools::getValue('sourceBox'); + parent::processBulkDelete(); + $this->redirect_after = self::$currentIndex.'&viewbusiness_source&id_business_source='.(int)$idBusinessSource.'&conf=1&token='.$this->token; + } + } + + protected function postProcessDeleteBusinessSource() + { + $objBusinessSource = new BusinessSource((int)Tools::getValue('id_business_source')); + if (!$objBusinessSource->isRemovable()) { + $this->errors[] = $this->l('For security reasons, you cannot delete a built-in Business Source.'); + } else { + return parent::postProcess(); + } + } + + protected function postProcessBulkDeleteBusinessSource() + { + foreach (Tools::getValue($this->table.'Box') as $selection) { + $objBusinessSource = new BusinessSource((int)$selection); + if (!$objBusinessSource->isRemovable()) { + $this->errors[] = $this->l('For security reasons, you cannot delete a built-in Business Source.'); + break; + } + } + + if (!count($this->errors)) { + return parent::postProcess(); + } + } + + protected function processUpdateStatusBulk($status = 1) + { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + $this->className = 'Source'; + $this->table = 'source'; + $this->boxes = Tools::getValue('sourceBox'); + if(empty($this->boxes)){ + $this->errors[] = $this->l('You must select at least one item to perform a bulk action.'); + } elseif ($status) { + foreach ($this->boxes as $selection) { + $objSource = new Source((int)$selection); + $objBusinessSource = new BusinessSource($objSource->id_business_source); + if (!Validate::isLoadedObject($objBusinessSource) || !$objBusinessSource->active) { + $this->errors[] = $this->l('You cannot enable a Booking Source whose Business Source is disabled.'); + break; + } + } + } + $this->display = 'view'; + if(!$this->errors){ + parent::processBulkStatusSelection($status); + $this->redirect_after = self::$currentIndex.'&viewbusiness_source&id_business_source='.(int)$idBusinessSource.'&conf=5&token='.$this->token; + } + } + + protected function postProcessSource() + { + $idSource = (int)Tools::getValue('id_source'); + $idBusinessSource = (int)Tools::getValue('id_business_source'); + $objSource = new Source($idSource); + $sourceCode = $idSource ? $objSource->code : trim(Tools::getValue('code')); + $isUnremovableSource = $idSource && in_array($idSource, Source::getUnremovableIds()); + + $defaultLangId = Configuration::get('PS_LANG_DEFAULT'); + $objDefaultLanguage = Language::getLanguage((int) $defaultLangId); + $languages = Language::getLanguages(false); + if (!trim(Tools::getValue('name_'.$defaultLangId))) { + $this->errors[] = $this->l('Name is required at least in '). + $objDefaultLanguage['name']; + } else { + foreach ($languages as $lang) { + if (trim(Tools::getValue('name_'.$lang['id_lang']))) { + if (!Validate::isGenericName(Tools::getValue('name_'.$lang['id_lang']))) { + $this->errors[] = $this->l('Invalid name in ').$lang['name']; + } + } + } + } + + $objBusinessSource = new BusinessSource($idBusinessSource); + if (!Validate::isLoadedObject($objBusinessSource) || $objBusinessSource->deleted) { + $this->errors[] = $this->l('Please select a valid Business Source.'); + } elseif (!$isUnremovableSource && (int)Tools::getValue('active') && !$objBusinessSource->active) { + $this->errors[] = $this->l('You cannot enable this Booking Source because its Business Source is disabled.'); + } + + if (!$idSource) { + if (!$sourceCode || !Validate::isModuleName($sourceCode)) { + $this->errors[] = $this->l('Source Code is invalid. Only letters, numbers, underscores and hyphens are allowed.'); + } elseif (Source::codeExists($sourceCode, $idSource)) { + $this->errors[] = $this->l('This Source Code is already used by another Booking Source.'); + } + } + + if (count($this->errors)) { + $this->display = $idSource ? 'edit' : 'add'; + return false; + } + + $objSource->id_business_source = $idBusinessSource; + $objSource->code = $sourceCode; + $objSource->active = $isUnremovableSource ? $objSource->active : (int)Tools::getValue('active'); + $objSource->name = array(); + foreach (Language::getIDs(false) as $idLang) { + $objSource->name[$idLang] = Tools::getValue('name_'.$idLang); + } + + if (!$objSource->save()) { + $this->errors[] = Tools::displayError('An error occurred while saving the Booking Source.'); + $this->display = $idSource ? 'edit' : 'add'; + return false; + } + + if (Tools::isSubmit('submitAddsourceAndStay')) { + Tools::redirectAdmin(self::$currentIndex.'&updatesource&id_source='.$objSource->id.'&conf='.($idSource ? 4 : 3).'&token='.$this->token); + } + + Tools::redirectAdmin(self::$currentIndex.'&viewbusiness_source&id_business_source='.$idBusinessSource.'&conf='.($idSource ? 4 : 3).'&token='.$this->token); + } + + protected function postProcessMergeSource() + { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + $idCurrentSource = (int)Tools::getValue('current_source'); + $idTargetSource = (int)Tools::getValue('target_source'); + + $objCurrent = new Source($idCurrentSource); + $objTarget = new Source($idTargetSource); + + if (!Validate::isLoadedObject($objCurrent) || !Validate::isLoadedObject($objTarget) + || (int)$objCurrent->id_business_source !== $idBusinessSource || (int)$objTarget->id_business_source !== $idBusinessSource + ) { + $this->errors[] = $this->l('Please select valid Current and Target Booking Sources.'); + } elseif ($idCurrentSource === $idTargetSource) { + $this->errors[] = $this->l('Current and Target Booking Source cannot be the same.'); + } else { + $objCurrent->mergeOrdersInto($idTargetSource); + } + $this->display = 'view'; + if (!count($this->errors)) { + Tools::redirectAdmin(self::$currentIndex.'&viewbusiness_source&id_business_source='.$idBusinessSource.'&conf=4&token='.$this->token); + } + } + + protected function postProcessBusinessSource() + { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + if ($idBusinessSource) { + $sourceTypeCode = (new BusinessSource($idBusinessSource))->code; + $_POST['code'] = $sourceTypeCode; + } else { + $sourceTypeCode = trim(Tools::getValue('code')); + } + + $defaultLangId = Configuration::get('PS_LANG_DEFAULT'); + $objDefaultLanguage = Language::getLanguage((int) $defaultLangId); + $languages = Language::getLanguages(false); + if (!trim(Tools::getValue('name_'.$defaultLangId))) { + $this->errors[] = $this->l('Name is required at least in '). + $objDefaultLanguage['name']; + } else { + foreach ($languages as $lang) { + if (trim(Tools::getValue('name_'.$lang['id_lang']))) { + if (!Validate::isGenericName(Tools::getValue('name_'.$lang['id_lang']))) { + $this->errors[] = $this->l('Invalid name in ').$lang['name']; + } + } + } + } + if (!$idBusinessSource) { + if (!$sourceTypeCode || !Validate::isModuleName($sourceTypeCode)) { + $this->errors[] = $this->l('Code is invalid. Only letters, numbers, underscores and hyphens are allowed.'); + } elseif (BusinessSource::codeExists($sourceTypeCode, $idBusinessSource)) { + $this->errors[] = $this->l('This Code is already used by another Business Source.'); + } + } + + if ($idBusinessSource && !(int)Tools::getValue('active')) { + $objBusinessSource = new BusinessSource($idBusinessSource); + if ($objBusinessSource->hasUnremovableSource()) { + $this->errors[] = $this->l('You cannot disable this Business Source because it has one or more permanent (built-in) Booking Sources under it.'); + } + } + + if (count($this->errors)) { + $this->display = $idBusinessSource ? 'edit' : 'add'; + return false; + } + + return parent::postProcess(); + } + + public function formatOrdersPercentage($count, $row) + { + $percentage = $this->ordersTotal ? round(((int)$count / $this->ordersTotal) * 100, 1) : 0; + return $percentage.'%'; + } + + public function displayEnableLink($token, $id, $value, $active, $id_category = null, $id_product = null, $ajax = false) + { + if ($this->table == 'source' && in_array((int)$id, Source::getUnremovableIds())) { + return ' + + '; + } + + $helper = new HelperList(); + $helper->token = $this->token; + $helper->currentIndex = self::$currentIndex; + $helper->table = $this->table; + $helper->identifier = $this->identifier; + + return $helper->displayEnableLink($token, $id, $value, $active, $id_category, $id_product, $ajax); + } + + public function displayMergeLink($token, $id, $name = null) + { + $idBusinessSource = (int) Tools::getValue('id_business_source'); + + return ' + ' . $this->l('Merge') . ' + '; + } + + public function ajaxProcessInitMergeSourceModal() + { + $response = array('hasError' => 1); + + if ($this->tabAccess['edit'] === 1) { + $idBusinessSource = (int)Tools::getValue('id_business_source'); + $idSource = (int)Tools::getValue('id_source'); + $objSource = new Source($idSource,(int)$this->context->language->id); + if (Validate::isLoadedObject($objSource) && (int)$objSource->id_business_source === $idBusinessSource) { + $this->context->smarty->assign(array( + 'sources' => Source::getActiveSource($idBusinessSource, $idSource, (int)$this->context->language->id), + 'current_source' => $objSource, + 'id_business_source' => $idBusinessSource, + 'current_index' => self::$currentIndex, + 'token' => $this->token, + )); + + $this->context->smarty->assign(array( + 'modal_id' => 'merge_source_modal', + 'modal_class' => 'modal-md', + 'modal_title' => '   ' .$this->l('Merge Booking Source'), + 'modal_content' => $this->context->smarty->fetch('controllers/sources/modals/_merge_source_form.tpl'), + )); + + $response['hasError'] = 0; + $response['modalHtml'] = $this->context->smarty->fetch('modal.tpl'); + } + } + + die(Tools::jsonEncode($response)); + } + + protected function filterToField($key, $filter) + { + if ($this->table == 'source') { + $this->initSourcesList(); + } else { + $this->initBusinessSourcesList(); + } + + return parent::filterToField($key, $filter); + } + + public function ajaxProcessUpdatePositions() + { + if ($this->tabAccess['edit'] !== 1) { + die(json_encode(array('hasError' => true, 'errors' => $this->l('You do not have permission to edit this.')))); + } + + $way = (int)Tools::getValue('way'); + $id = (int)Tools::getValue('id'); + + if ($positions = Tools::getValue('source')) { + $className = 'Source'; + } elseif ($positions = Tools::getValue('source_type')) { + $className = 'BusinessSource'; + } else { + die(json_encode(array('hasError' => true, 'errors' => 'Unknown table for position update.'))); + } + + foreach ($positions as $position => $value) { + $pos = explode('_', $value); + + if (isset($pos[2]) && (int)$pos[2] === $id) { + if ($object = new $className((int)$pos[2])) { + if (isset($position) && $object->updatePosition($way, $position)) { + echo 'ok position '.(int)$position.' for '.$className.' '.$id; + } else { + echo '{"hasError" : true, "errors" : "Can not update tab '.(int)$id.' to position '.(int)$position.' "}'; + } + } else { + echo json_encode(array( + 'hasError' => true, + 'errors' => 'Can not update '.$className.' '.$id.' to position '.(int)$position, + )); + } + + break; + } + } + } +} 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 9d0ec86a6..2bd9908fe 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 539c69f58..77c17f6fa 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 b4f597c96..85220ba0b 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 dc2816cd1..6a0f3b4bb 100644 --- a/controllers/front/OrderOpcController.php +++ b/controllers/front/OrderOpcController.php @@ -950,6 +950,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'], @@ -965,7 +973,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 fe384389a..d445b6e4c 100644 --- a/controllers/front/ParentOrderController.php +++ b/controllers/front/ParentOrderController.php @@ -364,6 +364,14 @@ class ParentOrderControllerCore extends FrontController 0 ); + 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 d7259646b..170cc40a6 100644 --- a/controllers/front/ProductController.php +++ b/controllers/front/ProductController.php @@ -472,7 +472,6 @@ class ProductControllerCore extends FrontController ); } } - $this->assignBookingFormVars($this->product->id, $date_from, $date_to, $occupancy_value); $this->assignRoomServiceProductVars(); @@ -485,6 +484,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); @@ -716,6 +745,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, @@ -726,13 +756,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; @@ -750,9 +788,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, @@ -780,6 +817,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); @@ -804,8 +845,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; } @@ -871,6 +921,11 @@ 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) { @@ -880,6 +935,7 @@ class ProductControllerCore extends FrontController $smartyVars['buying_option'] = $buying_option; } $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) { @@ -892,7 +948,15 @@ class ProductControllerCore extends FrontController $idHotel, false, $useTax, - 1 + 1, + null, + null, + false, + null, + 1, + null, + 0, + $includeTourismTax ); } @@ -904,7 +968,15 @@ class ProductControllerCore extends FrontController $idHotel, false, $useTax, - $quantity + $quantity, + null, + null, + false, + null, + 1, + null, + 0, + $includeTourismTax ); $smartyVars['service_price_without_reduction'] = Product::getServiceProductPrice( $this->product->id, @@ -917,7 +989,10 @@ class ProductControllerCore extends FrontController null, false, null, - false + false, + null, + 0, + $includeTourismTax ); if ($quantity) { $smartyVars['quantity'] = $quantity; diff --git a/install/data/db_structure.sql b/install/data/db_structure.sql index 07a270b34..69e4c3bef 100644 --- a/install/data/db_structure.sql +++ b/install/data/db_structure.sql @@ -1182,6 +1182,7 @@ CREATE TABLE `PREFIX_orders` ( `invoice_date` datetime NOT NULL, `delivery_date` datetime NOT NULL, `source` varchar(255) DEFAULT NULL, + `id_source` int(10) unsigned DEFAULT NULL, `valid` int(1) unsigned NOT NULL DEFAULT '0', `is_advance_payment` tinyint(1) NOT NULL DEFAULT '0', `advance_paid_amount` decimal(20,6) NOT NULL DEFAULT '0.00', @@ -1201,16 +1202,83 @@ CREATE TABLE `PREFIX_orders` ( KEY `id_shop_group` (`id_shop_group`), KEY (`current_state`), KEY `id_shop` (`id_shop`), + KEY `id_source` (`id_source`), 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` ( @@ -1280,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', @@ -1536,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, @@ -1599,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', @@ -1888,6 +1959,48 @@ CREATE TABLE `PREFIX_search_word` ( UNIQUE KEY `id_lang` (`id_lang`,`id_shop`, `word`) ) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION; +CREATE TABLE `PREFIX_business_source` ( + `id_business_source` int(10) unsigned NOT NULL auto_increment, + `code` varchar(64) NOT NULL, + `position` int(10) unsigned NOT NULL DEFAULT '0', + `unremovable` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `active` tinyint(1) UNSIGNED NOT NULL DEFAULT '1', + `deleted` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `date_add` datetime NOT NULL, + `date_upd` datetime NOT NULL, + PRIMARY KEY (`id_business_source`), + UNIQUE KEY `code` (`code`) +) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION; + +CREATE TABLE `PREFIX_business_source_lang` ( + `id_business_source` int(10) unsigned NOT NULL, + `id_lang` int(10) unsigned NOT NULL, + `name` varchar(64) NOT NULL, + PRIMARY KEY (`id_business_source`,`id_lang`) +) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION; + +CREATE TABLE `PREFIX_source` ( + `id_source` int(10) unsigned NOT NULL auto_increment, + `id_business_source` int(10) unsigned NOT NULL, + `code` varchar(64) NOT NULL, + `position` int(10) unsigned NOT NULL DEFAULT '0', + `unremovable` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `active` tinyint(1) UNSIGNED NOT NULL DEFAULT '1', + `deleted` tinyint(1) UNSIGNED NOT NULL DEFAULT '0', + `date_add` datetime NOT NULL, + `date_upd` datetime NOT NULL, + PRIMARY KEY (`id_source`), + UNIQUE KEY `code` (`code`), + KEY `id_business_source` (`id_business_source`) +) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION; + +CREATE TABLE `PREFIX_source_lang` ( + `id_source` int(10) unsigned NOT NULL, + `id_lang` int(10) unsigned NOT NULL, + `name` varchar(64) NOT NULL, + PRIMARY KEY (`id_source`,`id_lang`) +) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8 COLLATION; + CREATE TABLE `PREFIX_specific_price` ( `id_specific_price` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id_specific_price_rule` INT(11) UNSIGNED NOT NULL, @@ -1997,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`) @@ -2122,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/data/xml/business_source.xml b/install/data/xml/business_source.xml new file mode 100644 index 000000000..ec7355d81 --- /dev/null +++ b/install/data/xml/business_source.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/install/data/xml/source.xml b/install/data/xml/source.xml new file mode 100644 index 000000000..707c7c0eb --- /dev/null +++ b/install/data/xml/source.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/install/data/xml/tab.xml b/install/data/xml/tab.xml index 8c8205a9b..538da95e3 100644 --- a/install/data/xml/tab.xml +++ b/install/data/xml/tab.xml @@ -117,6 +117,9 @@ AdminStatuses + + AdminSources + AdminOrderMessage diff --git a/install/langs/bg/data/business_source.xml b/install/langs/bg/data/business_source.xml new file mode 100644 index 000000000..1e6d1d5a3 --- /dev/null +++ b/install/langs/bg/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Директен + + + OTA + + + Корпоративен + + + Туристическа агенция + + + Група + + + API + + diff --git a/install/langs/bg/data/source.xml b/install/langs/bg/data/source.xml new file mode 100644 index 000000000..d52cff723 --- /dev/null +++ b/install/langs/bg/data/source.xml @@ -0,0 +1,57 @@ + + + + Директен уебсайт + + + Без резервация + + + Телефон + + + Имейл + + + Социални медии + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API поръчка + + diff --git a/install/langs/bn/data/business_source.xml b/install/langs/bn/data/business_source.xml new file mode 100644 index 000000000..b2711ce28 --- /dev/null +++ b/install/langs/bn/data/business_source.xml @@ -0,0 +1,21 @@ + + + + সরাসরি + + + OTA + + + কর্পোরেট + + + ট্রাভেল এজেন্ট + + + গ্রুপ + + + API + + diff --git a/install/langs/bn/data/source.xml b/install/langs/bn/data/source.xml new file mode 100644 index 000000000..2bd5ccb5d --- /dev/null +++ b/install/langs/bn/data/source.xml @@ -0,0 +1,57 @@ + + + + সরাসরি ওয়েবসাইট + + + ওয়াক-ইন + + + ফোন + + + ইমেইল + + + সোশ্যাল মিডিয়া + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API অর্ডার + + diff --git a/install/langs/br/data/business_source.xml b/install/langs/br/data/business_source.xml new file mode 100644 index 000000000..55240b725 --- /dev/null +++ b/install/langs/br/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direto + + + OTA + + + Corporativo + + + Agência de Viagens + + + Grupo + + + API + + diff --git a/install/langs/br/data/source.xml b/install/langs/br/data/source.xml new file mode 100644 index 000000000..65d602c1c --- /dev/null +++ b/install/langs/br/data/source.xml @@ -0,0 +1,57 @@ + + + + Site Direto + + + Sem Reserva + + + Telefone + + + E-mail + + + Redes Sociais + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Pedido API + + diff --git a/install/langs/cs/data/business_source.xml b/install/langs/cs/data/business_source.xml new file mode 100644 index 000000000..eb74264fd --- /dev/null +++ b/install/langs/cs/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Přímý + + + OTA + + + Firemní + + + Cestovní kancelář + + + Skupina + + + API + + diff --git a/install/langs/cs/data/source.xml b/install/langs/cs/data/source.xml new file mode 100644 index 000000000..995df6040 --- /dev/null +++ b/install/langs/cs/data/source.xml @@ -0,0 +1,57 @@ + + + + Přímé webové stránky + + + Bez rezervace + + + Telefon + + + E-mail + + + Sociální média + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API objednávka + + diff --git a/install/langs/de/data/business_source.xml b/install/langs/de/data/business_source.xml new file mode 100644 index 000000000..62ff77c54 --- /dev/null +++ b/install/langs/de/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direkt + + + OTA + + + Firmenkunden + + + Reisebüro + + + Gruppe + + + API + + diff --git a/install/langs/de/data/source.xml b/install/langs/de/data/source.xml new file mode 100644 index 000000000..e57b3b915 --- /dev/null +++ b/install/langs/de/data/source.xml @@ -0,0 +1,57 @@ + + + + Direkte Website + + + Laufkundschaft + + + Telefon + + + E-Mail + + + Soziale Medien + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API-Bestellung + + diff --git a/install/langs/en/data/business_source.xml b/install/langs/en/data/business_source.xml new file mode 100644 index 000000000..ca736bac3 --- /dev/null +++ b/install/langs/en/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direct + + + OTA + + + Corporate + + + Travel Agent + + + Group + + + API + + diff --git a/install/langs/en/data/source.xml b/install/langs/en/data/source.xml new file mode 100644 index 000000000..3129c97c9 --- /dev/null +++ b/install/langs/en/data/source.xml @@ -0,0 +1,57 @@ + + + + Direct Website + + + Walk-in + + + Phone + + + Email + + + Social Media + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API Order + + diff --git a/install/langs/en/data/tab.xml b/install/langs/en/data/tab.xml index a47774d51..791f58a5a 100644 --- a/install/langs/en/data/tab.xml +++ b/install/langs/en/data/tab.xml @@ -37,6 +37,7 @@ + diff --git a/install/langs/es/data/business_source.xml b/install/langs/es/data/business_source.xml new file mode 100644 index 000000000..0507cf735 --- /dev/null +++ b/install/langs/es/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Directo + + + OTA + + + Corporativo + + + Agencia de Viajes + + + Grupo + + + API + + diff --git a/install/langs/es/data/source.xml b/install/langs/es/data/source.xml new file mode 100644 index 000000000..8b4a4c363 --- /dev/null +++ b/install/langs/es/data/source.xml @@ -0,0 +1,57 @@ + + + + Sitio Web Directo + + + Sin reserva + + + Teléfono + + + Correo electrónico + + + Redes Sociales + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Pedido API + + diff --git a/install/langs/fa/data/business_source.xml b/install/langs/fa/data/business_source.xml new file mode 100644 index 000000000..162a28b14 --- /dev/null +++ b/install/langs/fa/data/business_source.xml @@ -0,0 +1,21 @@ + + + + مستقیم + + + OTA + + + سازمانی + + + آژانس مسافرتی + + + گروه + + + API + + diff --git a/install/langs/fa/data/source.xml b/install/langs/fa/data/source.xml new file mode 100644 index 000000000..41b725ac8 --- /dev/null +++ b/install/langs/fa/data/source.xml @@ -0,0 +1,57 @@ + + + + وب‌سایت مستقیم + + + مراجعه حضوری + + + تلفن + + + ایمیل + + + رسانه‌های اجتماعی + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + سفارش API + + diff --git a/install/langs/fr/data/business_source.xml b/install/langs/fr/data/business_source.xml new file mode 100644 index 000000000..f69138d88 --- /dev/null +++ b/install/langs/fr/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direct + + + OTA + + + Entreprise + + + Agence de Voyage + + + Groupe + + + API + + diff --git a/install/langs/fr/data/source.xml b/install/langs/fr/data/source.xml new file mode 100644 index 000000000..9a93d2479 --- /dev/null +++ b/install/langs/fr/data/source.xml @@ -0,0 +1,57 @@ + + + + Site Web Direct + + + Sans réservation + + + Téléphone + + + E-mail + + + Réseaux Sociaux + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Commande API + + diff --git a/install/langs/he/data/business_source.xml b/install/langs/he/data/business_source.xml new file mode 100644 index 000000000..b0a0fbfb0 --- /dev/null +++ b/install/langs/he/data/business_source.xml @@ -0,0 +1,21 @@ + + + + ישיר + + + OTA + + + עסקי + + + סוכנות נסיעות + + + קבוצה + + + API + + diff --git a/install/langs/he/data/source.xml b/install/langs/he/data/source.xml new file mode 100644 index 000000000..be275f519 --- /dev/null +++ b/install/langs/he/data/source.xml @@ -0,0 +1,57 @@ + + + + אתר אינטרנט ישיר + + + הגעה ללא הזמנה + + + טלפון + + + אימייל + + + מדיה חברתית + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + הזמנת API + + diff --git a/install/langs/hr/data/business_source.xml b/install/langs/hr/data/business_source.xml new file mode 100644 index 000000000..c48f55f80 --- /dev/null +++ b/install/langs/hr/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Izravno + + + OTA + + + Poslovni + + + Putnička Agencija + + + Grupa + + + API + + diff --git a/install/langs/hr/data/source.xml b/install/langs/hr/data/source.xml new file mode 100644 index 000000000..5e09b1553 --- /dev/null +++ b/install/langs/hr/data/source.xml @@ -0,0 +1,57 @@ + + + + Izravna Web Stranica + + + Bez rezervacije + + + Telefon + + + E-pošta + + + Društveni Mediji + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API narudžba + + diff --git a/install/langs/hu/data/business_source.xml b/install/langs/hu/data/business_source.xml new file mode 100644 index 000000000..f3e68970b --- /dev/null +++ b/install/langs/hu/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Közvetlen + + + OTA + + + Vállalati + + + Utazási Iroda + + + Csoport + + + API + + diff --git a/install/langs/hu/data/source.xml b/install/langs/hu/data/source.xml new file mode 100644 index 000000000..5620de387 --- /dev/null +++ b/install/langs/hu/data/source.xml @@ -0,0 +1,57 @@ + + + + Közvetlen Weboldal + + + Foglalás Nélküli + + + Telefon + + + E-mail + + + Közösségi Média + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API rendelés + + diff --git a/install/langs/id/data/business_source.xml b/install/langs/id/data/business_source.xml new file mode 100644 index 000000000..6d46eb00d --- /dev/null +++ b/install/langs/id/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Langsung + + + OTA + + + Korporat + + + Agen Perjalanan + + + Grup + + + API + + diff --git a/install/langs/id/data/source.xml b/install/langs/id/data/source.xml new file mode 100644 index 000000000..f249e90dc --- /dev/null +++ b/install/langs/id/data/source.xml @@ -0,0 +1,57 @@ + + + + Situs Web Langsung + + + Tanpa Reservasi + + + Telepon + + + Email + + + Media Sosial + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Pesanan API + + diff --git a/install/langs/it/data/business_source.xml b/install/langs/it/data/business_source.xml new file mode 100644 index 000000000..4c96f4db3 --- /dev/null +++ b/install/langs/it/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Diretto + + + OTA + + + Aziendale + + + Agenzia di Viaggi + + + Gruppo + + + API + + diff --git a/install/langs/it/data/source.xml b/install/langs/it/data/source.xml new file mode 100644 index 000000000..e65882222 --- /dev/null +++ b/install/langs/it/data/source.xml @@ -0,0 +1,57 @@ + + + + Sito Web Diretto + + + Senza prenotazione + + + Telefono + + + Email + + + Social Media + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Ordine API + + diff --git a/install/langs/lt/data/business_source.xml b/install/langs/lt/data/business_source.xml new file mode 100644 index 000000000..957b71f5c --- /dev/null +++ b/install/langs/lt/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Tiesioginis + + + OTA + + + Įmonės + + + Kelionių Agentūra + + + Grupė + + + API + + diff --git a/install/langs/lt/data/source.xml b/install/langs/lt/data/source.xml new file mode 100644 index 000000000..c626ee59b --- /dev/null +++ b/install/langs/lt/data/source.xml @@ -0,0 +1,57 @@ + + + + Tiesioginė Svetainė + + + Be Rezervacijos + + + Telefonas + + + El. Paštas + + + Socialiniai Tinklai + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API užsakymas + + diff --git a/install/langs/mk/data/business_source.xml b/install/langs/mk/data/business_source.xml new file mode 100644 index 000000000..e6f20c84d --- /dev/null +++ b/install/langs/mk/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Директно + + + OTA + + + Корпоративно + + + Патничка Агенција + + + Група + + + API + + diff --git a/install/langs/mk/data/source.xml b/install/langs/mk/data/source.xml new file mode 100644 index 000000000..ad16f305f --- /dev/null +++ b/install/langs/mk/data/source.xml @@ -0,0 +1,57 @@ + + + + Директна Веб-страница + + + Без резервација + + + Телефон + + + Е-пошта + + + Социјални Медиуми + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API нарачка + + diff --git a/install/langs/nl/data/business_source.xml b/install/langs/nl/data/business_source.xml new file mode 100644 index 000000000..4755293bd --- /dev/null +++ b/install/langs/nl/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direct + + + OTA + + + Zakelijk + + + Reisbureau + + + Groep + + + API + + diff --git a/install/langs/nl/data/source.xml b/install/langs/nl/data/source.xml new file mode 100644 index 000000000..18f817ee0 --- /dev/null +++ b/install/langs/nl/data/source.xml @@ -0,0 +1,57 @@ + + + + Directe Website + + + Zonder Reservering + + + Telefoon + + + E-mail + + + Sociale Media + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API-bestelling + + diff --git a/install/langs/no/data/business_source.xml b/install/langs/no/data/business_source.xml new file mode 100644 index 000000000..50b17dab6 --- /dev/null +++ b/install/langs/no/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direkte + + + OTA + + + Bedrift + + + Reisebyrå + + + Gruppe + + + API + + diff --git a/install/langs/no/data/source.xml b/install/langs/no/data/source.xml new file mode 100644 index 000000000..5dfabae39 --- /dev/null +++ b/install/langs/no/data/source.xml @@ -0,0 +1,57 @@ + + + + Direkte Nettside + + + Uten Reservasjon + + + Telefon + + + E-post + + + Sosiale Medier + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API-bestilling + + diff --git a/install/langs/pl/data/business_source.xml b/install/langs/pl/data/business_source.xml new file mode 100644 index 000000000..af4f73cf3 --- /dev/null +++ b/install/langs/pl/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Bezpośredni + + + OTA + + + Firmowy + + + Biuro Podróży + + + Grupa + + + API + + diff --git a/install/langs/pl/data/source.xml b/install/langs/pl/data/source.xml new file mode 100644 index 000000000..2a9c9ea30 --- /dev/null +++ b/install/langs/pl/data/source.xml @@ -0,0 +1,57 @@ + + + + Bezpośrednia Strona Internetowa + + + Bez Rezerwacji + + + Telefon + + + E-mail + + + Media Społecznościowe + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Zamówienie API + + diff --git a/install/langs/pt/data/business_source.xml b/install/langs/pt/data/business_source.xml new file mode 100644 index 000000000..55240b725 --- /dev/null +++ b/install/langs/pt/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direto + + + OTA + + + Corporativo + + + Agência de Viagens + + + Grupo + + + API + + diff --git a/install/langs/pt/data/source.xml b/install/langs/pt/data/source.xml new file mode 100644 index 000000000..65d602c1c --- /dev/null +++ b/install/langs/pt/data/source.xml @@ -0,0 +1,57 @@ + + + + Site Direto + + + Sem Reserva + + + Telefone + + + E-mail + + + Redes Sociais + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Pedido API + + diff --git a/install/langs/qc/data/business_source.xml b/install/langs/qc/data/business_source.xml new file mode 100644 index 000000000..f69138d88 --- /dev/null +++ b/install/langs/qc/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direct + + + OTA + + + Entreprise + + + Agence de Voyage + + + Groupe + + + API + + diff --git a/install/langs/qc/data/source.xml b/install/langs/qc/data/source.xml new file mode 100644 index 000000000..159b1d937 --- /dev/null +++ b/install/langs/qc/data/source.xml @@ -0,0 +1,57 @@ + + + + Site Web Direct + + + Sans réservation + + + Téléphone + + + Courriel + + + Médias Sociaux + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Commande API + + diff --git a/install/langs/ro/data/business_source.xml b/install/langs/ro/data/business_source.xml new file mode 100644 index 000000000..3cfae5e22 --- /dev/null +++ b/install/langs/ro/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direct + + + OTA + + + Corporativ + + + Agenție de Turism + + + Grup + + + API + + diff --git a/install/langs/ro/data/source.xml b/install/langs/ro/data/source.xml new file mode 100644 index 000000000..ec61d8982 --- /dev/null +++ b/install/langs/ro/data/source.xml @@ -0,0 +1,57 @@ + + + + Site Web Direct + + + Fără Rezervare + + + Telefon + + + E-mail + + + Rețele Sociale + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Comandă API + + diff --git a/install/langs/ru/data/business_source.xml b/install/langs/ru/data/business_source.xml new file mode 100644 index 000000000..ff653dcb4 --- /dev/null +++ b/install/langs/ru/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Прямой + + + OTA + + + Корпоративный + + + Турагентство + + + Группа + + + API + + diff --git a/install/langs/ru/data/source.xml b/install/langs/ru/data/source.xml new file mode 100644 index 000000000..dc48395ef --- /dev/null +++ b/install/langs/ru/data/source.xml @@ -0,0 +1,57 @@ + + + + Прямой сайт + + + Без бронирования + + + Телефон + + + Эл. почта + + + Социальные сети + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Заказ через API + + diff --git a/install/langs/si/data/business_source.xml b/install/langs/si/data/business_source.xml new file mode 100644 index 000000000..59f36c869 --- /dev/null +++ b/install/langs/si/data/business_source.xml @@ -0,0 +1,21 @@ + + + + සෘජු + + + OTA + + + සංස්ථාපිත + + + සංචාරක නියෝජිත ආයතනය + + + කණ්ඩායම + + + API + + diff --git a/install/langs/si/data/source.xml b/install/langs/si/data/source.xml new file mode 100644 index 000000000..211c1dc75 --- /dev/null +++ b/install/langs/si/data/source.xml @@ -0,0 +1,57 @@ + + + + සෘජු වෙබ් අඩවිය + + + වෙන් කිරීමකින් තොරව + + + දුරකථනය + + + විද්‍යුත් තැපෑල + + + සමාජ මාධ්‍ය + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API ඇණවුම + + diff --git a/install/langs/sr/data/business_source.xml b/install/langs/sr/data/business_source.xml new file mode 100644 index 000000000..5f6b74237 --- /dev/null +++ b/install/langs/sr/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Директно + + + OTA + + + Пословно + + + Туристичка Агенција + + + Група + + + API + + diff --git a/install/langs/sr/data/source.xml b/install/langs/sr/data/source.xml new file mode 100644 index 000000000..eddb3b9a9 --- /dev/null +++ b/install/langs/sr/data/source.xml @@ -0,0 +1,57 @@ + + + + Директна Веб Страница + + + Без Резервације + + + Телефон + + + Е-пошта + + + Друштвени Медији + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API наруџбина + + diff --git a/install/langs/sv/data/business_source.xml b/install/langs/sv/data/business_source.xml new file mode 100644 index 000000000..ceba7eda7 --- /dev/null +++ b/install/langs/sv/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Direkt + + + OTA + + + Företag + + + Resebyrå + + + Grupp + + + API + + diff --git a/install/langs/sv/data/source.xml b/install/langs/sv/data/source.xml new file mode 100644 index 000000000..0882bff0b --- /dev/null +++ b/install/langs/sv/data/source.xml @@ -0,0 +1,57 @@ + + + + Direkt Webbplats + + + Utan Bokning + + + Telefon + + + E-post + + + Sociala Medier + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API-beställning + + diff --git a/install/langs/tw/data/business_source.xml b/install/langs/tw/data/business_source.xml new file mode 100644 index 000000000..4fc60dbf2 --- /dev/null +++ b/install/langs/tw/data/business_source.xml @@ -0,0 +1,21 @@ + + + + 直接 + + + OTA + + + 企業 + + + 旅行社 + + + 團體 + + + API + + diff --git a/install/langs/tw/data/source.xml b/install/langs/tw/data/source.xml new file mode 100644 index 000000000..5f11ab2dd --- /dev/null +++ b/install/langs/tw/data/source.xml @@ -0,0 +1,57 @@ + + + + 官方網站 + + + 現場入住 + + + 電話 + + + 電子郵件 + + + 社群媒體 + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API 訂單 + + diff --git a/install/langs/vn/data/business_source.xml b/install/langs/vn/data/business_source.xml new file mode 100644 index 000000000..7f01299af --- /dev/null +++ b/install/langs/vn/data/business_source.xml @@ -0,0 +1,21 @@ + + + + Trực tiếp + + + OTA + + + Doanh nghiệp + + + Đại lý Du lịch + + + Nhóm + + + API + + diff --git a/install/langs/vn/data/source.xml b/install/langs/vn/data/source.xml new file mode 100644 index 000000000..84a3b662a --- /dev/null +++ b/install/langs/vn/data/source.xml @@ -0,0 +1,57 @@ + + + + Trang Web Trực tiếp + + + Không đặt trước + + + Điện thoại + + + Email + + + Mạng Xã hội + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + Đơn hàng API + + diff --git a/install/langs/zh/data/business_source.xml b/install/langs/zh/data/business_source.xml new file mode 100644 index 000000000..8d5138e70 --- /dev/null +++ b/install/langs/zh/data/business_source.xml @@ -0,0 +1,21 @@ + + + + 直接 + + + OTA + + + 企业 + + + 旅行社 + + + 团体 + + + API + + diff --git a/install/langs/zh/data/source.xml b/install/langs/zh/data/source.xml new file mode 100644 index 000000000..a177714ff --- /dev/null +++ b/install/langs/zh/data/source.xml @@ -0,0 +1,57 @@ + + + + 官方网站 + + + 现场入住 + + + 电话 + + + 电子邮件 + + + 社交媒体 + + + WhatsApp + + + Booking.com + + + Expedia + + + GoMMT (GoIbibo/MakeMyTrip) + + + Agoda + + + Airbnb + + + Trip.com (Ctrip) + + + Bakuun + + + Despegar + + + Yatra + + + Hostelworld + + + HyperGuest + + + API 订单 + + diff --git a/install/models/install.php b/install/models/install.php index 32d939aeb..c665e028f 100644 --- a/install/models/install.php +++ b/install/models/install.php @@ -540,6 +540,14 @@ class InstallModelInstall extends InstallAbstractModel Configuration::updateGlobalValue('PS_OS_OUTOFSTOCK_PAID', (int)Configuration::get('PS_OS_OUTOFSTOCK')); } + // Set the default Booking Source (Direct Website) + $id_default_source = (int)Db::getInstance()->getValue( + 'SELECT `id_source` FROM '._DB_PREFIX_.'source WHERE `code` = \'DIRECT_WEBSITE\'' + ); + if ($id_default_source) { + Configuration::updateGlobalValue('PS_DEFAULT_BOOKING_SOURCE', $id_default_source); + } + // Set logo configuration if (file_exists(_PS_IMG_DIR_.'logo.png')) { list($width, $height) = getimagesize(_PS_IMG_DIR_.'logo.png'); @@ -623,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/js/admin/sources.js b/js/admin/sources.js new file mode 100644 index 000000000..7752aa310 --- /dev/null +++ b/js/admin/sources.js @@ -0,0 +1,44 @@ +/** +* 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 +* +* @author Webkul IN +* @copyright Since 2010 Webkul +* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0 +*/ + +$(document).on('click', '.merge-source-btn', function (e) { + e.preventDefault(); + + var $btn = $(this); + + $('.loading_overlay').show(); + $.ajax({ + type: 'POST', + url: $btn.data('ajax-url'), + dataType: 'JSON', + cache: false, + data: { + ajax: true, + action: 'InitMergeSourceModal', + id_source: $btn.data('id-source'), + id_business_source: $btn.data('id-business-source') + }, + success: function (result) { + if (!result.hasError && result.modalHtml) { + $('#merge_source_modal').remove(); + $('#footer').next('.bootstrap').append(result.modalHtml); + $('#merge_source_modal').modal('show'); + } else { + showErrorMessage(result.error); + } + }, + complete: function () { + $('.loading_overlay').hide(); + } + }); +}); 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 72d89e1e2..8ad965c04 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']; $product['standalone_total_qty'] += (int) $serviceProduct['quantity']; } } @@ -209,11 +212,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'; @@ -299,16 +307,24 @@ 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); @@ -316,7 +332,7 @@ class Blockcart extends Module } else { $canSellWithHotel = Product::isSellableWithHotel($objProduct->id); $canSellStandalone = Product::isSellableAsStandalone($objProduct->id); - + $includeTourismTax = $useTax && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP'); if ($canSellWithHotel && !empty($addedProduct['id_hotel'])) { $addedProduct['unit_price'] = Product::getServiceProductPrice( $objProduct->id, @@ -324,7 +340,15 @@ class Blockcart extends Module $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; @@ -342,7 +366,15 @@ class Blockcart extends Module 0, false, $useTax, - $addedProduct['qty'] + 1, + null, + null, + false, + null, + 1, + null, + 0, + $includeTourismTax ); if (isset($addedProduct['id_product_option']) @@ -362,10 +394,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, @@ -381,6 +424,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 2ab6e3690..9a2c63508 100644 --- a/modules/blockcart/blockcart.tpl +++ b/modules/blockcart/blockcart.tpl @@ -139,7 +139,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} @@ -267,7 +275,7 @@ {if $cart_qties > 0} - {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)} + {convertPrice price=$cart->getOrderTotal(($priceDisplay != 1), Cart::ONLY_ROOMS)} {/if}
    @@ -286,7 +294,7 @@ {if $cart_qties > 0} - {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)} + {convertPrice price=$normal_products_total} {/if} @@ -345,11 +353,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 41eeadad3..7dc46d4aa 100644 --- a/modules/hotelreservationsystem/classes/HotelCartBookingData.php +++ b/modules/hotelreservationsystem/classes/HotelCartBookingData.php @@ -1644,6 +1644,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'], @@ -1652,7 +1653,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'], @@ -1663,7 +1667,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'], @@ -1675,7 +1681,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']; @@ -1706,6 +1713,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'], @@ -1714,7 +1722,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'], @@ -1725,7 +1736,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'], @@ -1737,7 +1750,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 8050c1be2..10212e24a 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 06bcd0079..bcb41e8d3 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 ); } @@ -227,7 +230,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; @@ -251,6 +259,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/ServiceProductCartDetail.php b/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php index 93c74b676..b132f797e 100644 --- a/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php +++ b/modules/hotelreservationsystem/classes/ServiceProductCartDetail.php @@ -233,22 +233,50 @@ 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 = Product::getServicePriceBillableDays( + $objProduct->price_calculation_method, + $product['date_from'], + $product['date_to'] + ); + + $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'] + ) / max($numDays, 1); + 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 = Product::getServicePriceBillableDays( @@ -272,6 +300,7 @@ class ServiceProductCartDetail extends ObjectModel null, $product['htl_cart_booking_id'] ) / max($numDays, 1); + $priceTaxExcl = Product::getServiceProductPrice( $objProduct->id, $product['id_product_option'], @@ -287,6 +316,43 @@ class ServiceProductCartDetail extends ObjectModel null, $product['htl_cart_booking_id'] ) / max($numDays, 1); + + + $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'])) { @@ -333,10 +399,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; @@ -350,7 +416,10 @@ class ServiceProductCartDetail extends ObjectModel $productInfo['id_room'] = 0; } + $productInfo['tourism_tax'] = $tourismTaxAmount; + if ($detailedInfo && $product['id_hotel']) { + $productInfo['tourism_tax'] = $tourismTaxAmount; $objHotelBranchInformation = new HotelBranchInformation(); $hotelInfo = $objHotelBranchInformation->hotelBranchesInfo($language->id, 2, 1, $product['id_hotel']); $hotelInfo['location'] = $hotelInfo['hotel_name'].', '.$hotelInfo['city']. 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 9254faa54..efc6f2b0b 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 342d20974..103892bee 100644 --- a/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js +++ b/themes/hotel-reservation-theme/js/modules/blockcart/ajax-cart.js @@ -1320,6 +1320,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 75ce91bfc..694f6a1df 100644 --- a/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl +++ b/themes/hotel-reservation-theme/modules/blockcart/blockcart.tpl @@ -139,7 +139,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} @@ -267,7 +275,7 @@
    {if $cart_qties > 0} - {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)} + {convertPrice price=$cart->getOrderTotal(($priceDisplay != 1), Cart::ONLY_ROOMS)} {/if} @@ -286,7 +294,7 @@ {if $cart_qties > 0} - {convertPrice price=$cart->getOrderTotal(false, Cart::ONLY_PRODUCTS)} + {convertPrice price=$normal_products_total} {/if} @@ -345,11 +353,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 cb3916999..81fffe878 100644 --- a/themes/hotel-reservation-theme/order-confirmation.tpl +++ b/themes/hotel-reservation-theme/order-confirmation.tpl @@ -469,12 +469,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 72f129f6d..ec701631c 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_WITH_STANDALONE) + $order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_WITH_HOTEL))} {assign var=total_standard_products_tax_excl value=($order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_WITH_STANDALONE) + $order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_WITH_HOTEL))} @@ -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_WITH_STANDALONE) + $order->getTotalProductsWithTaxes(false, false, Product::SELLING_PREFERENCE_WITH_HOTEL))} {assign var=total_standard_products_tax_excl value=($order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_WITH_STANDALONE) + $order->getTotalProductsWithoutTaxes(false, false, Product::SELLING_PREFERENCE_WITH_HOTEL))} - {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'}