From ee4fb7a07a61a023633cf6f88fceb66461dd92fc Mon Sep 17 00:00:00 2001 From: shazar rahman Date: Thu, 2 Jul 2026 13:05:40 +0530 Subject: [PATCH 01/11] Added: Tourism tax system with per-hotel configurable, rate tiers, child pricing, and exemption Co-authored-by: Name --- .../controllers/normal_products/prices.tpl | 17 + .../controllers/orders/_product_line.tpl | 31 + .../orders/_rooms_informaion_table.tpl | 81 +- .../controllers/orders/helpers/view/view.tpl | 12 + .../template/controllers/products/prices.tpl | 17 + classes/Cart.php | 22 +- classes/PaymentModule.php | 37 + classes/Product.php | 8 +- classes/order/OrderDetail.php | 6 +- classes/order/OrderInvoice.php | 10 + classes/order/OrderReturn.php | 10 + classes/pdf/HTMLTemplateInvoice.php | 17 + classes/tax/Tax.php | 12 +- classes/tax/TaxRulesGroup.php | 37 +- .../admin/AdminNormalProductsController.php | 5 + controllers/admin/AdminOrdersController.php | 125 ++- controllers/admin/AdminProductsController.php | 5 + .../admin/AdminTaxRulesGroupController.php | 167 +++- controllers/admin/AdminTaxesController.php | 717 +++++++++++++++++- .../front/OrderConfirmationController.php | 13 + controllers/front/OrderDetailController.php | 1 + install/data/db_structure.sql | 5 + .../classes/HotelBookingDetail.php | 35 + .../classes/HotelBookingTaxExemption.php | 68 ++ .../classes/HotelBranchInformation.php | 2 + .../classes/HotelHelper.php | 2 + .../classes/HotelOrderTourismTax.php | 565 ++++++++++++++ .../classes/HotelReservationSystemDb.php | 83 +- .../classes/HotelRoomTypeFeaturePricing.php | 47 ++ .../classes/HotelTourismTax.php | 192 +++++ .../classes/HotelTourismTaxCalculator.php | 513 +++++++++++++ .../classes/HotelTourismTaxChildRange.php | 168 ++++ .../classes/HotelTourismTaxTier.php | 145 ++++ .../admin/AdminAddHotelController.php | 3 + modules/hotelreservationsystem/define.php | 7 + .../hotelreservationsystem.php | 53 +- .../views/css/TourismTaxAdmin.css | 73 ++ .../views/js/TourismTaxForm.js | 185 +++++ .../admin/add_hotel/helpers/form/form.tpl | 27 + .../helpers/view/view.tpl | 18 +- pdf/invoice.tax-tab.tpl | 24 + pdf/invoice.total-tab.tpl | 20 + .../cart-total-block.tpl | 14 + .../order-confirmation.tpl | 20 + .../hotel-reservation-theme/order-detail.tpl | 16 + 45 files changed, 3575 insertions(+), 60 deletions(-) create mode 100644 modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php create mode 100644 modules/hotelreservationsystem/classes/HotelOrderTourismTax.php create mode 100644 modules/hotelreservationsystem/classes/HotelTourismTax.php create mode 100644 modules/hotelreservationsystem/classes/HotelTourismTaxCalculator.php create mode 100644 modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php create mode 100644 modules/hotelreservationsystem/classes/HotelTourismTaxTier.php create mode 100644 modules/hotelreservationsystem/views/css/TourismTaxAdmin.css create mode 100644 modules/hotelreservationsystem/views/js/TourismTaxForm.js diff --git a/admin/themes/default/template/controllers/normal_products/prices.tpl b/admin/themes/default/template/controllers/normal_products/prices.tpl index bae5d1211..274fe5b0f 100644 --- a/admin/themes/default/template/controllers/normal_products/prices.tpl +++ b/admin/themes/default/template/controllers/normal_products/prices.tpl @@ -197,6 +197,23 @@ $(document).ready(function () { + {if isset($tourismTaxRulesGroups)} +
+
+ +
+ +
+
+ {/if} + {*
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/_product_line.tpl b/admin/themes/default/template/controllers/orders/_product_line.tpl index 1e44a648f..013adfbd2 100644 --- a/admin/themes/default/template/controllers/orders/_product_line.tpl +++ b/admin/themes/default/template/controllers/orders/_product_line.tpl @@ -81,6 +81,20 @@ {convertPriceWithCurrency price=($data['total_room_tax']) currency=$currency->id} + {if isset($use_tourism_tax) && $use_tourism_tax + && isset($data.tourism_tax_status) + && $data.tourism_tax_status eq $tourism_tax_status_applied + && isset($data.tourism_tax_total) && $data.tourism_tax_total > 0} + +   + + + {/if} {convertPriceWithCurrency price=($data['total_room_price_ti']) currency=$currency->id} @@ -136,6 +150,23 @@ {l s='Delete'} + {if isset($use_tourism_tax) && $use_tourism_tax && isset($data.tourism_tax_status)} + {if $data.tourism_tax_status eq $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 743254785..15f21bf46 100644 --- a/admin/themes/default/template/controllers/orders/_rooms_informaion_table.tpl +++ b/admin/themes/default/template/controllers/orders/_rooms_informaion_table.tpl @@ -40,7 +40,7 @@ {l s='Refund/Cancel Status'} {l s='Refunded amount'} {/if} - {if ($can_edit && !$order->hasBeenDelivered())} + {if (isset($can_edit) && $can_edit && !$order->hasBeenDelivered())} {l s='Actions'} {/if} @@ -62,4 +62,83 @@ {l s='Room information not available.'}
    +{/if} + +{if isset($use_tourism_tax) && $use_tourism_tax} + + {/if} \ No newline at end of file diff --git a/admin/themes/default/template/controllers/orders/helpers/view/view.tpl b/admin/themes/default/template/controllers/orders/helpers/view/view.tpl index 3ed6b7448..4a4208baf 100644 --- a/admin/themes/default/template/controllers/orders/helpers/view/view.tpl +++ b/admin/themes/default/template/controllers/orders/helpers/view/view.tpl @@ -852,6 +852,18 @@ {l s='Add Rooms'} {/if} + {if isset($use_tourism_tax) && $use_tourism_tax && $order_detail_data} + {if isset($order_tourism_tax_has_applied) && $order_tourism_tax_has_applied} + + {/if} + {if isset($order_tourism_tax_has_unapplied) && $order_tourism_tax_has_unapplied} + + {/if} + {/if} {* by webkul this code is added for showing rooms information on the order detail page *} {include file='controllers/orders/_rooms_informaion_table.tpl'} diff --git a/admin/themes/default/template/controllers/products/prices.tpl b/admin/themes/default/template/controllers/products/prices.tpl index c27d0906c..e01d8cab8 100644 --- a/admin/themes/default/template/controllers/products/prices.tpl +++ b/admin/themes/default/template/controllers/products/prices.tpl @@ -193,6 +193,22 @@ $(document).ready(function () { + {if isset($tourismTaxRulesGroups)} +
    +
    + +
    + +
    +
    + {/if}
    @@ -465,6 +481,7 @@ $(document).ready(function () {
    + {if isset($specificPriceModificationForm)}

    {l s='Specific prices'}

    diff --git a/classes/Cart.php b/classes/Cart.php index d7421ec7d..0eca871d0 100644 --- a/classes/Cart.php +++ b/classes/Cart.php @@ -1638,6 +1638,7 @@ class CartCore extends ObjectModel } $products_total = array(); $ecotax_total = 0; + $tourism_tax_total = 0.0; $totalDemandsPrice = 0; $objCartBookingData = new HotelCartBookingData(); $objServiceProductCartDetail = new ServiceProductCartDetail(); @@ -1911,6 +1912,10 @@ 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))) { + $tourism_tax_total += $roomTotalPrice['tourism_tax_online']; + } } } } @@ -2052,6 +2057,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; } @@ -3942,6 +3951,15 @@ class CartCore extends ObjectModel if ($total_tax_without_discount < 0) { $total_tax_without_discount = 0; } + $tourism_tax_online = $total_tax - $total_tax_without_discount; + if ($tourism_tax_online > 0) { + if (Configuration::get('QLO_TOURISM_TAX_GROSSED_UP')) { + $total_rooms_wt += $tourism_tax_online; + $total_rooms_with_services_without_discount_ti += $tourism_tax_online; + } else { + $total_tax_without_discount += $tourism_tax_online; + } + } $summary = array( 'delivery' => $delivery, @@ -3988,7 +4006,9 @@ class CartCore extends ObjectModel 'total_rooms_with_services_without_discount_ti' => $total_rooms_with_services_without_discount_ti, '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 + 'total_tax_without_discount' => $total_tax_without_discount, + 'tourism_tax_online' => max(0.0, (float) $tourism_tax_online), + 'tourism_tax_grossed_up' => (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP'), ); $hook = Hook::exec('actionCartSummary', $summary, null, true); if (is_array($hook)) { diff --git a/classes/PaymentModule.php b/classes/PaymentModule.php index fe1ce4392..c76c981f5 100644 --- a/classes/PaymentModule.php +++ b/classes/PaymentModule.php @@ -902,6 +902,43 @@ abstract class PaymentModuleCore extends Module } } if ($objBookingDetail->save()) { + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $idTourismTrg = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue( + 'SELECT `id_tourism_tax_rules_group` + FROM `' . _DB_PREFIX_ . 'product_shop` product_shop + WHERE `id_product` = ' . (int) $idProduct + . Shop::addSqlRestriction(false, 'product_shop') + ); + if ($idTourismTrg) { + $htlTaxAddressId = Cart::getIdAddressForTaxCalculation($idProduct); + $htlTaxAddress = new Address($htlTaxAddressId ?: 0); + $checkInDt = new DateTime($objBookingDetail->date_from); + $checkOutDt = new DateTime($objBookingDetail->date_to); + $ttNumNights = max(1, (int) $checkInDt->diff($checkOutDt)->days); + $ttNumAdults = (int) $objCartBookingData->adults; + $ttChildAges = !empty($objCartBookingData->child_ages) ? (array) json_decode($objCartBookingData->child_ages, true) : array(); + $ttUnitTe = (float) $total_price['total_price_tax_excl'] / $ttNumNights; + $ttCollType = Validate::isLoadedObject($objHotelBranch) ? (int) $objHotelBranch->tourism_tax_collection_type : 0; + + HotelTourismTaxCalculator::computeAndSave( + $order->id, + (int) $id_order_detail, + $objBookingDetail->id, + (int) $objCartBookingData->id_hotel, + $idTourismTrg, + $htlTaxAddress, + $ttUnitTe, + $objBookingDetail->date_from, + $ttNumNights, + $ttNumAdults, + $ttChildAges, + (int) $this->context->cart->id_currency, + $ttCollType, + (int) $idLang + ); + } + } + // save extra demands info if ($objCartBookingData->extra_demands && ($extraDemands = json_decode($objCartBookingData->extra_demands, true)) diff --git a/classes/Product.php b/classes/Product.php index fc962d3c0..4f96668ac 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'), diff --git a/classes/order/OrderDetail.php b/classes/order/OrderDetail.php index cce75207a..4ea14e253 100644 --- a/classes/order/OrderDetail.php +++ b/classes/order/OrderDetail.php @@ -131,6 +131,9 @@ class OrderDetailCore extends ObjectModel /** @var float */ public $ecotax_tax_rate; + /** @var float Tourism tax charged online for this order detail line */ + public $tourism_tax_amount = 0; + /** @var int */ public $discount_quantity_applied; @@ -218,7 +221,8 @@ 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'), + 'tourism_tax_amount' => 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'), diff --git a/classes/order/OrderInvoice.php b/classes/order/OrderInvoice.php index 9d097b5d4..616e01bde 100644 --- a/classes/order/OrderInvoice.php +++ b/classes/order/OrderInvoice.php @@ -866,6 +866,16 @@ class OrderInvoiceCore extends ObjectModel * @since 1.5 * @return array */ + /** + * Return tourism tax breakdown grouped by id_tax for invoice display. + * + * @return array [['tax_name' => string, 'total_amount' => float], ...] + */ + public function getTourismTaxBreakdown() + { + return HotelOrderTourismTax::getBreakdownForInvoice((int) $this->id_order); + } + public function getEcoTaxTaxesBreakdown() { $result = Db::getInstance()->executeS(' diff --git a/classes/order/OrderReturn.php b/classes/order/OrderReturn.php index 3da74d335..cbcca6cb9 100644 --- a/classes/order/OrderReturn.php +++ b/classes/order/OrderReturn.php @@ -211,6 +211,8 @@ class OrderReturnCore extends ObjectModel $calcServicePriceFirst = true; } + $onlineTourismTaxByBooking = Configuration::get('QLO_USE_TOURISM_TAX') ? HotelOrderTourismTax::getOnlineTotalsByBooking($idOrder) : array(); + foreach ($returnDetails as $key => &$bookingRow) { if ($skipReqCompletedNonRefunded) { $objReturnState = new OrderReturnState($bookingRow['id_return_state']); @@ -224,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_online_amount'] = 0; + $bookingRow['tourism_tax_paid_amount'] = 0; $roomSelectedDemands = $objBookingDemands->getRoomTypeBookingExtraDemands( $idOrder, @@ -291,6 +295,12 @@ class OrderReturnCore extends ObjectModel } } } + $tourismTaxOnline = isset($onlineTourismTaxByBooking[(int)$bookingRow['id_htl_booking']]) ? (float) $onlineTourismTaxByBooking[(int)$bookingRow['id_htl_booking']] : 0.0; + $bookingRow['tourism_tax_online_amount'] = $tourismTaxOnline; + if ($tourismTaxOnline > 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 * $tourismTaxOnline / $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/pdf/HTMLTemplateInvoice.php b/classes/pdf/HTMLTemplateInvoice.php index e5b36b518..1a721b3b4 100644 --- a/classes/pdf/HTMLTemplateInvoice.php +++ b/classes/pdf/HTMLTemplateInvoice.php @@ -692,6 +692,22 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate $footer['total_tax_without_discount'] = 0; } + $ttBreakdown = Configuration::get('QLO_USE_TOURISM_TAX') ? HotelOrderTourismTax::getBreakdownForInvoice((int) $this->order->id) : array(); + $footer['tourism_tax_breakdown'] = $ttBreakdown; + $footer['tourism_tax_online'] = 0.0; + $footer['tourism_tax_at_hotel'] = 0.0; + foreach ($ttBreakdown as $ttRow) { + if ((int) $ttRow['collection_type'] === 0) { + $footer['tourism_tax_online'] += (float) $ttRow['total_amount']; + } else { + $footer['tourism_tax_at_hotel'] += (float) $ttRow['total_amount']; + } + } + + if ($footer['tourism_tax_online'] > 0) { + $footer['total_tax_without_discount'] = max(0.0, $footer['total_tax_without_discount'] - $footer['tourism_tax_online']); + } + $data = array( 'cart_htl_data' => $cart_htl_data, 'is_hotel_order' => HotelBookingDetail::getIdHotelByIdOrder($this->order->id), @@ -812,6 +828,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/tax/Tax.php b/classes/tax/Tax.php index a7f8ee1c3..13838239b 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 htl_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), ), ); diff --git a/classes/tax/TaxRulesGroup.php b/classes/tax/TaxRulesGroup.php index c37d6367a..140130443 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 = 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' => 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,32 @@ 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` = '.($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()); + $placeholder = $isTourismTax ? Tools::displayError('None') : Tools::displayError('No tax'); + $tax_rules = array(array('id_tax_rules_group' => 0, 'name' => $placeholder)); + return array_merge($tax_rules, TaxRulesGroup::getTaxRulesGroups($onlyActive, $isTourismTax)); } public function delete() diff --git a/controllers/admin/AdminNormalProductsController.php b/controllers/admin/AdminNormalProductsController.php index 43838ccc8..83f33dc6c 100644 --- a/controllers/admin/AdminNormalProductsController.php +++ b/controllers/admin/AdminNormalProductsController.php @@ -3373,6 +3373,11 @@ class AdminNormalProductsControllerCore extends AdminController // $data->assign('productFeaturePrices', $productFeaturePrices); // } + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $data->assign('tourismTaxRulesGroups', TaxRulesGroup::getTaxRulesGroupsForOptions(true, true)); + $data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0); + } + $this->tpl_form_vars['custom_form'] = $data->fetch(); } diff --git a/controllers/admin/AdminOrdersController.php b/controllers/admin/AdminOrdersController.php index 7b7c58fc2..f76246c52 100644 --- a/controllers/admin/AdminOrdersController.php +++ b/controllers/admin/AdminOrdersController.php @@ -1349,6 +1349,7 @@ class AdminOrdersControllerCore extends AdminController parent::setMedia(); $this->addJqueryUI('ui.datepicker'); + $this->addJqueryUI('ui.tooltip', 'base', true); $this->addJS(_PS_JS_DIR_.'vendor/d3.v3.min.js'); if ($this->display == 'view') { @@ -3397,12 +3398,21 @@ class AdminOrdersControllerCore extends AdminController $totalDemandsPriceTI = 0; $totalRefundedRooms = 0; $orderConvenienceFeeServices = array(); + $bookingTourismTaxMap = array(); + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $bookingTourismTaxMap = HotelOrderTourismTax::getOnlineTotalsByBooking($order->id); + } if ($order_detail_data = $objBookingDetail->getOrderFormatedBookinInfoByIdOrder($order->id)) { $objBookingDemand = new HotelBookingDemands(); $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']; + $order_detail_data[$key]['tourism_tax_amount'] = 0.0; + if (isset($bookingTourismTaxMap[$value['id']]) && $bookingTourismTaxMap[$value['id']] > 0) { + $order_detail_data[$key]['tourism_tax_amount'] = $bookingTourismTaxMap[$value['id']]; + $order_detail_data[$key]['total_room_price_ti'] += $bookingTourismTaxMap[$value['id']]; + } $order_detail_data[$key]['extra_demands'] = $objBookingDemand->getRoomTypeBookingExtraDemands( $order->id, @@ -3572,7 +3582,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']) + $order_detail_data[$key]['tourism_tax_amount']; $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; @@ -3580,6 +3590,10 @@ 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']; + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $order_detail_data[$key]['tourism_tax_status'] = HotelOrderTourismTax::getStatusForBooking($value['id']); + $order_detail_data[$key]['tourism_tax_total'] = HotelOrderTourismTax::getTotalForBooking($value['id']); + } if (isset($value['refund_info']) && $value['refund_info']['refunded'] @@ -3594,6 +3608,20 @@ class AdminOrdersControllerCore extends AdminController $order_detail_data = array(); } + $orderTourismTaxHasApplied = false; + $orderTourismTaxHasUnapplied = false; + if (Configuration::get('QLO_USE_TOURISM_TAX') && $order_detail_data) { + foreach ($order_detail_data as $bookingRow) { + if (isset($bookingRow['tourism_tax_status'])) { + if ($bookingRow['tourism_tax_status'] === HotelOrderTourismTax::STATUS_APPLIED) { + $orderTourismTaxHasApplied = true; + } else { + $orderTourismTaxHasUnapplied = true; + } + } + } + } + if ($orderedRooms = $objServiceProductOrderDetail->getRoomTypeServiceProducts( $order->id, 0, @@ -3722,6 +3750,12 @@ class AdminOrdersControllerCore extends AdminController 'htl_booking_order_data' => $bookingOrderInfo, 'hotel_order_status' => $htlOrderStatus, 'order_detail_data' => $order_detail_data, + 'use_tourism_tax' => (bool) Configuration::get('QLO_USE_TOURISM_TAX'), + 'info_icon_path' => $this->context->link->getMediaLink(_MODULE_DIR_.'hotelreservationsystem/views/img/Slices/icon-info.svg'), + 'tourism_tax_status_applied' => HotelOrderTourismTax::STATUS_APPLIED, + 'tourism_tax_status_exempted' => HotelOrderTourismTax::STATUS_EXEMPTED, + 'order_tourism_tax_has_applied' => $orderTourismTaxHasApplied, + 'order_tourism_tax_has_unapplied' => $orderTourismTaxHasUnapplied, 'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'), 'hotel_service_products' => $orderHotelServiceProducts, 'standalone_service_products' => $orderStandaloneServiceProducts, @@ -8947,4 +8981,93 @@ class AdminOrdersControllerCore extends AdminController $this->ajaxDie(json_encode($result)); } + + /** + * Apply tourism tax to a single booking (id_htl_booking) or all bookings of an order (id_order). + */ + public function ajaxProcessApplyTourismTax() + { + $idHtlBooking = (int) Tools::getValue('id_htl_booking', 0); + $idOrder = (int) Tools::getValue('id_order', 0); + $errors = array(); + + if ($idHtlBooking) { + $result = HotelOrderTourismTax::applyForBooking($idHtlBooking); + if ($result === HotelOrderTourismTax::APPLY_ERROR_RESTORE) { + $errors[] = $this->l('Could not restore tourism tax for this booking.'); + } elseif ($result === HotelOrderTourismTax::APPLY_ERROR_NO_RULE) { + $errors[] = $this->l('No tourism tax rule is assigned to this room type.'); + } elseif ($result === HotelOrderTourismTax::APPLY_ERROR_REFUNDED) { + $errors[] = $this->l('Tourism tax cannot be applied to a refunded booking.'); + } + } elseif ($idOrder) { + $hasNoRuleError = false; + $hasRestoreError = false; + foreach (HotelOrderTourismTax::applyForOrder($idOrder) as $failure) { + if ($failure['error'] === HotelOrderTourismTax::APPLY_ERROR_RESTORE) { + $hasRestoreError = true; + } else { + $hasNoRuleError = true; + } + } + if ($hasRestoreError) { + $errors[] = $this->l('Could not restore tourism tax for one or more bookings.'); + } + if ($hasNoRuleError) { + $errors[] = $this->l('One or more bookings do not have a tourism tax rule assigned.'); + } + } else { + $errors[] = $this->l('Invalid request parameters.'); + } + + die(Tools::jsonEncode(array( + 'hasError' => !empty($errors), + 'errors' => $errors, + ))); + } + + /** + * Exempt tourism tax for a single booking (id_htl_booking) or all bookings of an order (id_order). + */ + public function ajaxProcessExemptTourismTax() + { + $idHtlBooking = (int) Tools::getValue('id_htl_booking', 0); + $idOrder = (int) Tools::getValue('id_order', 0); + $idEmployee = (int) Context::getContext()->employee->id; + $errors = array(); + + if ($idHtlBooking) { + $result = HotelOrderTourismTax::exemptIfApplied($idHtlBooking, $idEmployee); + if ($result === HotelOrderTourismTax::EXEMPT_ERROR_NO_RULE) { + $errors[] = $this->l('No tourism tax rule is assigned to this room type.'); + } elseif ($result === HotelOrderTourismTax::EXEMPT_ERROR_SAVE) { + $errors[] = $this->l('Could not exempt tourism tax for this booking.'); + } elseif ($result === HotelOrderTourismTax::EXEMPT_ERROR_REFUNDED) { + $errors[] = $this->l('Tourism tax cannot be exempted for a refunded booking.'); + } + } elseif ($idOrder) { + $hasNoRuleError = false; + $hasSaveError = false; + foreach (HotelOrderTourismTax::exemptForOrder($idOrder, $idEmployee) as $failure) { + if ($failure['error'] === HotelOrderTourismTax::EXEMPT_ERROR_NO_RULE) { + $hasNoRuleError = true; + } else { + $hasSaveError = true; + } + } + if ($hasNoRuleError) { + $errors[] = $this->l('One or more bookings do not have a tourism tax rule assigned.'); + } + if ($hasSaveError) { + $errors[] = $this->l('Could not exempt tourism tax for one or more bookings.'); + } + } 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 9b8e1ab48..25fcc213e 100644 --- a/controllers/admin/AdminProductsController.php +++ b/controllers/admin/AdminProductsController.php @@ -4157,6 +4157,11 @@ class AdminProductsControllerCore extends AdminController $data->assign('productFeaturePrices', $productFeaturePrices); } + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $data->assign('tourismTaxRulesGroups', TaxRulesGroup::getTaxRulesGroupsForOptions(true, true)); + $data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0); + } + $this->tpl_form_vars['custom_form'] = $data->fetch(); } diff --git a/controllers/admin/AdminTaxRulesGroupController.php b/controllers/admin/AdminTaxRulesGroupController.php index 894e7f257..3a64f4d3f 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() + { + $idTrg = (int) Tools::getValue('id_tax_rules_group'); + $isTourism = false; + if ($idTrg) { + $trg = new TaxRulesGroup($idTrg); + if (Validate::isLoadedObject($trg)) { + $isTourism = (bool) $trg->is_tourism_tax_rule; + } + } + + $idLang = (int) $this->context->language->id; + $sql = new DbQuery(); + if ($isTourism) { + $sql->select( + 't.`id_tax`, tl.`name`,' + . ' IFNULL(ht.`tax_type`, 0) AS ht_tax_type,' + . ' IFNULL(ht.`tax_value`, 0) AS ht_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('htl_tourism_tax', 'ht', 'ht.`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['ht_tax_type'] === 1) + ? (float) $row['ht_tax_value'] . '%' + : $currencySign . ' ' . (float) $row['ht_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(ht.`tax_type`, 0) AS ht_tax_type, + IFNULL(ht.`tax_value`, 0) AS ht_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_.'htl_tourism_tax` ht + ON (ht.`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', + '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,86 @@ 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, ht_tax_type, ht_tax_value + * @return string + */ + public function displayTaxRuleRate($value, $row) + { + if (!(int) $row['is_tourism_tax']) { + return Tools::safeOutput($value) . '%'; + } + $displayValue = Tools::safeOutput((float) $row['ht_tax_value']); + if ((int) $row['ht_tax_type'] === 1) { + return $displayValue . '%'; + } + $currency = $this->context->currency; + $currencySign = Tools::safeOutput(trim($currency->prefix . $currency->suffix)); + return $currencySign . ' ' . $displayValue; + } + + public function postProcess() + { + if ($this->action == 'save') { + $idGroup = (int) Tools::getValue('id_tax_rules_group'); + $newIsTourism = (int) Tools::getValue('is_tourism_tax_rule'); + + if ($idGroup) { + $trg = new TaxRulesGroup($idGroup); + if (Validate::isLoadedObject($trg)) { + $oldIsTourism = (int) $trg->is_tourism_tax_rule; + + // Tourism → VAT: block while tourism tax rules still exist in this group. + if ($oldIsTourism === 1 && $newIsTourism === 0) { + $hasConflict = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue( + 'SELECT COUNT(*) 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` = ' . $idGroup . ' + AND t.`is_tourism_tax` = 1' + ); + if ($hasConflict) { + $this->errors[] = $this->l( + 'Cannot convert this to a VAT group: it still contains tourism taxes. Remove the tourism tax rules first.' + ); + // Reset POST to DB state: getFieldValue() prefers $_POST over the DB + // object, so without this reset the toggle, behavior dropdown, and tax + // dropdown would render inconsistently (toggle=NO, sub-forms=tourism state). + $_POST['is_tourism_tax_rule'] = $oldIsTourism; + $this->display = 'edit'; + return; + } + } + + // VAT → Tourism: block while regular VAT tax rules still exist in this group. + // "No Tax" rules (id_tax = 0) are type-neutral and are excluded from the check. + if ($oldIsTourism === 0 && $newIsTourism === 1) { + $hasConflict = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue( + 'SELECT COUNT(*) 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` = ' . $idGroup . ' + AND tr.`id_tax` != 0 + AND t.`is_tourism_tax` = 0' + ); + if ($hasConflict) { + $this->errors[] = $this->l( + 'Cannot convert this to a tourism group: it still contains VAT taxes. Remove the VAT tax rules first.' + ); + // Same reason: reset POST to DB state so the whole form is consistent. + $_POST['is_tourism_tax_rule'] = $oldIsTourism; + $this->display = 'edit'; + return; + } + } + } + } + } + parent::postProcess(); + } + public function initProcess() { if (Tools::isSubmit('deletetax_rule')) { diff --git a/controllers/admin/AdminTaxesController.php b/controllers/admin/AdminTaxesController.php index ca2787e05..0997006c2 100644 --- a/controllers/admin/AdminTaxesController.php +++ b/controllers/admin/AdminTaxesController.php @@ -49,7 +49,21 @@ 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, + 'search' => false, + ), + '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) ); @@ -110,9 +124,30 @@ class AdminTaxesControllerCore extends AdminController ); } + $this->fields_options['general']['fields']['QLO_USE_TOURISM_TAX'] = array( + 'title' => $this->l('Use tourism tax'), + 'hint' => $this->l('Enable tourism / city 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 room price'), + 'hint' => $this->l('Yes: rolled into the headline room price. No: shown as a separate line item.'), + 'validation' => 'isBool', + 'cast' => 'intval', + 'type' => 'bool', + ); + } + parent::__construct(); $this->_where .= ' AND a.deleted = 0'; + $this->_join .= ' LEFT JOIN `'._DB_PREFIX_.'htl_tourism_tax` ht ON ht.`id_tax` = a.`id_tax`'; + $extraSelect = 'IFNULL(ht.`tax_type`, 0) AS `ht_tax_type`, IFNULL(ht.`tax_value`, 0) AS `ht_tax_value`'; + $this->_select = ($this->_select ? $this->_select . ', ' : '') . $extraSelect; } public function initPageHeaderToolbar() @@ -189,27 +224,247 @@ class AdminTaxesControllerCore extends AdminController public function renderForm() { + $idTax = (int) Tools::getValue('id_' . $this->table); + $subtype = null; + $tiers = array(); + $childRanges = 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 = HotelTourismTax::getByTaxId($idTax); + if ($subtype) { + $_POST['htl_tax_type'] = $subtype->tax_type; + $_POST['htl_is_per_night'] = $subtype->is_per_night; + $_POST['htl_is_per_person'] = $subtype->is_per_person; + $_POST['htl_tax_value'] = $subtype->tax_value; + $_POST['htl_is_tiered'] = $subtype->is_tiered; + $_POST['htl_has_child_rate'] = $subtype->has_child_rate; + $_POST['htl_valid_from'] = $subtype->valid_from ?: ''; + $_POST['htl_valid_to'] = $subtype->valid_to ?: ''; + + $tiers = HotelTourismTaxTier::getByTaxId($idTax); + $childRanges = HotelTourismTaxChildRange::getByTaxId($idTax); + } + } elseif ($isAnySubmit) { + $postTierIds = (array) Tools::getValue('htl_tier_id', array()); + $postTierMins = (array) Tools::getValue('htl_tier_min', array()); + $postTierMaxs = (array) Tools::getValue('htl_tier_max', array()); + $postTierValues = (array) Tools::getValue('htl_tier_value', array()); + foreach ($postTierIds as $i => $tierId) { + $tiers[] = array( + 'id_tier' => (int) $tierId, + 'min_amount' => isset($postTierMins[$i]) ? $postTierMins[$i] : '', + 'max_amount' => isset($postTierMaxs[$i]) ? $postTierMaxs[$i] : '', + 'tax_value' => isset($postTierValues[$i]) ? $postTierValues[$i] : '', + ); + } + + $postChildIds = (array) Tools::getValue('htl_child_id', array()); + $postChildMins = (array) Tools::getValue('htl_child_min', array()); + $postChildMaxs = (array) Tools::getValue('htl_child_max', array()); + $postChildValues = (array) Tools::getValue('htl_child_value', array()); + foreach ($postChildIds as $i => $childId) { + $childRanges[] = array( + 'id_child_range' => (int) $childId, + 'min_age' => isset($postChildMins[$i]) ? $postChildMins[$i] : '', + 'max_age' => isset($postChildMaxs[$i]) ? $postChildMaxs[$i] : '', + 'tax_value' => isset($postChildValues[$i]) ? $postChildValues[$i] : '', + ); + } + } + + $taxType = (int) Tools::getValue('htl_tax_type', 0); + $baseTaxValue = (float) Tools::getValue('htl_tax_value', $subtype ? $subtype->tax_value : 0); + $allDayKeys = array('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); + if ($subtype && (int) $subtype->is_special_days_exists && $subtype->special_days) { + $defaultSpecialDays = json_decode($subtype->special_days, true) ?: $allDayKeys; + } else { + $defaultSpecialDays = $allDayKeys; + } + $specialDaysChecked = Tools::getValue('htl_valid_days', $defaultSpecialDays); + if (!is_array($specialDaysChecked)) { + $specialDaysChecked = $allDayKeys; + } + + $initialTypeSign = ($taxType == 1) ? '%' : $currencySign; + $requiredMarker = ' *'; + $taxValueInputHtml = ' +
    + ' . $initialTypeSign . ' + +
    '; + $rateCurrentValue = (float) Tools::getValue('rate', (isset($this->object) && $this->object) ? $this->object->rate : 0); + $rateInputHtml = ' +
    + % + +
    '; + $priceBracketsHtml = ' + + + + + + + + '; + foreach ($tiers as $tierRowIndex => $tier) { + $priceBracketsHtml .= $this->buildTierRow($tierRowIndex, $tier); + } + $priceBracketsHtml .= ' + +
    ' . $this->l('Min price (from)') . $requiredMarker . '' . $this->l('Max price (up to, 0 = no cap)') . $requiredMarker . '' . $this->l('Tax value') . $requiredMarker . '
    +
    + +
    + '; + + $ageBandsHtml = ' + + + + + + + + '; + foreach ($childRanges as $rangeRowIndex => $range) { + $ageBandsHtml .= $this->buildChildRangeRow($rangeRowIndex, $range); + } + $ageBandsHtml .= ' + +
    ' . $this->l('Min age') . '' . $this->l('Max age (0 = no cap)') . '' . $this->l('Tax value') . $requiredMarker . '
    +
    + +
    + '; + + $validFromValue = Tools::getValue('htl_valid_from', $subtype ? ($subtype->valid_from ?: '') : ''); + $validToValue = Tools::getValue('htl_valid_to', $subtype ? ($subtype->valid_to ?: '') : ''); + $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 .= '
    '; + + $validityInputHtml = ' +
    +
    +
    + ' . $this->l('From') . ' + + +
    +
    +
    +
    + ' . $this->l('To') . ' + + +
    + +
    +
    '; + $this->fields_form = array( 'legend' => array( 'title' => $this->l('Taxes'), - 'icon' => 'icon-money' + 'icon' => 'icon-money', ), 'input' => array( + array( + 'type' => 'switch', + 'label' => $this->l('Is tourism tax'), + 'name' => 'is_tourism_tax', + 'is_bool' => true, + 'hint' => $this->l('When enabled, this tax is used as a tourism / city tax. Rate is forced to 0 and parameters below apply.'), + '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' => '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').' <>;=#{}' + 'hint' => $this->l('Tax name to display in carts and on invoices (e.g. "City Tax").') + . ' - ' . $this->l('Invalid characters') . ' <>;=#{}', ), array( - 'type' => 'text', + 'type' => 'select', + 'label' => $this->l('Tax Calculation Type'), + 'name' => 'htl_tax_type', + 'form_group_class' => 'tt-field', + 'options' => array( + 'query' => array( + array('id' => 0, 'name' => $this->l('Fixed amount per unit')), + array('id' => 1, 'name' => $this->l('Percentage of room price (excl. VAT)')), + ), + 'id' => 'id', + 'name' => 'name', + ), + ), + array( + 'type' => 'html', + 'label' => $this->l('Tax value'), + 'name' => 'htl_tax_value_wrap', + 'html_content' => $taxValueInputHtml, + 'hint' => $this->l('Leave 0 and enable tiered pricing below for bracket-based rates.'), + 'form_group_class' => 'tt-field', + ), + array( + 'type' => 'html', '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').' <>;=#{}' + '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' => 'tt-non-tourism', + ), + array( + 'type' => 'select', + 'label' => $this->l('Price calculation method'), + 'name' => 'htl_is_per_night', + 'hint' => $this->l('Select whether the tax amount is applied per night or once for the entire booking range.'), + 'form_group_class' => 'tt-field', + 'options' => array( + 'query' => array( + array('id' => 1, 'name' => $this->l('Add per each night of booking')), + array('id' => 0, 'name' => $this->l('Add once for the booking range')), + ), + 'id' => 'id', + 'name' => 'name', + ), ), array( 'type' => 'switch', @@ -229,15 +484,81 @@ class AdminTaxesControllerCore extends AdminController 'label' => $this->l('Disabled') ) ) - ) - ), - 'submit' => array( - 'title' => $this->l('Save') + ), + array( + 'type' => 'switch', + 'label' => $this->l('Per person (occupancy wise)'), + 'name' => 'htl_is_per_person', + 'is_bool' => true, + 'hint' => $this->l('When enabled, the tax amount multiplies by the number of adult occupants.'), + 'form_group_class' => 'tt-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' => 'htl_has_child_rate', + 'is_bool' => true, + 'hint' => $this->l('Define the age band below. Children within the band pay the band\'s rate; all others pay the adult rate.'), + 'form_group_class' => 'tt-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' => 'html', + 'label' => $this->l('Child age bands'), + 'name' => 'htl_child_ranges', + 'html_content' => $ageBandsHtml, + 'form_group_class' => 'tt-field tt-child-field', + ), + array( + 'type' => 'switch', + 'label' => $this->l('Tiered pricing'), + 'name' => 'htl_is_tiered', + 'is_bool' => true, + 'hint' => $this->l('When a room price falls within a bracket, that bracket\'s value is used. Prices outside all brackets fall back to the base Tax value above.'), + 'form_group_class' => 'tt-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 brackets'), + 'name' => 'htl_tiers', + 'html_content' => $priceBracketsHtml, + 'form_group_class' => 'tt-field tt-tiered-field', + ), + array( + 'type' => 'html', + 'label' => $this->l('Valid'), + 'name' => 'htl_validity_wrap', + 'html_content' => $validityInputHtml, + 'hint' => $this->l('Leave blank for always active.'), + 'form_group_class' => 'tt-field', + ), + array( + 'type' => 'html', + 'label' => $this->l('Valid days'), + 'name' => 'htl_valid_days_wrap', + 'html_content' => $weekdayCheckboxesHtml + . '

    ' + . $this->l('Uncheck days when the tax should not apply within the date range.') + . '

    ', + 'form_group_class' => 'tt-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 +569,162 @@ class AdminTaxesControllerCore extends AdminController return parent::renderForm(); } + /** + * @param int|string $idx Row index or '__IDX__' for JS template + * @param array $tier + * @return string + */ + protected function buildTierRow($idx, array $tier) + { + $min = isset($tier['min_amount']) ? (float) $tier['min_amount'] : ''; + $max = isset($tier['max_amount']) ? (float) $tier['max_amount'] : ''; + $val = isset($tier['tax_value']) ? (float) $tier['tax_value'] : ''; + $id = isset($tier['id_tier']) ? (int) $tier['id_tier'] : 0; + + return ' + + + + + + + + +
    + + +
    + + + + + + + + '; + } + + /** + * @param int|string $idx Row index or '__IDX__' for JS template + * @param array $range + * @return string + */ + protected function buildChildRangeRow($idx, array $range) + { + $min = isset($range['min_age']) ? (int) $range['min_age'] : ''; + $max = isset($range['max_age']) ? (int) $range['max_age'] : ''; + $val = isset($range['tax_value']) ? (float) $range['tax_value'] : ''; + $id = isset($range['id_child_range']) ? (int) $range['id_child_range'] : 0; + + return ' + + + + + + + + +
    + + +
    + + + + + + + + '; + } + + /** + * 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 ht_tax_type, ht_tax_value from join) + * @return string + */ + public function displayRate($value, $row) + { + if (!(int) $row['is_tourism_tax']) { + return Tools::safeOutput($value) . '%'; + } + $displayValue = Tools::safeOutput((float) $row['ht_tax_value']); + if ((int) $row['ht_tax_type'] === 1) { + return $displayValue . '%'; + } + $activeCurrency = $this->context->currency; + $currencySign = Tools::safeOutput(trim($activeCurrency->prefix . $activeCurrency->suffix)); + return $currencySign . ' ' . $displayValue; + } + 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); + if ($isTourismTax) { + $object->rate = 0; + } $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 ($object->id !== $existingTaxId) { + $oldSubtype = HotelTourismTax::getByTaxId($existingTaxId); + if ($oldSubtype) { + $oldSubtype->delete(); + } + } + if ($isTourismTax) { + $this->saveTourismTaxSubtype($object->id); + } + if ($wasTourismTax && !$isTourismTax) { + $this->cleanTaxFromTourismTrgs($object->id); + } + if (!$wasTourismTax && $isTourismTax) { + $this->cleanTaxFromVatTrgs($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 { @@ -283,15 +741,20 @@ class AdminTaxesControllerCore extends AdminController /** @var Tax $object */ $object = new $this->className(); $this->copyFromPost($object, $this->table); + if ($isTourismTax) { + $object->rate = 0; + } if (!$object->add()) { $this->errors[] = Tools::displayError('An error occurred while creating an object.').' '.$this->table.''; } elseif ($this->postImage($object->id)) { + if ($isTourismTax) { + $this->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 +765,226 @@ 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('htl_tax_value', ''); + if ($taxValue !== '') { + if (!is_numeric($taxValue)) { + $this->errors[] = $this->l('Tax value must be a valid number.'); + } elseif ((float) $taxValue < 0) { + $this->errors[] = $this->l('Tax value cannot be negative.'); + } + } + + $validFrom = trim(Tools::getValue('htl_valid_from', '')); + $validTo = trim(Tools::getValue('htl_valid_to', '')); + if ($validFrom === '0000-00-00') { $validFrom = ''; } + if ($validTo === '0000-00-00') { $validTo = ''; } + if ($validFrom !== '' && !Validate::isDate($validFrom)) { + $this->errors[] = $this->l('"Valid from" is not a valid date (use YYYY-MM-DD).'); + } + if ($validTo !== '' && !Validate::isDate($validTo)) { + $this->errors[] = $this->l('"Valid to" is not a valid date (use YYYY-MM-DD).'); + } + if ($validFrom !== '' && $validTo !== '' + && Validate::isDate($validFrom) && Validate::isDate($validTo) + && strtotime($validTo) < strtotime($validFrom) + ) { + $this->errors[] = $this->l('"Valid to" must be on or after "Valid from".'); + } + + if ((int) Tools::getValue('htl_has_child_rate')) { + $submittedBandMins = Tools::getValue('htl_child_min', array()); + $submittedBandMaxs = Tools::getValue('htl_child_max', array()); + $submittedBandValues = Tools::getValue('htl_child_value', array()); + $filledBands = array(); + foreach ($submittedBandValues as $rowIndex => $taxValue) { + if ($taxValue === '' || $taxValue === false) { + continue; + } + $rowNum = (int) $rowIndex + 1; + $bandMin = (isset($submittedBandMins[$rowIndex]) && $submittedBandMins[$rowIndex] !== '') + ? (int) $submittedBandMins[$rowIndex] : 0; + $bandMax = (isset($submittedBandMaxs[$rowIndex]) && $submittedBandMaxs[$rowIndex] !== '') + ? (int) $submittedBandMaxs[$rowIndex] : 0; + if (!is_numeric($taxValue)) { + $this->errors[] = sprintf($this->l('Child band #%d: tax value must be a valid number.'), $rowNum); + } elseif ((float) $taxValue < 0) { + $this->errors[] = sprintf($this->l('Child band #%d: tax value cannot be negative.'), $rowNum); + } + if ($bandMax > 0 && $bandMin > $bandMax) { + $this->errors[] = sprintf($this->l('Child band #%d: min age cannot be greater than max age.'), $rowNum); + } + $filledBands[] = array('minAge' => $bandMin, 'maxAge' => $bandMax, 'rowNum' => $rowNum); + } + $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 bands #%d and #%d overlap. Each band must cover a distinct age range.'), + $outerBand['rowNum'], + $innerBand['rowNum'] + ); + break 2; + } + } + } + } + + if ((int) Tools::getValue('htl_is_tiered')) { + $submittedTierMins = Tools::getValue('htl_tier_min', array()); + $submittedTierMaxs = Tools::getValue('htl_tier_max', array()); + $submittedTierValues = Tools::getValue('htl_tier_value', array()); + $filledTiers = array(); + foreach ($submittedTierValues as $rowIndex => $taxValue) { + if ($taxValue === '' || $taxValue === false) { + continue; + } + $rowNum = (int) $rowIndex + 1; + $tierMin = (isset($submittedTierMins[$rowIndex]) && $submittedTierMins[$rowIndex] !== '') + ? (float) $submittedTierMins[$rowIndex] : 0.0; + $tierMax = (isset($submittedTierMaxs[$rowIndex]) && $submittedTierMaxs[$rowIndex] !== '') + ? (float) $submittedTierMaxs[$rowIndex] : 0.0; + if (!is_numeric($taxValue)) { + $this->errors[] = sprintf($this->l('Tier #%d: tax value must be a valid number.'), $rowNum); + } elseif ((float) $taxValue < 0) { + $this->errors[] = sprintf($this->l('Tier #%d: tax value cannot be negative.'), $rowNum); + } + if ($tierMax > 0 && $tierMin > $tierMax) { + $this->errors[] = sprintf($this->l('Tier #%d: min price cannot be greater than max price.'), $rowNum); + } + $filledTiers[] = array('minPrice' => $tierMin, 'maxPrice' => $tierMax, 'rowNum' => $rowNum); + } + $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 brackets #%d and #%d overlap. Each bracket must cover a distinct price range.'), + $outerTier['rowNum'], + $innerTier['rowNum'] + ); + break 2; + } + } + } + } + } + + public function setMedia() + { + parent::setMedia(); + if ($this->display == 'edit' || $this->display == 'add') { + $this->addCSS(_MODULE_DIR_ . 'hotelreservationsystem/views/css/TourismTaxAdmin.css'); + $this->addJs(_MODULE_DIR_ . 'hotelreservationsystem/views/js/TourismTaxForm.js'); + } + } + + /** + * @param int $idTax + */ + protected function saveTourismTaxSubtype($idTax) + { + $tourismTax = HotelTourismTax::getByTaxId((int) $idTax); + if (!$tourismTax) { + $tourismTax = new HotelTourismTax(); + $tourismTax->id = (int) $idTax; + } + $tourismTax->tax_type = (int) Tools::getValue('htl_tax_type', 0); + $tourismTax->is_per_night = (int) Tools::getValue('htl_is_per_night', 1); + $tourismTax->is_per_person = (int) Tools::getValue('htl_is_per_person', 0); + $tourismTax->tax_value = (float) Tools::getValue('htl_tax_value', 0); + $tourismTax->is_tiered = (int) Tools::getValue('htl_is_tiered', 0); + $tourismTax->has_child_rate = (int) Tools::getValue('htl_has_child_rate', 0); + $validFromRaw = trim(Tools::getValue('htl_valid_from', '')); + $validToRaw = trim(Tools::getValue('htl_valid_to', '')); + $tourismTax->valid_from = ($validFromRaw && $validFromRaw !== '0000-00-00') ? $validFromRaw : null; + $tourismTax->valid_to = ($validToRaw && $validToRaw !== '0000-00-00') ? $validToRaw : null; + + $submittedDays = Tools::getValue('htl_valid_days', array()); + if (!is_array($submittedDays)) { + $submittedDays = array(); + } + $validDayKeys = array('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); + $submittedDays = array_values(array_intersect($validDayKeys, $submittedDays)); + if (empty($submittedDays) || count($submittedDays) === 7) { + $tourismTax->is_special_days_exists = 0; + $tourismTax->special_days = null; + } else { + $tourismTax->is_special_days_exists = 1; + $tourismTax->special_days = json_encode($submittedDays); + } + + if (HotelTourismTax::existsForTax((int) $idTax)) { + $tourismTax->update(); + } else { + $tourismTax->add(); + } + + HotelTourismTaxTier::saveAll( + (int) $idTax, + Tools::getValue('htl_tier_min', array()), + Tools::getValue('htl_tier_max', array()), + Tools::getValue('htl_tier_value', array()) + ); + HotelTourismTaxChildRange::saveAll( + (int) $idTax, + Tools::getValue('htl_child_min', array()), + Tools::getValue('htl_child_max', array()), + Tools::getValue('htl_child_value', array()) + ); + } + + /** + * Remove this tax from all tourism TRGs when it is demoted from tourism to regular. + * Flashes a warning listing how many rule rows were deleted. + * + * @param int $idTax + */ + protected function cleanTaxFromTourismTrgs($idTax) + { + $count = HotelTourismTax::cleanFromTourismTrgs((int) $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) TRGs when it is promoted to a tourism tax. + * Flashes a warning listing how many rule rows were deleted. + * + * @param int $idTax + */ + protected function cleanTaxFromVatTrgs($idTax) + { + $count = HotelTourismTax::cleanFromVatTrgs((int) $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/OrderConfirmationController.php b/controllers/front/OrderConfirmationController.php index f00addd43..30fd8fd62 100644 --- a/controllers/front/OrderConfirmationController.php +++ b/controllers/front/OrderConfirmationController.php @@ -135,6 +135,8 @@ class OrderConfirmationControllerCore extends FrontController $orderTotalInfo['total_paid_real'] = 0; $orderTotalInfo['total_wrapping'] = 0; $orderTotalInfo['total_order_amount'] = 0; + $orderTotalInfo['tourism_tax_online'] = 0.0; + $orderTotalInfo['tourism_tax_at_hotel'] = 0.0; $orders_has_invoice = 1; if ($cartOrders = Order::getAllOrdersByCartId($order->id_cart)) { @@ -432,6 +434,17 @@ 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; + + if (Configuration::get('QLO_USE_TOURISM_TAX')) { + $ttBreakdown = HotelOrderTourismTax::getBreakdownForInvoice($idOrder); + foreach ($ttBreakdown as $ttRow) { + if ((int) $ttRow['collection_type'] === 0) { + $orderTotalInfo['tourism_tax_online'] += (float) $ttRow['total_amount']; + } else { + $orderTotalInfo['tourism_tax_at_hotel'] += (float) $ttRow['total_amount']; + } + } + } } $totalTaxIncl = $orderTotalInfo['total_rooms_ti'] + $orderTotalInfo['total_services_ti'] + $orderTotalInfo['total_convenience_fee_ti'] + $orderTotalInfo['total_auto_add_services_ti'] + $orderTotalInfo['total_demands_price_ti'] + $orderTotalInfo['total_standalone_products_ti']; diff --git a/controllers/front/OrderDetailController.php b/controllers/front/OrderDetailController.php index 25111fbad..cf8ca6624 100644 --- a/controllers/front/OrderDetailController.php +++ b/controllers/front/OrderDetailController.php @@ -429,6 +429,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, + 'tourism_tax_breakdown' => Configuration::get('QLO_USE_TOURISM_TAX') ? HotelOrderTourismTax::getBreakdownForInvoice((int) $id_order) : array(), 'total_demands_price_ti' => $total_demands_price_ti, 'total_demands_price_te' => $total_demands_price_te, 'any_back_order' => $anyBackOrder, diff --git a/install/data/db_structure.sql b/install/data/db_structure.sql index 9520ec459..8c8869938 100644 --- a/install/data/db_structure.sql +++ b/install/data/db_structure.sql @@ -1277,6 +1277,7 @@ CREATE TABLE `PREFIX_order_detail` ( `tax_rate` DECIMAL(10,3) NOT NULL DEFAULT '0.000', `ecotax` decimal(21,6) NOT NULL DEFAULT '0.00', `ecotax_tax_rate` DECIMAL(5,3) NOT NULL DEFAULT '0.000', + `tourism_tax_amount` DECIMAL(21,6) NOT NULL DEFAULT '0.000000', `discount_quantity_applied` TINYINT(1) NOT NULL DEFAULT '0', `download_hash` varchar(255) DEFAULT NULL, `download_nb` int(10) unsigned DEFAULT '0', @@ -1527,6 +1528,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, @@ -1590,6 +1592,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', @@ -1988,6 +1991,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`) @@ -2113,6 +2117,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` 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/modules/hotelreservationsystem/classes/HotelBookingDetail.php b/modules/hotelreservationsystem/classes/HotelBookingDetail.php index 68086cde4..f4529ec1c 100644 --- a/modules/hotelreservationsystem/classes/HotelBookingDetail.php +++ b/modules/hotelreservationsystem/classes/HotelBookingDetail.php @@ -2517,6 +2517,35 @@ class HotelBookingDetail extends ObjectModel $data['is_cancelled'] = (int) $is_cancelled; } + $tourismBookingIds = array(); + if ((int) $is_refunded && Configuration::get('QLO_USE_TOURISM_TAX')) { + if ($id_rooms) { + foreach ($id_rooms as $val_rm) { + $bid = (int) Db::getInstance()->getValue( + 'SELECT `id` FROM `'._DB_PREFIX_.'htl_booking_detail` + WHERE `id_order` = '.(int) $id_order.' + AND `id_room` = '.(int) $val_rm['id_room'].' + AND `date_from` = \''.pSQL($date_from).'\' + AND `date_to` = \''.pSQL($date_to).'\' + AND `is_refunded` = 0' + ); + if ($bid) { + $tourismBookingIds[] = $bid; + } + } + } else { + $rows = Db::getInstance()->executeS( + 'SELECT `id` FROM `'._DB_PREFIX_.'htl_booking_detail` + WHERE `id_order` = '.(int) $id_order.' AND `is_refunded` = 0' + ); + if ($rows) { + foreach ($rows as $row) { + $tourismBookingIds[] = (int) $row['id']; + } + } + } + } + if ($id_rooms) { foreach ($id_rooms as $key_rm => $val_rm) { $where = 'id_order='.(int)$id_order.' AND id_room = '.(int)$val_rm['id_room'].' AND `date_from`= \''. @@ -2527,6 +2556,12 @@ class HotelBookingDetail extends ObjectModel $result = Db::getInstance()->update($table, $data, 'id_order='.(int)$id_order); } + if ($result && $tourismBookingIds) { + foreach ($tourismBookingIds as $bid) { + HotelOrderTourismTax::setRefunded($bid); + } + } + // if automatic overbooking resolution is enabled if ($result && Configuration::get('PS_OVERBOOKING_AUTO_RESOLVE') && $is_refunded) { // if room is getting free and this room is not already in back order then resolve the overbookings for this free room diff --git a/modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php b/modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php new file mode 100644 index 000000000..1db4a235b --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php @@ -0,0 +1,68 @@ + 'htl_booking_tax_exemption', + 'primary' => 'id_exemption', + 'fields' => array( + 'id_order_tourism_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'id_htl_booking' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'id_order' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'id_employee' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'), + ), + ); + + /** + * Return all exemption records for a given htl_order_tourism_tax row. + * + * @param int $idOrderTourismTax + * @return array + */ + public static function getByOrderTourismTax($idOrderTourismTax) + { + return Db::getInstance()->executeS( + 'SELECT e.* FROM `' . _DB_PREFIX_ . 'htl_booking_tax_exemption` e + WHERE e.`id_order_tourism_tax` = ' . (int) $idOrderTourismTax . ' + ORDER BY e.`date_add` ASC' + ); + } + +} diff --git a/modules/hotelreservationsystem/classes/HotelBranchInformation.php b/modules/hotelreservationsystem/classes/HotelBranchInformation.php index 0797c7035..4acff2916 100644 --- a/modules/hotelreservationsystem/classes/HotelBranchInformation.php +++ b/modules/hotelreservationsystem/classes/HotelBranchInformation.php @@ -39,6 +39,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 +62,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/HotelHelper.php b/modules/hotelreservationsystem/classes/HotelHelper.php index f7f4f1c2b..a5ea104db 100644 --- a/modules/hotelreservationsystem/classes/HotelHelper.php +++ b/modules/hotelreservationsystem/classes/HotelHelper.php @@ -1069,6 +1069,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/HotelOrderTourismTax.php b/modules/hotelreservationsystem/classes/HotelOrderTourismTax.php new file mode 100644 index 000000000..4d7b15ed0 --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelOrderTourismTax.php @@ -0,0 +1,565 @@ + 'htl_order_tourism_tax', + 'primary' => 'id_order_tourism_tax', + '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_hotel' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'id_currency' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), + 'tax_name' => array('type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'size' => 100, 'required' => true), + 'num_nights' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'num_adults' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'total_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'), + 'collection_type' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'is_exempted' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'is_refunded' => 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'), + ), + ); + + /** + * @param int $idOrderDetail + * @return array + */ + public static function getByOrderDetail($idOrderDetail) + { + return Db::getInstance()->executeS( + 'SELECT * FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order_detail` = ' . (int) $idOrderDetail + ); + } + + /** + * @param int $idOrder + * @return array + */ + public static function getByOrder($idOrder) + { + return Db::getInstance()->executeS( + 'SELECT * FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order` = ' . (int) $idOrder + ); + } + + /** + * Return online-collected, non-exempted, non-refunded tourism tax totals for all bookings + * of an order, keyed by id_htl_booking. One query per order — avoids N+1 in the booking loop. + * + * @param int $idOrder + * @return array [id_htl_booking => float total_amount] + */ + public static function getOnlineTotalsByBooking($idOrder) + { + $rows = Db::getInstance()->executeS( + 'SELECT `id_htl_booking`, SUM(`total_amount`) AS total_amount + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order` = ' . (int) $idOrder . ' + AND `collection_type` = 0 + AND `is_exempted` = 0 + AND `is_refunded` = 0 + GROUP BY `id_htl_booking`' + ); + $result = array(); + if ($rows) { + foreach ($rows as $row) { + $result[(int) $row['id_htl_booking']] = (float) $row['total_amount']; + } + } + return $result; + } + + /** + * Sum of total_amount for online-collected (collection_type=0), non-exempted rows for an order. + * + * @param int $idOrder + * @return float + */ + public static function getOnlineTotalForOrder($idOrder) + { + $total = Db::getInstance()->getValue( + 'SELECT SUM(`total_amount`) FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order` = ' . (int) $idOrder . ' + AND `collection_type` = 0 + AND `is_exempted` = 0 + AND `is_refunded` = 0' + ); + return (float) $total; + } + + /** + * Mark all rows for a booking as refunded (used on cancellation). + * + * @param int $idHtlBooking + * @return bool + */ + public static function setRefunded($idHtlBooking) + { + return Db::getInstance()->execute( + 'UPDATE `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + SET `is_refunded` = 1, `date_upd` = NOW() + WHERE `id_htl_booking` = ' . (int) $idHtlBooking + ); + } + + /** + * Delete all snapshot rows for an order_detail. + * + * @param int $idOrderDetail + * @return bool + */ + public static function deleteByOrderDetail($idOrderDetail) + { + return Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order_detail` = ' . (int) $idOrderDetail + ); + } + + /** + * Delete all snapshot rows for a single booking. + * Used by computeAndSave idempotency step — scoped to id_htl_booking so that + * multiple bookings sharing one order_detail do not erase each other. + * + * @param int $idHtlBooking + * @return bool + */ + public static function deleteByHtlBooking($idHtlBooking) + { + return Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking + ); + } + + /** + * Return breakdown for invoice/PDF — grouped by id_tax, excluding exempted rows. + * + * @param int $idOrder + * @return array + */ + public static function getBreakdownForInvoice($idOrder) + { + return Db::getInstance()->executeS( + 'SELECT `id_tax`, `tax_name`, `collection_type`, + SUM(`num_nights`) AS num_nights, + SUM(`num_adults`) AS num_adults, + SUM(`total_amount`) AS total_amount + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order` = ' . (int) $idOrder . ' + AND `is_exempted` = 0 + GROUP BY `id_tax`, `tax_name`, `collection_type`' + ); + } + + /** + * Return tourism tax status for a single booking. + * STATUS_NONE — no rows exist for this booking yet + * STATUS_APPLIED — rows exist and at least one is not exempted + * STATUS_EXEMPTED — rows exist but all are exempted + * + * @param int $idHtlBooking + * @return string + */ + public static function getStatusForBooking($idHtlBooking) + { + $rows = Db::getInstance()->executeS( + 'SELECT `is_exempted` FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_refunded` = 0' + ); + if (empty($rows)) { + return self::STATUS_NONE; + } + foreach ($rows as $row) { + if (!(int) $row['is_exempted']) { + return self::STATUS_APPLIED; + } + } + return self::STATUS_EXEMPTED; + } + + /** + * Sum of total_amount for all non-exempted tourism tax rows for a booking. + * Returns 0 when the booking has no rows or all rows are exempted. + * + * @param int $idHtlBooking + * @return float + */ + public static function getTotalForBooking($idHtlBooking) + { + return (float) Db::getInstance()->getValue( + 'SELECT SUM(`total_amount`) FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_exempted` = 0 + AND `is_refunded` = 0' + ); + } + + /** + * Mark all non-refunded rows for a booking as exempted. + * Runs inside a DB transaction with SELECT FOR UPDATE to prevent concurrent double-exemption. + * Writes one HotelBookingTaxExemption audit row per htl_order_tourism_tax row exempted. + * + * @param int $idHtlBooking + * @param int $idEmployee PS employee id (0 = resolved from Context) + * @return bool + */ + public static function exemptForBooking($idHtlBooking, $idEmployee = 0) + { + $db = Db::getInstance(); + $db->execute('START TRANSACTION'); + try { + $rows = $db->executeS( + 'SELECT `id_order_tourism_tax`, `id_order`, `id_order_detail`, `collection_type`, `total_amount` + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_exempted` = 0 + AND `is_refunded` = 0 + FOR UPDATE' + ); + + if (empty($rows)) { + $db->execute('COMMIT'); + return true; + } + + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + SET `is_exempted` = 1, `date_upd` = NOW() + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_refunded` = 0' + ); + + $idOrderDetail = (int) $rows[0]['id_order_detail']; + $idOrder = (int) $rows[0]['id_order']; + $onlineAmount = 0.0; + $totalExemptedAmount = 0.0; + $idEmployeeResolved = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id; + + foreach ($rows as $row) { + $totalExemptedAmount += (float) $row['total_amount']; + if ((int) $row['collection_type'] === 0) { + $onlineAmount += (float) $row['total_amount']; + } + $exemption = new HotelBookingTaxExemption(); + $exemption->id_order_tourism_tax = (int) $row['id_order_tourism_tax']; + $exemption->id_htl_booking = (int) $idHtlBooking; + $exemption->id_order = $idOrder; + $exemption->id_employee = $idEmployeeResolved; + if (!$exemption->add()) { + throw new Exception('Failed to save exemption audit row'); + } + } + + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'order_detail` + SET `tourism_tax_amount` = GREATEST(0, `tourism_tax_amount` - ' . (float) $totalExemptedAmount . '), + `total_price_tax_incl` = `total_price_tax_incl` - ' . (float) $onlineAmount . ' + WHERE `id_order_detail` = ' . $idOrderDetail + ); + + if ($onlineAmount > 0) { + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'orders` + SET `total_paid_tax_incl` = `total_paid_tax_incl` - ' . (float) $onlineAmount . ', + `total_paid_tax_excl` = `total_paid_tax_excl` - ' . (float) $onlineAmount . ', + `total_paid` = `total_paid` - ' . (float) $onlineAmount . ' + WHERE `id_order` = ' . $idOrder + ); + } + + $db->execute('COMMIT'); + return true; + } catch (Exception $e) { + $db->execute('ROLLBACK'); + return false; + } + } + + /** + * Remove exemption for all rows of a booking (restore applied state). + * Runs inside a DB transaction with SELECT FOR UPDATE to prevent concurrent double-restore. + * + * @param int $idHtlBooking + * @return bool + */ + public static function unExemptForBooking($idHtlBooking) + { + $db = Db::getInstance(); + $db->execute('START TRANSACTION'); + try { + $rows = $db->executeS( + 'SELECT `id_order`, `id_order_detail`, `collection_type`, `total_amount` + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_exempted` = 1 + AND `is_refunded` = 0 + FOR UPDATE' + ); + + if (empty($rows)) { + $db->execute('COMMIT'); + return true; + } + + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + SET `is_exempted` = 0, `date_upd` = NOW() + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `is_refunded` = 0' + ); + + $idOrderDetail = (int) $rows[0]['id_order_detail']; + $idOrder = (int) $rows[0]['id_order']; + $onlineAmount = 0.0; + + foreach ($rows as $row) { + if ((int) $row['collection_type'] === 0) { + $onlineAmount += (float) $row['total_amount']; + } + } + + $restored = (float) $db->getValue( + 'SELECT SUM(`total_amount`) + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_order_detail` = ' . $idOrderDetail . ' + AND `is_refunded` = 0' + ); + + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'order_detail` + SET `tourism_tax_amount` = ' . $restored . ', + `total_price_tax_incl` = `total_price_tax_incl` + ' . (float) $onlineAmount . ' + WHERE `id_order_detail` = ' . $idOrderDetail + ); + + if ($onlineAmount > 0) { + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'orders` + SET `total_paid_tax_incl` = `total_paid_tax_incl` + ' . (float) $onlineAmount . ', + `total_paid_tax_excl` = `total_paid_tax_excl` + ' . (float) $onlineAmount . ', + `total_paid` = `total_paid` + ' . (float) $onlineAmount . ' + WHERE `id_order` = ' . $idOrder + ); + } + + $db->execute('COMMIT'); + return true; + } catch (Exception $e) { + $db->execute('ROLLBACK'); + return false; + } + } + + /** + * Apply or restore tourism tax for a single booking based on its current status. + * STATUS_EXEMPTED → removes the exemption. + * STATUS_NONE → computes fresh rows, then adjusts order totals. + * STATUS_APPLIED → no-op (returns APPLY_OK). + * + * @param int $idHtlBooking + * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NO_RULE + */ + public static function applyForBooking($idHtlBooking) + { + $idHtlBooking = (int) $idHtlBooking; + $status = self::getStatusForBooking($idHtlBooking); + + if ($status === self::STATUS_EXEMPTED) { + return self::unExemptForBooking($idHtlBooking) ? self::APPLY_OK : self::APPLY_ERROR_RESTORE; + } + + if ($status === self::STATUS_NONE) { + // If refunded rows exist, the booking was cancelled — do not create new snapshot rows. + $hasRefundedRows = (bool) Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . $idHtlBooking . ' AND `is_refunded` = 1' + ); + if ($hasRefundedRows) { + return self::APPLY_ERROR_REFUNDED; + } + + if (!HotelTourismTaxCalculator::applyFromExistingBooking($idHtlBooking)) { + return self::APPLY_ERROR_NO_RULE; + } + self::adjustOrderTotalsForNewBooking($idHtlBooking); + } + + return self::APPLY_OK; + } + + /** + * Apply tourism tax for all bookings of an order. + * Returns array of ['booking_id' => int, 'error' => int] for each failure. + * + * @param int $idOrder + * @return array + */ + public static function applyForOrder($idOrder) + { + $failures = array(); + $objBookingDetail = new HotelBookingDetail(); + $bookings = $objBookingDetail->getBookingDataByOrderId((int) $idOrder); + if ($bookings) { + foreach ($bookings as $booking) { + $result = self::applyForBooking($booking['id']); + if ($result !== self::APPLY_OK) { + $failures[] = array('booking_id' => (int) $booking['id'], 'error' => $result); + } + } + } + return $failures; + } + + /** + * Exempt tourism tax for a booking only if it is currently STATUS_APPLIED. + * Returns EXEMPT_OK on success or when already exempted, EXEMPT_ERROR_NO_RULE when tourism tax + * was never applied to this booking (room type has no tourism TRG), EXEMPT_ERROR_SAVE on DB failure. + * + * @param int $idHtlBooking + * @param int $idEmployee + * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE + */ + public static function exemptIfApplied($idHtlBooking, $idEmployee = 0) + { + $idHtlBooking = (int) $idHtlBooking; + $status = self::getStatusForBooking($idHtlBooking); + if ($status === self::STATUS_NONE) { + $hasRefundedRows = (bool) Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . $idHtlBooking . ' AND `is_refunded` = 1' + ); + return $hasRefundedRows ? self::EXEMPT_ERROR_REFUNDED : self::EXEMPT_ERROR_NO_RULE; + } + if ($status === self::STATUS_APPLIED) { + return self::exemptForBooking($idHtlBooking, $idEmployee) + ? self::EXEMPT_OK + : self::EXEMPT_ERROR_SAVE; + } + return self::EXEMPT_OK; + } + + /** + * Exempt tourism tax for all applicable bookings of an order. + * Returns array of ['booking_id' => int, 'error' => int] for each failure. + * + * @param int $idOrder + * @param int $idEmployee + * @return array + */ + public static function exemptForOrder($idOrder, $idEmployee = 0) + { + $failures = array(); + $objBookingDetail = new HotelBookingDetail(); + $bookings = $objBookingDetail->getBookingDataByOrderId((int) $idOrder); + if ($bookings) { + foreach ($bookings as $booking) { + $result = self::exemptIfApplied($booking['id'], $idEmployee); + if ($result !== self::EXEMPT_OK) { + $failures[] = array('booking_id' => (int) $booking['id'], 'error' => $result); + } + } + } + return $failures; + } + + /** + * After fresh-applying tourism tax to a STATUS_NONE booking, add the online + * portion to the order totals (it was absent from the original order total). + * + * @param int $idHtlBooking + * @return void + */ + private static function adjustOrderTotalsForNewBooking($idHtlBooking) + { + $booking = new HotelBookingDetail((int) $idHtlBooking); + if (!Validate::isLoadedObject($booking)) { + return; + } + $onlineTotal = (float) Db::getInstance()->getValue( + 'SELECT SUM(`total_amount`) + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `collection_type` = 0 + AND `is_exempted` = 0 + AND `is_refunded` = 0' + ); + if ($onlineTotal > 0) { + Db::getInstance()->execute( + 'UPDATE `' . _DB_PREFIX_ . 'orders` + SET `total_paid_tax_incl` = `total_paid_tax_incl` + ' . $onlineTotal . ', + `total_paid_tax_excl` = `total_paid_tax_excl` + ' . $onlineTotal . ', + `total_paid` = `total_paid` + ' . $onlineTotal . ' + WHERE `id_order` = ' . (int) $booking->id_order + ); + } + } +} diff --git a/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php b/modules/hotelreservationsystem/classes/HotelReservationSystemDb.php index ca44a2826..57023c05d 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`) @@ -513,6 +514,81 @@ class HotelReservationSystemDb PRIMARY KEY (`id_room_type_bed_type`) ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."htl_tourism_tax` ( + `id_tax` INT(11) UNSIGNED NOT NULL, + `tax_type` TINYINT(1) NOT NULL DEFAULT '0', + `is_per_night` TINYINT(1) NOT NULL DEFAULT '1', + `is_per_person` TINYINT(1) NOT NULL DEFAULT '0', + `tax_value` DECIMAL(20,6) NOT NULL DEFAULT '0.000000', + `is_tiered` TINYINT(1) NOT NULL DEFAULT '0', + `has_child_rate` TINYINT(1) NOT NULL DEFAULT '0', + `child_tax_value` DECIMAL(20,6) NOT NULL DEFAULT '0.000000', + `valid_from` DATE NULL DEFAULT NULL, + `valid_to` DATE NULL DEFAULT NULL, + `is_special_days_exists` TINYINT(1) NOT NULL DEFAULT '0', + `special_days` TEXT NULL DEFAULT NULL, + PRIMARY KEY (`id_tax`) + ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + + "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."htl_tourism_tax_tier` ( + `id_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', + `position` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0', + PRIMARY KEY (`id_tier`), + KEY `id_tax` (`id_tax`) + ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + + "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."htl_tourism_tax_child_range` ( + `id_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', + `position` TINYINT(3) UNSIGNED NOT NULL DEFAULT '0', + PRIMARY KEY (`id_child_range`), + KEY `id_tax` (`id_tax`) + ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + + "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."htl_order_tourism_tax` ( + `id_order_tourism_tax` 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_hotel` INT(11) UNSIGNED NOT NULL, + `id_tax` INT(11) UNSIGNED NOT NULL, + `id_currency` INT(11) UNSIGNED NOT NULL, + `tax_name` VARCHAR(100) NOT NULL, + `num_nights` SMALLINT(5) UNSIGNED NOT NULL DEFAULT '1', + `num_adults` TINYINT(3) UNSIGNED NOT NULL DEFAULT '1', + `total_amount` DECIMAL(20,6) NOT NULL DEFAULT '0.000000', + `collection_type` TINYINT(1) NOT NULL DEFAULT '0', + `is_exempted` TINYINT(1) NOT NULL DEFAULT '0', + `is_refunded` TINYINT(1) NOT NULL DEFAULT '0', + `date_add` DATETIME NOT NULL, + `date_upd` DATETIME NOT NULL, + PRIMARY KEY (`id_order_tourism_tax`), + UNIQUE KEY `biz_key` (`id_order_detail`, `id_tax`), + KEY `id_order` (`id_order`), + KEY `id_htl_booking` (`id_htl_booking`), + KEY `id_hotel` (`id_hotel`) + ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + + "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."htl_booking_tax_exemption` ( + `id_exemption` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `id_order_tourism_tax` INT(11) UNSIGNED NOT NULL, + `id_htl_booking` INT(11) UNSIGNED NOT NULL, + `id_order` INT(11) UNSIGNED NOT NULL, + `id_employee` INT(11) UNSIGNED NOT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id_exemption`), + KEY `id_order_tourism_tax` (`id_order_tourism_tax`), + KEY `id_htl_booking` (`id_htl_booking`), + KEY `id_order` (`id_order`) + ) ENGINE="._MYSQL_ENGINE_." DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;", + "INSERT INTO `"._DB_PREFIX_."htl_settings_link` (`id_settings_link`, `icon`, `link`, `new_window`, `position`, `unremovable`, `active`, `date_add`, `date_upd`) VALUES (1, 'icon-cogs', 'index.php?controller=AdminHotelGeneralSettings', 0, 0, 1, 1, NOW(), NOW()), (2, 'icon-dollar', 'index.php?controller=AdminHotelFeaturePricesSettings', 0, 2, 1, 1, NOW(), NOW()), @@ -615,7 +691,12 @@ class HotelReservationSystemDb `'._DB_PREFIX_.'htl_room_type_bed_type`, `'._DB_PREFIX_.'htl_access`, `'._DB_PREFIX_.'htl_settings_link`, - `'._DB_PREFIX_.'htl_settings_link_lang`' + `'._DB_PREFIX_.'htl_settings_link_lang`, + `'._DB_PREFIX_.'htl_tourism_tax`, + `'._DB_PREFIX_.'htl_tourism_tax_tier`, + `'._DB_PREFIX_.'htl_tourism_tax_child_range`, + `'._DB_PREFIX_.'htl_order_tourism_tax`, + `'._DB_PREFIX_.'htl_booking_tax_exemption`' ); } } diff --git a/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php b/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php index dd4f7da37..6ae745eda 100644 --- a/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php +++ b/modules/hotelreservationsystem/classes/HotelRoomTypeFeaturePricing.php @@ -433,6 +433,53 @@ class HotelRoomTypeFeaturePricing extends ObjectModel $totalPrice['total_price_tax_incl'] = Tools::processPriceRounding($totalPrice['total_price_tax_incl'], $quantity); $totalPrice['total_price_tax_excl'] = Tools::processPriceRounding($totalPrice['total_price_tax_excl'], $quantity); + $totalPrice['tourism_tax_online'] = 0.0; + $totalPrice['tourism_tax_at_hotel'] = 0.0; + if (Configuration::get('QLO_USE_TOURISM_TAX') + && is_array($occupancy) && !empty($occupancy) && isset($occupancy[0]['adults']) + ) { + $idTourismTrg = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue( + 'SELECT `id_tourism_tax_rules_group` + FROM `' . _DB_PREFIX_ . 'product_shop` product_shop + WHERE `id_product` = ' . (int) $id_product + . Shop::addSqlRestriction(false) + ); + if ($idTourismTrg) { + $idHotel = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue( + 'SELECT `id_hotel` FROM `' . _DB_PREFIX_ . 'htl_room_type` + WHERE `id_product` = ' . (int) $id_product + ); + $branchObj = new HotelBranchInformation($idHotel); + $collectionType = (int) $branchObj->tourism_tax_collection_type; + $checkInDt = new DateTime($date_from); + $checkOutDt = new DateTime($date_to); + $numNights = max(1, (int) $checkInDt->diff($checkOutDt)->days); + $unitPriceTe = (float) $totalPrice['total_price_tax_excl'] / $numNights; + $idLang = (int) Context::getContext()->language->id; + foreach ($occupancy as $roomOcc) { + $childAges = !empty($roomOcc['child_ages']) ? (array) $roomOcc['child_ages'] : array(); + $rows = HotelTourismTaxCalculator::compute( + $idTourismTrg, + $objAddress, + $unitPriceTe, + $date_from, + $numNights, + (int) $roomOcc['adults'], + $childAges, + $id_currency, + $collectionType, + $idLang + ); + $amount = (float) array_sum(array_column($rows, 'total_amount')); + if ($collectionType === 0) { + $totalPrice['tourism_tax_online'] += $amount; + } else { + $totalPrice['tourism_tax_at_hotel'] += $amount; + } + } + } + } + return $totalPrice; } diff --git a/modules/hotelreservationsystem/classes/HotelTourismTax.php b/modules/hotelreservationsystem/classes/HotelTourismTax.php new file mode 100644 index 000000000..dfc5a1ac7 --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelTourismTax.php @@ -0,0 +1,192 @@ +id before calling add(). + */ +class HotelTourismTax extends ObjectModel +{ + public $id_tax; + public $tax_type; + public $is_per_night; + public $is_per_person; + public $tax_value; + public $is_tiered; + public $has_child_rate; + public $child_tax_value; + public $valid_from; + public $valid_to; + public $is_special_days_exists; + public $special_days; + + public static $definition = array( + 'table' => 'htl_tourism_tax', + 'primary' => 'id_tax', + 'fields' => array( + 'tax_type' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + 'is_per_night' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'is_per_person' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'tax_value' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'), + 'is_tiered' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'has_child_rate' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'child_tax_value' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'), + 'valid_from' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'), + 'valid_to' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'), + 'is_special_days_exists' => array('type' => self::TYPE_BOOL, 'validate' => 'isBool'), + 'special_days' => array('type' => self::TYPE_STRING), + ), + ); + + /** + * Override add() because id_tax is a FK-as-PK (not auto-increment). + * PrestaShop's ObjectModel::add() never writes the primary key column, so + * MySQL would default it to 0 in non-strict mode, creating 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) + { + if (!(int) $this->id) { + return false; + } + $result = Db::getInstance()->insert( + 'htl_tourism_tax', + array( + 'id_tax' => (int) $this->id, + 'tax_type' => (int) $this->tax_type, + 'is_per_night' => (int) $this->is_per_night, + 'is_per_person' => (int) $this->is_per_person, + 'tax_value' => (float) $this->tax_value, + 'is_tiered' => (int) $this->is_tiered, + 'has_child_rate' => (int) $this->has_child_rate, + 'child_tax_value' => (float) $this->child_tax_value, + 'valid_from' => $this->valid_from ? pSQL($this->valid_from) : null, + 'valid_to' => $this->valid_to ? pSQL($this->valid_to) : null, + 'is_special_days_exists' => (int) $this->is_special_days_exists, + 'special_days' => $this->special_days ? pSQL($this->special_days) : null, + ) + ); + if ($result) { + $this->id_tax = (int) $this->id; + } + return $result; + } + + /** + * @param int $idTax + * @return HotelTourismTax|false + */ + public static function getByTaxId($idTax) + { + $obj = new HotelTourismTax((int) $idTax); + return Validate::isLoadedObject($obj) ? $obj : false; + } + + /** + * @param int $idTax + * @return bool + */ + public static function existsForTax($idTax) + { + return (bool) Db::getInstance()->getValue( + 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'htl_tourism_tax` + WHERE `id_tax` = ' . (int) $idTax + ); + } + + /** + * Remove this tax from all tourism TRGs. + * Returns the count of deleted tax_rule rows so the caller can flash a warning. + * + * @param int $idTax + * @return int + */ + public static function cleanFromTourismTrgs($idTax) + { + $count = (int) Db::getInstance()->getValue( + 'SELECT COUNT(*) 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` = 1' + ); + Db::getInstance()->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` = 1' + ); + return $count; + } + + /** + * Remove this tax from all standard (non-tourism) TRGs when it is promoted to a tourism tax. + * Symmetric counterpart of cleanFromTourismTrgs(). + * Returns the count of deleted tax_rule rows so the caller can flash a warning. + * + * @param int $idTax + * @return int + */ + public static function cleanFromVatTrgs($idTax) + { + $count = (int) Db::getInstance()->getValue( + 'SELECT COUNT(*) 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` = 0' + ); + Db::getInstance()->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` = 0' + ); + return $count; + } + + /** + * Delete the subtype row and all related tiers/child-ranges. + */ + public function delete() + { + Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_tier` + WHERE `id_tax` = ' . (int) $this->id + ); + Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_child_range` + WHERE `id_tax` = ' . (int) $this->id + ); + return parent::delete(); + } +} diff --git a/modules/hotelreservationsystem/classes/HotelTourismTaxCalculator.php b/modules/hotelreservationsystem/classes/HotelTourismTaxCalculator.php new file mode 100644 index 000000000..58b05c094 --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelTourismTaxCalculator.php @@ -0,0 +1,513 @@ +format('N') - 1; + + $results = array(); + + foreach ($rows as $row) { + $taxParams = $row['params']; + + if (!self::isValidForDate($taxParams, $checkInDate, $isoDayIndex)) { + continue; + } + + $isTiered = (bool) $taxParams['is_tiered']; + $taxType = (int) $taxParams['tax_type']; + $isPerNight = (bool) $taxParams['is_per_night']; + $isPerPerson = (bool) $taxParams['is_per_person']; + $hasChildRate = (bool) $taxParams['has_child_rate']; + + $baseValue = (float) $taxParams['tax_value']; + if ($isTiered) { + $tier = HotelTourismTaxTier::matchTier((int) $row['id_tax'], (float) $unitPriceTaxExcl); + if (!$tier) { + continue; + } + $baseValue = (float) $tier['tax_value']; + } + + $adultMultiplier = 1; + if ($isPerNight) { + $adultMultiplier *= (int) $numNights; + } + if ($isPerPerson) { + $adultMultiplier *= (int) $numAdults; + } + + if ($taxType === 0) { + $unitAmountAdult = $baseValue; + $totalAmountAdult = $baseValue * $adultMultiplier; + } else { + $unitAmountAdult = (float) $unitPriceTaxExcl * ($baseValue / 100); + $totalAmountAdult = $unitAmountAdult * $adultMultiplier; + } + + $unitAmountChild = 0.0; + $totalAmountChild = 0.0; + $numQualifyingChildren = 0; + + if ($hasChildRate && !empty($childrenAges)) { + $contribution = HotelTourismTaxChildRange::computeChildContribution( + (int) $row['id_tax'], + $childrenAges, + $taxType, + (float) $unitPriceTaxExcl, + $baseValue + ); + $numQualifyingChildren = (int) $contribution['count']; + if ($numQualifyingChildren > 0) { + $nightMultiplier = $isPerNight ? (int) $numNights : 1; + $totalAmountChild = $contribution['total'] * $nightMultiplier; + $unitAmountChild = $contribution['total'] / $numQualifyingChildren; + } + } + + $unitAmountAdult = Tools::convertPrice($unitAmountAdult, $idCurrency); + $unitAmountChild = Tools::convertPrice($unitAmountChild, $idCurrency); + $totalAmountAdult = Tools::convertPrice($totalAmountAdult, $idCurrency); + $totalAmountChild = Tools::convertPrice($totalAmountChild, $idCurrency); + $totalAmount = round($totalAmountAdult + $totalAmountChild, 6); + + $results[] = array( + 'id_tax' => (int) $row['id_tax'], + 'tax_name' => (string) $row['tax_name'], + 'num_nights' => (int) $numNights, + 'num_adults' => (int) $numAdults, + 'total_amount' => $totalAmount, + 'collection_type' => (int) $collectionType, + ); + } + + return $results; + } + + /** + * Compute and persist tourism tax rows for a confirmed order_detail line. + * Runs inside a single DB transaction. Safe to call more than once (idempotent via DELETE+INSERT). + * + * Returns the sum of total_amount where collection_type=0 (for Cart::getOrderTotal). + * + * @param int $idOrder + * @param int $idOrderDetail + * @param int $idHtlBooking + * @param int $idHotel + * @param int $idTaxRulesGroup + * @param Address $address + * @param float $unitPriceTaxExcl + * @param string $checkInDate + * @param int $numNights + * @param int $numAdults + * @param array $childrenAges + * @param int $idCurrency + * @param int $collectionType + * @param int $idLang + * @return float Online-charged tourism tax total for this order detail + */ + public static function computeAndSave( + $idOrder, + $idOrderDetail, + $idHtlBooking, + $idHotel, + $idTaxRulesGroup, + Address $address, + $unitPriceTaxExcl, + $checkInDate, + $numNights, + $numAdults, + array $childrenAges, + $idCurrency, + $collectionType, + $idLang + ) { + $rows = self::compute( + $idTaxRulesGroup, + $address, + $unitPriceTaxExcl, + $checkInDate, + $numNights, + $numAdults, + $childrenAges, + $idCurrency, + $collectionType, + $idLang + ); + + $db = Db::getInstance(); + $db->execute('START TRANSACTION'); + + try { + // Capture per-booking previous totals before deleting rows. + // Both queries scoped to id_htl_booking so multiple bookings sharing one + // order_detail row accumulate independently and do not overwrite each other. + $prevOnlineTotal = (float) $db->getValue( + 'SELECT COALESCE(SUM(`total_amount`), 0) + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' + AND `collection_type` = 0 + AND `is_exempted` = 0 + AND `is_refunded` = 0' + ); + $prevTotalTourismTax = (float) $db->getValue( + 'SELECT COALESCE(SUM(`total_amount`), 0) + FROM `' . _DB_PREFIX_ . 'htl_order_tourism_tax` + WHERE `id_htl_booking` = ' . (int) $idHtlBooking + ); + + HotelOrderTourismTax::deleteByHtlBooking((int) $idHtlBooking); + + $now = date('Y-m-d H:i:s'); + $totalTourismTax = 0.0; + $newOnlineTotal = 0.0; + foreach ($rows as $row) { + $db->insert('htl_order_tourism_tax', array( + 'id_order' => (int) $idOrder, + 'id_order_detail' => (int) $idOrderDetail, + 'id_htl_booking' => (int) $idHtlBooking, + 'id_hotel' => (int) $idHotel, + 'id_tax' => (int) $row['id_tax'], + 'id_currency' => (int) $idCurrency, + 'tax_name' => pSQL($row['tax_name']), + 'num_nights' => (int) $row['num_nights'], + 'num_adults' => (int) $row['num_adults'], + 'total_amount' => (float) $row['total_amount'], + 'collection_type' => (int) $row['collection_type'], + 'is_exempted' => 0, + 'is_refunded' => 0, + 'date_add' => pSQL($now), + 'date_upd' => pSQL($now), + )); + $totalTourismTax += (float) $row['total_amount']; + if ((int) $row['collection_type'] === 0) { + $newOnlineTotal += (float) $row['total_amount']; + } + } + + // Delta-update both fields so multiple bookings on one order_detail accumulate. + // tourism_tax_amount: subtract this booking's old total, add new total. + // total_price_tax_incl: subtract old online total, add new online total. + $db->execute( + 'UPDATE `' . _DB_PREFIX_ . 'order_detail` + SET `tourism_tax_amount` = GREATEST(0, `tourism_tax_amount` + - ' . (float) $prevTotalTourismTax . ' + + ' . (float) $totalTourismTax . '), + `total_price_tax_incl` = `total_price_tax_incl` + - ' . (float) $prevOnlineTotal . ' + + ' . (float) $newOnlineTotal . ' + WHERE `id_order_detail` = ' . (int) $idOrderDetail + ); + + $db->execute('COMMIT'); + } catch (Exception $e) { + $db->execute('ROLLBACK'); + throw $e; + } + + return $newOnlineTotal; + } + + /** + * Resolve the applicable ps_tax + htl_tourism_tax rows for a TRG and hotel address. + * Follows the same geo-routing logic as TaxRulesTaxManagerCore::getTaxCalculator(). + * behavior=0 on the most specific row → stop after first match. + * behavior=1 → accumulate all matched rows. + * + * @param int $idTaxRulesGroup + * @param Address $address + * @param int $idLang + * @return array Each entry: ['id_tax', 'tax_name', 'params' => htl_tourism_tax row, 'behavior'] + */ + protected static function resolveApplicableTaxes($idTaxRulesGroup, Address $address, $idLang) + { + $postcode = !empty($address->postcode) ? $address->postcode : '0'; + + $taxRows = Db::getInstance()->executeS( + 'SELECT tr.`id_tax`, tr.`behavior`, + tl.`name` AS tax_name, + ht.* + FROM `' . _DB_PREFIX_ . 'tax_rule` tr + JOIN `' . _DB_PREFIX_ . 'tax` t + ON t.`id_tax` = tr.`id_tax` + AND t.`active` = 1 + AND t.`deleted` = 0 + AND t.`is_tourism_tax` = 1 + JOIN `' . _DB_PREFIX_ . 'tax_rules_group` trg + ON trg.`id_tax_rules_group` = tr.`id_tax_rules_group` + AND trg.`active` = 1 + AND trg.`is_tourism_tax_rule` = 1 + LEFT JOIN `' . _DB_PREFIX_ . 'tax_lang` tl + ON tl.`id_tax` = t.`id_tax` + AND tl.`id_lang` = ' . (int) $idLang . ' + JOIN `' . _DB_PREFIX_ . 'htl_tourism_tax` ht + ON ht.`id_tax` = t.`id_tax` + WHERE tr.`id_tax_rules_group` = ' . (int) $idTaxRulesGroup . ' + AND tr.`id_country` = ' . (int) $address->id_country . ' + AND tr.`id_state` IN (0, ' . (int) $address->id_state . ') + AND tr.`id_tax` != 0 + AND (\'' . pSQL($postcode) . '\' BETWEEN tr.`zipcode_from` AND tr.`zipcode_to` + OR (tr.`zipcode_to` = 0 AND tr.`zipcode_from` IN (0, \'' . pSQL($postcode) . '\'))) + ORDER BY tr.`zipcode_from` DESC, tr.`zipcode_to` DESC, + tr.`id_state` DESC, tr.`id_country` DESC' + ); + + if (empty($taxRows)) { + return array(); + } + + $resolved = array(); + $firstBehavior = null; + + foreach ($taxRows as $taxRow) { + if ($firstBehavior === null) { + $firstBehavior = (int) $taxRow['behavior']; + } + + $resolved[] = array( + 'id_tax' => (int) $taxRow['id_tax'], + 'tax_name' => (string) ($taxRow['tax_name'] ?: ''), + 'params' => $taxRow, + 'behavior' => (int) $taxRow['behavior'], + ); + + if ($firstBehavior === 0) { + break; + } + } + + return $resolved; + } + + /** + * Check whether a tourism tax is valid for the given check-in date. + * + * @param array $params htl_tourism_tax row + * @param string $checkInDate 'YYYY-MM-DD' + * @param int $isoDayIndex 0=Mon…6=Sun (DateTime::format('N') - 1) + * @return bool + */ + protected static function isValidForDate(array $params, $checkInDate, $isoDayIndex) + { + $checkIn = new DateTime($checkInDate); + + if (!empty($params['valid_from']) && $params['valid_from'] !== '0000-00-00') { + $from = new DateTime($params['valid_from']); + if ($checkIn < $from) { + return false; + } + } + + if (!empty($params['valid_to']) && $params['valid_to'] !== '0000-00-00') { + $to = new DateTime($params['valid_to']); + if ($checkIn > $to) { + return false; + } + } + + if ((int) $params['is_special_days_exists']) { + $dayNames = array('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'); + $specialDays = json_decode($params['special_days'], true); + if (!is_array($specialDays) || !in_array($dayNames[$isoDayIndex], $specialDays)) { + return false; + } + } + + return true; + } + + /** + * Apply (compute + save) tourism tax for a booking on an already-confirmed order. + * Fetches all required parameters from the existing DB records. + * Returns false when the booking has no tourism TRG assigned or data is missing. + * + * @param int $idHtlBooking + * @return bool + */ + public static function applyFromExistingBooking($idHtlBooking) + { + $booking = new HotelBookingDetail((int) $idHtlBooking); + if (!Validate::isLoadedObject($booking)) { + return false; + } + + $order = new Order((int) $booking->id_order); + if (!Validate::isLoadedObject($order)) { + return false; + } + + $idTaxRulesGroup = (int) Db::getInstance()->getValue( + 'SELECT `id_tourism_tax_rules_group` + FROM `' . _DB_PREFIX_ . 'product_shop` + WHERE `id_product` = ' . (int) $booking->id_product . ' + AND `id_shop` = ' . (int) $order->id_shop + ); + if (!$idTaxRulesGroup) { + return false; + } + + $address = new Address((int) $order->id_address_tax); + if (!Validate::isLoadedObject($address)) { + return false; + } + + $objBranch = new HotelBranchInformation((int) $booking->id_hotel); + if (!Validate::isLoadedObject($objBranch)) { + return false; + } + $collectionType = (int) $objBranch->tourism_tax_collection_type; + + $numNights = (int) HotelHelper::getNumberOfDays($booking->date_from, $booking->date_to); + if ($numNights <= 0) { + return false; + } + + $unitPriceTaxExcl = 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); + } + } + + self::computeAndSave( + (int) $booking->id_order, + (int) $booking->id_order_detail, + (int) $idHtlBooking, + (int) $booking->id_hotel, + $idTaxRulesGroup, + $address, + $unitPriceTaxExcl, + $booking->date_from, + $numNights, + (int) $booking->adults, + $childrenAges, + (int) $order->id_currency, + $collectionType, + (int) Context::getContext()->language->id + ); + + return true; + } + + /** + * Compute cart-time total for display (does not save). + * Used by Cart::getOrderTotal() before order confirmation. + * + * @param int $idTaxRulesGroup + * @param Address $address + * @param float $unitPriceTaxExcl + * @param string $checkInDate + * @param int $numNights + * @param int $numAdults + * @param array $childrenAges + * @param int $idCurrency + * @param int $collectionType + * @param int $idLang + * @return float Online-charged total only + */ + public static function computeOnlineTotal( + $idTaxRulesGroup, + Address $address, + $unitPriceTaxExcl, + $checkInDate, + $numNights, + $numAdults, + array $childrenAges, + $idCurrency, + $collectionType, + $idLang + ) { + if ((int) $collectionType !== 0) { + return 0.0; + } + $rows = self::compute( + $idTaxRulesGroup, $address, $unitPriceTaxExcl, $checkInDate, + $numNights, $numAdults, $childrenAges, $idCurrency, $collectionType, $idLang + ); + $total = 0.0; + foreach ($rows as $row) { + $total += (float) $row['total_amount']; + } + return $total; + } +} diff --git a/modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php b/modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php new file mode 100644 index 000000000..6149e6409 --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php @@ -0,0 +1,168 @@ + 'htl_tourism_tax_child_range', + 'primary' => 'id_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'), + 'position' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + ), + ); + + /** + * Return all child age ranges for a tax sorted by position. + * + * @param int $idTax + * @return array + */ + public static function getByTaxId($idTax) + { + return Db::getInstance()->executeS( + 'SELECT * FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_child_range` + WHERE `id_tax` = ' . (int) $idTax . ' + ORDER BY `position` ASC, `min_age` ASC' + ); + } + + /** + * Replace all child range rows for $idTax with the submitted arrays. + * Skips 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, array $mins, array $maxs, array $values) + { + Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_child_range` + WHERE `id_tax` = ' . (int) $idTax + ); + foreach ($values as $i => $value) { + if ($value === '' || $value === false) { + continue; + } + $range = new HotelTourismTaxChildRange(); + $range->id_tax = (int) $idTax; + $range->min_age = isset($mins[$i]) ? (int) $mins[$i] : 0; + $range->max_age = isset($maxs[$i]) ? (int) $maxs[$i] : 0; + $range->tax_value = (float) $value; + $range->position = (int) $i; + $range->add(); + } + } + + /** + * Sum per-band tax contributions for the given child ages. + * Each child is matched to the first qualifying band; unmatched children pay the adult rate + * when $adultBaseValue is provided, otherwise they contribute 0. + * + * Returns array: ['total' => float, 'count' => int] + * + * @param int $idTax + * @param int[] $ages Ages at check-in date + * @param int $taxType 0=fixed, 1=percentage + * @param float $unitPrice Room unit_price_tax_excl (used for percentage type) + * @param float|null $adultBaseValue Adult tax_value (fixed amount or percentage); null = exempt unmatched children + * @return array + */ + public static function computeChildContribution($idTax, array $ages, $taxType, $unitPrice, $adultBaseValue = null) + { + $ranges = self::getByTaxId((int) $idTax); + $total = 0.0; + $count = 0; + foreach ($ages as $age) { + $matched = false; + foreach ($ranges as $range) { + $inLower = ((int) $age >= (int) $range['min_age']); + $inUpper = ((int) $range['max_age'] == 0 || (int) $age <= (int) $range['max_age']); + if ($inLower && $inUpper) { + $bandValue = (float) $range['tax_value']; + $total += ((int) $taxType === 0) + ? $bandValue + : ((float) $unitPrice * ($bandValue / 100)); + $count++; + $matched = true; + break; + } + } + if (!$matched && $adultBaseValue !== null) { + $total += ((int) $taxType === 0) + ? (float) $adultBaseValue + : ((float) $unitPrice * ((float) $adultBaseValue / 100)); + $count++; + } + } + return array('total' => $total, 'count' => $count); + } + + /** + * Check whether a new range [newMin, newMax] overlaps any existing range for the same tax. + * + * @param int $idTax + * @param int $newMin + * @param int $newMax + * @param int $excludeId Range id to exclude (for edit validation) + * @return bool + */ + public static function hasOverlap($idTax, $newMin, $newMax, $excludeId = 0) + { + $ranges = self::getByTaxId((int) $idTax); + foreach ($ranges as $range) { + if ($excludeId && (int) $range['id_child_range'] === (int) $excludeId) { + continue; + } + $exMax = (int) $range['max_age']; + $newMaxEff = ($newMax == 0) ? PHP_INT_MAX : (int) $newMax; + $exMaxEff = ($exMax == 0) ? PHP_INT_MAX : $exMax; + if ((int) $newMin <= $exMaxEff && $newMaxEff >= (int) $range['min_age']) { + return true; + } + } + return false; + } +} diff --git a/modules/hotelreservationsystem/classes/HotelTourismTaxTier.php b/modules/hotelreservationsystem/classes/HotelTourismTaxTier.php new file mode 100644 index 000000000..693d42180 --- /dev/null +++ b/modules/hotelreservationsystem/classes/HotelTourismTaxTier.php @@ -0,0 +1,145 @@ + 'htl_tourism_tax_tier', + 'primary' => 'id_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'), + 'position' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'), + ), + ); + + /** + * Return all tiers for a tax sorted by position. + * + * @param int $idTax + * @return array + */ + public static function getByTaxId($idTax) + { + return Db::getInstance()->executeS( + 'SELECT * FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_tier` + WHERE `id_tax` = ' . (int) $idTax . ' + ORDER BY `position` ASC, `min_amount` ASC' + ); + } + + /** + * Find the matching tier for a given unit price. + * Returns the tier row or false when no tier matches. + * + * @param int $idTax + * @param float $unitPrice + * @return array|false + */ + public static function matchTier($idTax, $unitPrice) + { + $tiers = self::getByTaxId((int) $idTax); + foreach ($tiers as $tier) { + $inLower = ((float) $unitPrice >= (float) $tier['min_amount']); + $inUpper = ((float) $tier['max_amount'] == 0 || (float) $unitPrice < (float) $tier['max_amount']); + if ($inLower && $inUpper) { + return $tier; + } + } + return false; + } + + /** + * Replace all tier rows for $idTax with the submitted arrays. + * Skips 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, array $mins, array $maxs, array $values) + { + Db::getInstance()->execute( + 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_tier` + WHERE `id_tax` = ' . (int) $idTax + ); + foreach ($values as $i => $value) { + if ($value === '' || $value === false) { + continue; + } + $tier = new HotelTourismTaxTier(); + $tier->id_tax = (int) $idTax; + $tier->min_amount = isset($mins[$i]) ? (float) $mins[$i] : 0; + $tier->max_amount = isset($maxs[$i]) ? (float) $maxs[$i] : 0; + $tier->tax_value = (float) $value; + $tier->position = (int) $i; + $tier->add(); + } + } + + /** + * Check whether a new tier [newMin, newMax) overlaps any existing tier for the same tax. + * + * @param int $idTax + * @param float $newMin + * @param float $newMax 0 = open-ended + * @param int $excludeId Tier id to exclude (for edit validation) + * @return bool + */ + public static function hasOverlap($idTax, $newMin, $newMax, $excludeId = 0) + { + $tiers = self::getByTaxId((int) $idTax); + foreach ($tiers as $tier) { + if ($excludeId && (int) $tier['id_tier'] === (int) $excludeId) { + continue; + } + $exMin = (float) $tier['min_amount']; + $exMax = (float) $tier['max_amount']; + $newMaxEff = ((float) $newMax == 0) ? PHP_FLOAT_MAX : (float) $newMax; + $exMaxEff = ($exMax == 0) ? PHP_FLOAT_MAX : $exMax; + if ($newMin < $exMaxEff && $newMaxEff > $exMin) { + return true; + } + } + return false; + } +} diff --git a/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php b/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php index 868012630..42d52a6fd 100644 --- a/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php +++ b/modules/hotelreservationsystem/controllers/admin/AdminAddHotelController.php @@ -236,6 +236,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); @@ -518,6 +520,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/define.php b/modules/hotelreservationsystem/define.php index e8a7cc909..7f65da344 100644 --- a/modules/hotelreservationsystem/define.php +++ b/modules/hotelreservationsystem/define.php @@ -61,5 +61,12 @@ require_once 'classes/ServiceProductOption.php'; require_once 'classes/HotelSettingsLink.php'; require_once 'classes/HotelBookingDocument.php'; +require_once 'classes/HotelTourismTax.php'; +require_once 'classes/HotelTourismTaxTier.php'; +require_once 'classes/HotelTourismTaxChildRange.php'; +require_once 'classes/HotelOrderTourismTax.php'; +require_once 'classes/HotelBookingTaxExemption.php'; +require_once 'classes/HotelTourismTaxCalculator.php'; + // Web services classes require_once 'classes/WebserviceSpecificManagementHotelAri.php'; diff --git a/modules/hotelreservationsystem/hotelreservationsystem.php b/modules/hotelreservationsystem/hotelreservationsystem.php index 5246337d7..b7b15581e 100644 --- a/modules/hotelreservationsystem/hotelreservationsystem.php +++ b/modules/hotelreservationsystem/hotelreservationsystem.php @@ -504,15 +504,46 @@ class HotelReservationSystem extends Module public function HookActionCartSummary($params) { - // $objCartBookingData = new HotelCartBookingData(); - // $totalFacilityCostTI = $objCartBookingData->getCartExtraDemands($params['cart']->id, 0, 0, 0, 0, 1, 0, 1); - // $totalFacilityCostTE = $objCartBookingData->getCartExtraDemands($params['cart']->id, 0, 0, 0, 0, 1, 0, 0); - // return array( - // 'additional_facilities_tax' => ($totalFacilityCostTI - $totalFacilityCostTE), - // 'totalFacilityCostTE' => $totalFacilityCostTE, - // 'totalFacilityCostTI' => $totalFacilityCostTI, - // ); - return array(); + if (!Configuration::get('QLO_USE_TOURISM_TAX') || !Configuration::get('PS_TAX')) { + return array(); + } + + $cart = $this->context->cart; + if (!Validate::isLoadedObject($cart)) { + return array(); + } + + $tourismTaxAtHotel = 0.0; + $objCartBookingData = new HotelCartBookingData(); + $cartItems = $objCartBookingData->getCartCurrentDataByCartId($cart->id); + if ($cartItems) { + foreach ($cartItems as $item) { + $occupancy = array( + array( + 'adults' => (int) $item['adults'], + 'children' => (int) $item['children'], + 'child_ages' => json_decode($item['child_ages']), + ) + ); + $roomTotalPrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice( + $item['id_product'], + $item['date_from'], + $item['date_to'], + $occupancy, + Group::getCurrent()->id, + $item['id_cart'], + $item['id_guest'], + $item['id_room'], + 0 + ); + $tourismTaxAtHotel += (float) $roomTotalPrice['tourism_tax_at_hotel']; + } + } + + return array( + 'tourism_tax_at_hotel' => $tourismTaxAtHotel, + 'tourism_tax_grossed_up' => (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP'), + ); } public function callInstallTab() @@ -648,7 +679,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/TourismTaxAdmin.css b/modules/hotelreservationsystem/views/css/TourismTaxAdmin.css new file mode 100644 index 000000000..fa36a93b6 --- /dev/null +++ b/modules/hotelreservationsystem/views/css/TourismTaxAdmin.css @@ -0,0 +1,73 @@ +/** + * 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 + */ + +.tt-child-field, +.tt-tiered-field { + display: none; +} + +.tt-subtable { + width: 100%; + margin-bottom: 8px; + border-collapse: collapse; +} + +.tt-subtable thead th { + background: #f5f5f5; + font-weight: 600; + padding: 8px 10px; + border: 1px solid #ddd; + white-space: nowrap; +} + +.tt-subtable tbody td { + padding: 5px 8px; + border: 1px solid #e8e8e8; + vertical-align: middle; +} + +.tt-subtable .form-control { + height: 30px; + padding: 4px 8px; + font-size: 13px; +} + +.tt-subtable .btn-remove-row { + padding: 3px 8px; +} + +.tt-add-row-btn { + margin-top: 8px; +} + +.tt-req-star { + color: #c0392b; + font-weight: bold; + font-size: 15px; + margin-left: 2px; +} + +.form-control.tt-field-error, +.form-control.tt-field-error:focus { + border-color: #a94442; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 4px #ce8483; +} diff --git a/modules/hotelreservationsystem/views/js/TourismTaxForm.js b/modules/hotelreservationsystem/views/js/TourismTaxForm.js new file mode 100644 index 000000000..1213d3a0a --- /dev/null +++ b/modules/hotelreservationsystem/views/js/TourismTaxForm.js @@ -0,0 +1,185 @@ +/** + * 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 tierIdx = $('#tt-tiers-body tr').length; + var childIdx = $('#tt-child-body tr').length; + + $('#htl_valid_from').datepicker({ + onSelect: function () { + $('#htl_valid_to').datepicker('option', 'minDate', $(this).datepicker('getDate')); + validateDates(); + } + }); + + $('#htl_valid_to').datepicker({ onSelect: validateDates }); + + function validateDates() { + var from = $('#htl_valid_from').val(); + var to = $('#htl_valid_to').val(); + var $err = $('#tt-date-error'); + if (from && to) { + try { + var fromTs = $.datepicker.parseDate('yy-mm-dd', from).getTime(); + var toTs = $.datepicker.parseDate('yy-mm-dd', to).getTime(); + if (toTs < fromTs) { + $err.text('"Valid to" must be on or after "Valid from".').show(); + return false; + } + } catch (e) {} + } + $err.hide(); + return true; + } + + function isTourismOn() { return $('input[name="is_tourism_tax"]:checked').val() == '1'; } + function isChildRateOn() { return $('input[name="htl_has_child_rate"]:checked').val() == '1'; } + function isTieredOn() { return $('input[name="htl_is_tiered"]:checked').val() == '1'; } + + function setVisible($el, visible, animate) { + if (animate) { + visible ? $el.slideDown(250) : $el.slideUp(250); + } else { + visible ? $el.show() : $el.hide(); + } + } + + function ensureOneRow(bodyId, tplId, idxRef) { + if ($('#' + bodyId + ' tr').length === 0) { + var tpl = $('#' + tplId).html().replace(/__IDX__/g, idxRef.val); + var $row = $(tpl); + $row.find('.tt-row-type-sign').text(currentTypeSign()); + $('#' + bodyId).append($row); + idxRef.val++; + } + } + + var tierIdxRef = { val: tierIdx }; + var childIdxRef = { val: childIdx }; + + function updateAllVisibility(animate) { + var t = isTourismOn(); + setVisible($('.tt-field').not('.tt-child-field, .tt-tiered-field'), t, animate); + setVisible($('.tt-non-tourism'), !t, animate); + + var showChild = t && isChildRateOn(); + setVisible($('.tt-child-field'), showChild, animate); + if (showChild) { + ensureOneRow('tt-child-body', 'tt-child-row-tpl', childIdxRef); + } + + var showTiered = t && isTieredOn(); + setVisible($('.tt-tiered-field'), showTiered, animate); + if (showTiered) { + ensureOneRow('tt-tiers-body', 'tt-tier-row-tpl', tierIdxRef); + } + } + + updateAllVisibility(false); + updateTypeSign(); + + $('input[name="is_tourism_tax"], input[name="htl_has_child_rate"], input[name="htl_is_tiered"]').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 = $('#tt-type-sign'); + return $('select[name="htl_tax_type"]').val() == 1 ? '%' : $sign.data('currency'); + } + + function updateTypeSign() { + var sign = currentTypeSign(); + $('#tt-type-sign').text(sign); + $('.tt-row-type-sign').text(sign); + } + + $('select[name="htl_tax_type"]').on('change', updateTypeSign); + + $(document).on('click', '#tt-add-tier', function (e) { + e.preventDefault(); + var tpl = $('#tt-tier-row-tpl').html().replace(/__IDX__/g, tierIdxRef.val); + var $row = $(tpl); + $row.find('.tt-row-type-sign').text(currentTypeSign()); + $('#tt-tiers-body').append($row); + tierIdxRef.val++; + }); + + $(document).on('click', '#tt-add-child', function (e) { + e.preventDefault(); + var tpl = $('#tt-child-row-tpl').html().replace(/__IDX__/g, childIdxRef.val); + var $row = $(tpl); + $row.find('.tt-row-type-sign').text(currentTypeSign()); + $('#tt-child-body').append($row); + childIdxRef.val++; + }); + + $(document).on('click', '.tt-remove-row', function (e) { + e.preventDefault(); + $(this).closest('tr').remove(); + }); + + $('form.defaultForm').on('submit', function () { + var hasError = false; + + if (isChildRateOn()) { + $('#tt-child-body tr').each(function () { + $(this).find('.tt-req').each(function () { + if ($.trim($(this).val()) === '') { + $(this).addClass('tt-field-error'); + hasError = true; + } + }); + }); + } + + if (isTieredOn()) { + $('#tt-tiers-body tr').each(function () { + $(this).find('.tt-req').each(function () { + if ($.trim($(this).val()) === '') { + $(this).addClass('tt-field-error'); + hasError = true; + } + }); + }); + } + + if (hasError) { + setTimeout(function () { + var $first = $('.tt-field-error').first(); + if ($first.length) { + $first[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 50); + } + return !hasError; + }); + + $(document).on('input', '.tt-req', function () { + if ($.trim($(this).val()) !== '') { + $(this).removeClass('tt-field-error'); + } + }); +}); 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 978872548..4fdc902bc 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 @@ -98,6 +98,14 @@ {l s='Features' mod='hotelreservationsystem'} + {if $use_tourism_tax} +
  • + + + {l s='Tourism Tax' mod='hotelreservationsystem'} + +
  • + {/if} {hook h='displayAdminAddHotelFormTab' id_hotel=$hook_arg_id_hotel}
    @@ -631,6 +639,25 @@ {hook h='displayAdminAddHotelFormFeaturesTabAfter' id_hotel=$hook_arg_id_hotel}
    + {if $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 d8cadf9e9..058394fdd 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 @@ -170,7 +170,7 @@ {assign var="is_full_date" value=($show_full_date && ($booking['date_from']|date_format:'%D' == $booking['date_to']|date_format:'%D'))} {dateFormat date=$booking['date_from'] full=$is_full_date} {l s='To' mod='hotelreservationsystem'} {dateFormat date=$booking['date_to'] full=$is_full_date} - {displayPrice price=($booking['total_price_tax_incl'] + $booking['extra_service_total_price_tax_incl']) currency=$orderCurrency['id']} + {displayPrice price=($booking['total_price_tax_incl'] + $booking['extra_service_total_price_tax_incl'] + $booking['tourism_tax_online_amount']) currency=$orderCurrency['id']}   @@ -183,10 +183,16 @@ {displayPrice price=$booking['extra_service_total_price_tax_incl'] currency=$orderCurrency['id']} + {if $booking['tourism_tax_online_amount'] > 0} +
    + + {displayPrice price=$booking['tourism_tax_online_amount'] currency=$orderCurrency['id']} +
    + {/if} - {displayPrice price=($booking['room_paid_amount'] + $booking['extra_service_total_paid_amount']) currency=$orderCurrency['id']} + {displayPrice price=($booking['room_paid_amount'] + $booking['extra_service_total_paid_amount'] + $booking['tourism_tax_paid_amount']) currency=$orderCurrency['id']}   @@ -199,6 +205,12 @@ {displayPrice price=$booking['extra_service_total_paid_amount'] currency=$orderCurrency['id']} + {if $booking['tourism_tax_paid_amount'] > 0} +
    + + {displayPrice price=$booking['tourism_tax_paid_amount'] currency=$orderCurrency['id']} +
    + {/if} {if !$isRefundCompleted} @@ -216,7 +228,7 @@ {displayPrice price=$booking['refunded_amount'] currency=$orderCurrency['id']} {else} {$orderCurrency['sign']|escape:'html':'UTF-8'} - + {l s='tax incl.' mod='hotelreservationsystem'} {/if} diff --git a/pdf/invoice.tax-tab.tpl b/pdf/invoice.tax-tab.tpl index 511d8fe54..c0cedb038 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 b0a42eac7..e2653c1eb 100644 --- a/pdf/invoice.total-tab.tpl +++ b/pdf/invoice.total-tab.tpl @@ -116,6 +116,26 @@ {/if} + {if isset($footer.tourism_tax_online) && $footer.tourism_tax_online > 0} + + + {l s='Tourism Tax' pdf='true'} + + + {displayPrice currency=$order->id_currency price=$footer.tourism_tax_online} + + + {/if} + {if isset($footer.tourism_tax_at_hotel) && $footer.tourism_tax_at_hotel > 0} + + + {l s='Tourism Tax (payable at hotel)' pdf='true'} + + + {displayPrice currency=$order->id_currency price=$footer.tourism_tax_at_hotel} + + + {/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 4b4e1b2fd..3ad92ada5 100644 --- a/themes/hotel-reservation-theme/cart-total-block.tpl +++ b/themes/hotel-reservation-theme/cart-total-block.tpl @@ -95,6 +95,20 @@ {displayPrice price=($total_tax_without_discount)}

    {/if} + {if $show_taxes && ((!isset($tourism_tax_grossed_up) || !$tourism_tax_grossed_up) || ($use_taxes && $priceDisplay != 0))} + {if isset($tourism_tax_online) && $tourism_tax_online > 0} +

    + {l s='Tourism Tax'} + {displayPrice price=$tourism_tax_online} +

    + {/if} + {if isset($tourism_tax_at_hotel) && $tourism_tax_at_hotel > 0} +

    + {l s='Tourism Tax (payable at hotel)'} + {displayPrice price=$tourism_tax_at_hotel} +

    + {/if} + {/if}

    {if $display_tax_label} diff --git a/themes/hotel-reservation-theme/order-confirmation.tpl b/themes/hotel-reservation-theme/order-confirmation.tpl index e8cc450cc..d29fd97b6 100644 --- a/themes/hotel-reservation-theme/order-confirmation.tpl +++ b/themes/hotel-reservation-theme/order-confirmation.tpl @@ -481,6 +481,26 @@ {displayWtPriceWithCurrency price=$orderTotalInfo['total_tax_without_discount'] currency=$objOrderCurrency convert=1} + {if isset($orderTotalInfo['tourism_tax_online']) && $orderTotalInfo['tourism_tax_online'] > 0} + + + {l s='Tourism Tax'} + + + {displayWtPriceWithCurrency price=$orderTotalInfo['tourism_tax_online'] currency=$objOrderCurrency} + + + {/if} + {if isset($orderTotalInfo['tourism_tax_at_hotel']) && $orderTotalInfo['tourism_tax_at_hotel'] > 0} + + + {l s='Tourism Tax (payable at hotel)'} + + + {displayWtPriceWithCurrency price=$orderTotalInfo['tourism_tax_at_hotel'] 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 efa462dc0..558a37f31 100644 --- a/themes/hotel-reservation-theme/order-detail.tpl +++ b/themes/hotel-reservation-theme/order-detail.tpl @@ -380,6 +380,22 @@ + {if isset($tourism_tax_breakdown) && $tourism_tax_breakdown} + {foreach from=$tourism_tax_breakdown item=ttRow} + + + {$ttRow.tax_name|escape:'htmlall':'UTF-8'} + {if $ttRow.collection_type == 1} +  ({l s='payable at hotel'}) + {/if} + + + {displayWtPriceWithCurrency price=$ttRow.total_amount currency=$currency} + + + {/foreach} + {/if} + {if $order->total_discounts > 0} {l s='Total Vouchers'} From 2abdb5b1e147d846cb90e4480952bef00d778ba8 Mon Sep 17 00:00:00 2001 From: shazar rahman Date: Fri, 7 Aug 2026 20:03:28 +0530 Subject: [PATCH 02/11] Fixed: Tourism tax flow and code review changes implemented --- .../controllers/normal_products/prices.tpl | 17 +- .../controllers/orders/_product_line.tpl | 13 +- .../orders/_rooms_informaion_table.tpl | 61 +- .../controllers/orders/helpers/view/view.tpl | 33 +- .../orders/modals/_add_room_booking.tpl | 10 + .../products/_tourism_tax_rule_field.tpl | 30 + .../template/controllers/products/prices.tpl | 17 +- classes/Cart.php | 51 +- classes/PaymentModule.php | 165 ++- classes/Product.php | 25 + classes/order/OrderInvoice.php | 14 +- classes/order/OrderReturn.php | 13 +- classes/pdf/HTMLTemplateBookingVoucher.php | 4 +- classes/pdf/HTMLTemplateInvoice.php | 20 +- classes/tax/Tax.php | 6 +- classes/tax/TaxCalculator.php | 137 ++ classes/tax/TaxRulesGroup.php | 3 +- classes/tourismtax/OrderTourismTax.php | 1196 +++++++++++++++++ .../tourismtax/OrderTourismTaxExemption.php | 21 +- classes/tourismtax/TourismTax.php | 346 +++++ .../tourismtax/TourismTaxChildRange.php | 101 +- .../tourismtax/TourismTaxTier.php | 89 +- classes/tourismtax/index.php | 35 + .../admin/AdminNormalProductsController.php | 52 +- controllers/admin/AdminOrdersController.php | 270 +++- controllers/admin/AdminProductsController.php | 60 +- .../admin/AdminTaxRulesGroupController.php | 69 +- controllers/admin/AdminTaxesController.php | 617 +++++---- .../front/OrderConfirmationController.php | 33 +- controllers/front/OrderDetailController.php | 18 +- controllers/front/ProductController.php | 80 +- install/data/db_structure.sql | 73 + js/admin/price.js | 46 +- mails/en/order_conf_total_info.tpl | 12 +- mails/en/order_conf_total_info_text.tpl | 6 +- modules/blockcart/blockcart.php | 18 +- .../classes/HotelBookingDetail.php | 14 +- .../classes/HotelCartBookingData.php | 10 + .../classes/HotelOrderRefundRules.php | 8 +- .../classes/HotelOrderTourismTax.php | 565 -------- .../classes/HotelReservationSystemDb.php | 82 +- .../classes/HotelRoomTypeFeaturePricing.php | 57 +- .../classes/HotelTourismTax.php | 192 --- .../classes/HotelTourismTaxCalculator.php | 513 ------- .../classes/RoomTypeServiceProduct.php | 31 + .../classes/ServiceProductCartDetail.php | 101 +- .../classes/ServiceProductOrderDetail.php | 21 + modules/hotelreservationsystem/define.php | 6 - .../hotelreservationsystem.php | 49 +- .../views/css/HotelReservationAdmin.css | 4 + .../views/css/TourismTaxAdmin.css | 73 - .../views/js/TourismTaxForm.js | 115 +- .../admin/add_hotel/helpers/form/form.tpl | 14 +- .../helpers/view/view.tpl | 18 +- modules/wkhotelroom/wkhotelroom.php | 15 +- pdf/booking-voucher.tpl | 8 +- pdf/invoice.total-tab.tpl | 18 +- .../cart-total-block.tpl | 20 +- .../css/modules/blockcart/blockcart.css | 1 + .../js/modules/blockcart/ajax-cart.js | 6 + .../modules/blockcart/blockcart.tpl | 22 +- .../order-confirmation.tpl | 18 +- .../hotel-reservation-theme/order-detail.tpl | 37 +- 63 files changed, 3399 insertions(+), 2380 deletions(-) create mode 100644 admin/themes/default/template/controllers/products/_tourism_tax_rule_field.tpl create mode 100644 classes/tourismtax/OrderTourismTax.php rename modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php => classes/tourismtax/OrderTourismTaxExemption.php (79%) create mode 100644 classes/tourismtax/TourismTax.php rename modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php => classes/tourismtax/TourismTaxChildRange.php (51%) rename modules/hotelreservationsystem/classes/HotelTourismTaxTier.php => classes/tourismtax/TourismTaxTier.php (51%) create mode 100644 classes/tourismtax/index.php delete mode 100644 modules/hotelreservationsystem/classes/HotelOrderTourismTax.php delete mode 100644 modules/hotelreservationsystem/classes/HotelTourismTax.php delete mode 100644 modules/hotelreservationsystem/classes/HotelTourismTaxCalculator.php delete mode 100644 modules/hotelreservationsystem/views/css/TourismTaxAdmin.css diff --git a/admin/themes/default/template/controllers/normal_products/prices.tpl b/admin/themes/default/template/controllers/normal_products/prices.tpl index 274fe5b0f..84644a117 100644 --- a/admin/themes/default/template/controllers/normal_products/prices.tpl +++ b/admin/themes/default/template/controllers/normal_products/prices.tpl @@ -197,22 +197,7 @@ $(document).ready(function () { - {if isset($tourismTaxRulesGroups)} -

    -
    - -
    - -
    -
    - {/if} + {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/_product_line.tpl b/admin/themes/default/template/controllers/orders/_product_line.tpl index 013adfbd2..367d53394 100644 --- a/admin/themes/default/template/controllers/orders/_product_line.tpl +++ b/admin/themes/default/template/controllers/orders/_product_line.tpl @@ -81,14 +81,15 @@ {convertPriceWithCurrency price=($data['total_room_tax']) currency=$currency->id} - {if isset($use_tourism_tax) && $use_tourism_tax - && isset($data.tourism_tax_status) - && $data.tourism_tax_status eq $tourism_tax_status_applied - && isset($data.tourism_tax_total) && $data.tourism_tax_total > 0} + {if isset($data.tourism_tax_status) && $data.tourism_tax_status == $tourism_tax_status_applied && isset($data.tourism_tax_total) && $data.tourism_tax_total > 0}   '; - $validityInputHtml = ' -
    -
    -
    - ' . $this->l('From') . ' - - -
    -
    -
    -
    - ' . $this->l('To') . ' - - -
    - -
    -
    '; - $this->fields_form = array( 'legend' => array( 'title' => $this->l('Taxes'), @@ -588,6 +628,15 @@ class AdminTaxesControllerCore extends AdminController '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'), @@ -621,15 +670,6 @@ class AdminTaxesControllerCore extends AdminController 'html_content' => $taxValueInputHtml, 'form_group_class' => 'tourism-tax-field', ), - 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('Per night'), @@ -637,6 +677,7 @@ class AdminTaxesControllerCore extends AdminController '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')), @@ -675,26 +716,46 @@ class AdminTaxesControllerCore extends AdminController 'label' => $this->l('Set child age range'), 'name' => 'has_child_age_range', 'is_bool' => true, - 'hint' => $this->l('When enabled, define a custom set of child age ranges below, each with its own tax value.'), + '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, - 'hint' => $this->l('When enabled, the tax amount is determined by the matching price range. Prices outside all ranges use the base tax amount.'), + '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')), @@ -707,16 +768,27 @@ class AdminTaxesControllerCore extends AdminController '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'), - 'name' => 'validity_wrap', - 'html_content' => $validityInputHtml - . '

    ' - . $this->l('Leave both blank to apply with no date restriction. Set only "From" for no end date, or only "To" for no start date.') - . '

    ', - 'form_group_class' => 'tourism-tax-field', + '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', @@ -870,25 +942,68 @@ class AdminTaxesControllerCore extends AdminController } } - $validFrom = trim(Tools::getValue('valid_from', '')); - $validTo = trim(Tools::getValue('valid_to', '')); - if ($validFrom === '0000-00-00') { $validFrom = ''; } - if ($validTo === '0000-00-00') { $validTo = ''; } - if ($validFrom !== '' && !Validate::isDate($validFrom)) { - $this->errors[] = $this->l('"Valid from" is not a valid date (use YYYY-MM-DD).'); - $this->invalidCoreFields['valid_from'] = true; - } - if ($validTo !== '' && !Validate::isDate($validTo)) { - $this->errors[] = $this->l('"Valid to" is not a valid date (use YYYY-MM-DD).'); - $this->invalidCoreFields['valid_to'] = true; - } - if ($validFrom !== '' && $validTo !== '' - && Validate::isDate($validFrom) && Validate::isDate($validTo) - && strtotime($validTo) < strtotime($validFrom) - ) { - $this->errors[] = $this->l('"Valid to" must be on or after "Valid from".'); - $this->invalidCoreFields['valid_from'] = true; - $this->invalidCoreFields['valid_to'] = 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')) { @@ -898,7 +1013,7 @@ class AdminTaxesControllerCore extends AdminController $submittedBandMaxs = Tools::getValue('child_max', array()); $submittedBandValues = Tools::getValue('child_value', array()); if (empty($submittedBandValues)) { - $this->errors[] = $this->l('Add at least one band or disable "Apply on child".'); + $this->errors[] = $this->l('Add at least one range or disable "Set child age range".'); } $filledBands = array(); foreach ($submittedBandValues as $rowIndex => $taxValue) { @@ -906,30 +1021,30 @@ class AdminTaxesControllerCore extends AdminController $bandMaxRaw = isset($submittedBandMaxs[$rowIndex]) ? $submittedBandMaxs[$rowIndex] : ''; $rowNum = (int) $rowIndex + 1; if ($taxValue === '' || $taxValue === false) { - $this->errors[] = sprintf($this->l('Child band #%d: tax value is required.'), $rowNum); + $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 band #%d: tax value must be a valid number.'), $rowNum); + $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 band #%d: tax value cannot be negative.'), $rowNum); + $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 band #%d: min age cannot be greater than max age.'), $rowNum); + $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 band #%d: min age cannot be below the infant age (%d).'), $rowNum, $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 band #%d: max age cannot be %d or above (the general child age setting).'), $rowNum, $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); @@ -943,7 +1058,7 @@ class AdminTaxesControllerCore extends AdminController $innerMaxEffective = ($innerBand['maxAge'] == 0) ? PHP_INT_MAX : $innerBand['maxAge']; if ($outerBand['minAge'] <= $innerMaxEffective && $outerMaxEffective >= $innerBand['minAge']) { $this->errors[] = sprintf( - $this->l('Child age bands #%d and #%d overlap. Each band must cover a distinct age range.'), + $this->l('Child age ranges #%d and #%d overlap. Each range must cover a distinct age range.'), $outerBand['rowNum'], $innerBand['rowNum'] ); @@ -962,7 +1077,7 @@ class AdminTaxesControllerCore extends AdminController $submittedTierMaxs = Tools::getValue('tier_max', array()); $submittedTierValues = Tools::getValue('tier_value', array()); if (empty($submittedTierValues)) { - $this->errors[] = $this->l('Add at least one bracket or disable "Tiered pricing".'); + $this->errors[] = $this->l('Add at least one range or disable "Tiered pricing".'); } $filledTiers = array(); foreach ($submittedTierValues as $rowIndex => $taxValue) { @@ -999,7 +1114,7 @@ class AdminTaxesControllerCore extends AdminController $innerMaxEffective = ($innerTier['maxPrice'] == 0) ? PHP_FLOAT_MAX : $innerTier['maxPrice']; if ($outerTier['minPrice'] < $innerMaxEffective && $outerMaxEffective > $innerTier['minPrice']) { $this->errors[] = sprintf( - $this->l('Price brackets #%d and #%d overlap. Each bracket must cover a distinct price range.'), + $this->l('Price ranges #%d and #%d overlap. Each range must cover a distinct price range.'), $outerTier['rowNum'], $innerTier['rowNum'] ); @@ -1019,7 +1134,6 @@ class AdminTaxesControllerCore extends AdminController parent::setMedia(); if ($this->display == 'edit' || $this->display == 'add') { $this->addCSS(_MODULE_DIR_ . 'hotelreservationsystem/views/css/HotelReservationAdmin.css'); - Media::addJsDef(array('tourismTaxDateErrorMsg' => $this->l('"Valid to" must be on or after "Valid from".'))); $this->addJs(_MODULE_DIR_ . 'hotelreservationsystem/views/js/TourismTaxForm.js'); } } @@ -1043,10 +1157,8 @@ class AdminTaxesControllerCore extends AdminController $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); - $validFromRaw = trim(Tools::getValue('valid_from', '')); - $validToRaw = trim(Tools::getValue('valid_to', '')); - $tourismTax->valid_from = ($validFromRaw && $validFromRaw !== '0000-00-00') ? $validFromRaw : null; - $tourismTax->valid_to = ($validToRaw && $validToRaw !== '0000-00-00') ? $validToRaw : null; + $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)) { @@ -1077,6 +1189,11 @@ class AdminTaxesControllerCore extends AdminController Tools::getValue('child_max', array()), Tools::getValue('child_value', array()) ); + TaxValidityRange::saveAll( + $idTax, + Tools::getValue('validity_from', array()), + Tools::getValue('validity_to', array()) + ); } /** diff --git a/install/data/db_structure.sql b/install/data/db_structure.sql index b2b136ec5..3a5fa76b0 100644 --- a/install/data/db_structure.sql +++ b/install/data/db_structure.sql @@ -1205,8 +1205,8 @@ CREATE TABLE `PREFIX_tax_configuration` ( `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', - `valid_from` date DEFAULT NULL, - `valid_to` date DEFAULT NULL, + `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; @@ -1231,6 +1231,15 @@ CREATE TABLE `PREFIX_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, diff --git a/modules/hotelreservationsystem/views/js/TourismTaxForm.js b/modules/hotelreservationsystem/views/js/TourismTaxForm.js index d5254a44b..80e478c60 100644 --- a/modules/hotelreservationsystem/views/js/TourismTaxForm.js +++ b/modules/hotelreservationsystem/views/js/TourismTaxForm.js @@ -25,61 +25,44 @@ $(document).ready(function () { var tierIdx = $('#tourism-tax-tiers-body tr').length; var childIdx = $('#tourism-tax-child-body tr').length; + var validityIdx = $('#tourism-tax-validity-body tr').length; - $('#valid_from').datepicker({ - dateFormat: 'yy-mm-dd', - onSelect: function () { - $('#valid_to').datepicker('option', 'minDate', $(this).datepicker('getDate')); - validateDates(); - } - }); - - $('#valid_to').datepicker({ dateFormat: 'yy-mm-dd', onSelect: validateDates }); - - function validateDates() { - var from = $('#valid_from').val(); - var to = $('#valid_to').val(); - var $err = $('#tourism-tax-date-error'); - if (from && to) { - try { - var fromTs = $.datepicker.parseDate('yy-mm-dd', from).getTime(); - var toTs = $.datepicker.parseDate('yy-mm-dd', to).getTime(); - if (toTs < fromTs) { - $err.text(tourismTaxDateErrorMsg).show(); - return false; - } - } catch (e) {} - } - $err.hide(); - return true; + 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) { + function ensureOneRow(bodyId, tplId, idxRef, signClass, signFn) { if ($('#' + bodyId + ' tr').length === 0) { var tpl = $('#' + tplId).html().replace(/__IDX__/g, idxRef.val); var $row = $(tpl); - $row.find('.tourism-tax-row-type-sign').text(currentTypeSign()); + if (signClass && signFn) { + $row.find('.' + signClass).text(signFn()); + } + bindValidityDatepickers($row); $('#' + bodyId).append($row); idxRef.val++; } } - var tierIdxRef = { val: tierIdx }; - var childIdxRef = { val: childIdx }; + 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'), t, animate); + 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(); @@ -88,20 +71,27 @@ $(document).ready(function () { 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); + 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); + 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"]').on('change', function () { + $('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); }); @@ -120,7 +110,16 @@ $(document).ready(function () { $('.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(); @@ -135,11 +134,20 @@ $(document).ready(function () { e.preventDefault(); var tpl = $('#tourism-tax-child-row-tpl').html().replace(/__IDX__/g, childIdxRef.val); var $row = $(tpl); - $row.find('.tourism-tax-row-type-sign').text(currentTypeSign()); + $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(); From b39520b6cafcb63ea1043ed1c728f2a6fc588f70 Mon Sep 17 00:00:00 2001 From: shazar rahman Date: Mon, 31 Aug 2026 20:22:54 +0530 Subject: [PATCH 11/11] Updated: Suggested flow review changes for tourism tax and client url for back-office --- .../orders/_hotel_service_products_table.tpl | 2 +- .../controllers/orders/_product_line.tpl | 2 +- .../_standalone_service_products_table.tpl | 2 +- .../orders/_tourism_tax_actions.tpl | 34 +++++-- classes/order/OrderTaxDetail.php | 99 +++++++++++++++---- classes/tax/TaxChildRange.php | 12 +-- classes/tax/TaxConfiguration.php | 8 +- classes/tax/TaxPriceTier.php | 8 +- classes/tax/TaxValidityRange.php | 8 +- controllers/admin/AdminOrdersController.php | 16 +-- controllers/admin/AdminTaxesController.php | 22 ++--- controllers/front/OrderOpcController.php | 13 +++ install/models/install.php | 4 +- .../classes/RoomTypeServiceProduct.php | 93 +++++++++++++++++ .../_partials/cart_booking_demands.tpl | 17 +++- 15 files changed, 270 insertions(+), 70 deletions(-) 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 3024dcbde..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 @@ -156,7 +156,7 @@ {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'} diff --git a/admin/themes/default/template/controllers/orders/_product_line.tpl b/admin/themes/default/template/controllers/orders/_product_line.tpl index a8ac75851..3f0f52ced 100644 --- a/admin/themes/default/template/controllers/orders/_product_line.tpl +++ b/admin/themes/default/template/controllers/orders/_product_line.tpl @@ -199,7 +199,7 @@ {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'} 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 596dbef1a..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 @@ -156,7 +156,7 @@ {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'} diff --git a/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl b/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl index 0d68952a4..95a69f587 100644 --- a/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl +++ b/admin/themes/default/template/controllers/orders/_tourism_tax_actions.tpl @@ -38,6 +38,10 @@