@@ -1034,19 +1036,36 @@
- {if $total_rooms_price_tax_excl}
+ {if $total_rooms_price_tax_excl && $total_rooms_tax_only > 0}
{l s='Total Rooms Tax'}
- {displayPrice price=($total_rooms_price_tax_incl - $total_rooms_price_tax_excl) currency=$currency->id}
+ {displayPrice price=$total_rooms_tax_only currency=$currency->id}
{/if}
- {if isset($total_room_services_and_demands_tax_incl) && (($total_room_services_and_demands_tax_incl - $total_room_services_and_demands_tax_excl) - ($total_convenience_fee_tax_incl - $total_convenience_fee_tax_excl)) > 0}
+ {if $tourism_tax_online_room > 0}
+
+ {l s='Tourism Tax (Room)'}
+
+ {displayPrice price=$tourism_tax_online_room currency=$currency->id}
+
+
+ {/if}
+ {if isset($total_room_services_and_demands_tax_incl) && $total_services_tax_only > 0}
{l s='Extra services Tax'}
- {displayPrice price=(($total_room_services_and_demands_tax_incl - $total_room_services_and_demands_tax_excl) - ($total_convenience_fee_tax_incl - $total_convenience_fee_tax_excl)) currency=$currency->id}
+ {displayPrice price=$total_services_tax_only currency=$currency->id}
+
+
+
+ {/if}
+ {if $tourism_tax_online_service > 0}
+
+ {l s='Tourism Tax (Service Product)'}
+
+ {displayPrice price=$tourism_tax_online_service currency=$currency->id}
diff --git a/admin/themes/default/template/controllers/orders/modals/_add_room_booking.tpl b/admin/themes/default/template/controllers/orders/modals/_add_room_booking.tpl
index c9d897d84..27cd54266 100644
--- a/admin/themes/default/template/controllers/orders/modals/_add_room_booking.tpl
+++ b/admin/themes/default/template/controllers/orders/modals/_add_room_booking.tpl
@@ -128,6 +128,16 @@
{/if}
+ {if isset($use_tourism_tax) && $use_tourism_tax}
+
+ {/if}
- {if isset($tourismTaxRulesGroups)}
-
- {/if}
+ {include file="controllers/products/_tourism_tax_rule_field.tpl"}
{l s='Retail price with tax'}
diff --git a/classes/Cart.php b/classes/Cart.php
index 0eca871d0..2c8882e64 100644
--- a/classes/Cart.php
+++ b/classes/Cart.php
@@ -187,6 +187,7 @@ class CartCore extends ObjectModel
const ONLY_CONVENIENCE_FEE = 18;
const ONLY_PRODUCTS_WITH_DEMANDS = 19;
+ const ONLY_TOURISM_TAX = 20;
public function __construct($id = null, $id_lang = null)
{
@@ -1572,6 +1573,7 @@ class CartCore extends ObjectModel
Cart::ADVANCE_PAYMENT,
Cart::ADVANCE_PAYMENT_ONLY_PRODUCTS,
Cart::ONLY_PRODUCTS_WITH_DEMANDS,
+ Cart::ONLY_TOURISM_TAX,
);
// Define virtual context to prevent case where the cart is not the in the global context
@@ -1639,6 +1641,7 @@ class CartCore extends ObjectModel
$products_total = array();
$ecotax_total = 0;
$tourism_tax_total = 0.0;
+ $tourism_tax_online_service = 0.0;
$totalDemandsPrice = 0;
$objCartBookingData = new HotelCartBookingData();
$objServiceProductCartDetail = new ServiceProductCartDetail();
@@ -1774,6 +1777,11 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $priceAdd * (int)$servicePorduct['quantity'];
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $servicePorduct['tourism_tax_online'];
+ $tourism_tax_online_service += $servicePorduct['tourism_tax_online'];
+ }
}
}
@@ -1827,6 +1835,11 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $priceAdd;
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $servicePorduct['tourism_tax_online'];
+ $tourism_tax_online_service += $servicePorduct['tourism_tax_online'];
+ }
}
}
} else if (Product::SELLING_PREFERENCE_WITH_ROOM_TYPE == $product['selling_preference_type']) {
@@ -1856,6 +1869,11 @@ class CartCore extends ObjectModel
} else {
$products_total[$id_tax_rules_group] += $servicePrice;
}
+
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
+ $tourism_tax_total += $service['tourism_tax_online'];
+ $tourism_tax_online_service += $service['tourism_tax_online'];
+ }
}
}
}
@@ -1865,8 +1883,6 @@ class CartCore extends ObjectModel
$priceDisplay = Group::getPriceDisplayMethod(Group::getCurrent()->id);
if ($type == Cart::ADVANCE_PAYMENT || $type == Cart::ADVANCE_PAYMENT_ONLY_PRODUCTS) {
- // getProductMinAdvPaymentAmountByIdCart already aggregates all rooms for this product internally,
- // so call it once and add once — not inside the per-room loop.
$advProductPrice = $objAdvPayment->getProductMinAdvPaymentAmountByIdCart(
$this->id,
$product['id_product'],
@@ -1913,7 +1929,7 @@ class CartCore extends ObjectModel
$products_total[$id_tax_rules_group] += Tools::processPriceRounding($totalPriceByProduct);
}
- if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING))) {
+ if ($with_taxes && in_array($type, array(Cart::BOTH, Cart::BOTH_WITHOUT_SHIPPING, Cart::ONLY_TOURISM_TAX))) {
$tourism_tax_total += $roomTotalPrice['tourism_tax_online'];
}
}
@@ -1923,6 +1939,14 @@ class CartCore extends ObjectModel
// price of extra demands on room type in the cart
$totalDemandsPrice += $objCartBookingData->getCartExtraDemands($this->id, $product['id_product'], 0, 0, 0, 1, 0, (int)$with_taxes);
}
+
+ if ($type == Cart::ONLY_TOURISM_TAX) {
+ return array(
+ 'tourism_tax_online' => $tourism_tax_total,
+ 'tourism_tax_online_service' => $tourism_tax_online_service,
+ );
+ }
+
foreach ($products_total as $key => $price) {
$order_total += $price;
}
@@ -3951,14 +3975,16 @@ 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;
- }
+
+ $tourismTaxTotals = $this->getOrderTotal(true, Cart::ONLY_TOURISM_TAX);
+ $tourism_tax_online = $tourismTaxTotals['tourism_tax_online'];
+ $tourism_tax_online_service = $tourismTaxTotals['tourism_tax_online_service'];
+ $tourism_tax_online_room = $tourism_tax_online - $tourism_tax_online_service;
+
+ if (TourismTax::isGrossedUp($tourism_tax_online)) {
+ $total_rooms_wt += $tourism_tax_online_room;
+ $total_additional_services_wt += $tourism_tax_online_service;
+ $total_rooms_with_services_without_discount_ti += $tourism_tax_online;
}
$summary = array(
@@ -4008,7 +4034,8 @@ class CartCore extends ObjectModel
'cart_total_without_discount_ti' => $cart_total_without_discount_ti,
'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'),
+ 'total_tourism_tax' => max(0.0, (float) $tourism_tax_online),
+ 'tourism_tax_grossed_up' => TourismTax::isGrossedUp($tourism_tax_online),
);
$hook = Hook::exec('actionCartSummary', $summary, null, true);
if (is_array($hook)) {
diff --git a/classes/PaymentModule.php b/classes/PaymentModule.php
index c76c981f5..8277eb006 100644
--- a/classes/PaymentModule.php
+++ b/classes/PaymentModule.php
@@ -505,6 +505,7 @@ abstract class PaymentModuleCore extends Module
$objMail = new Mail();
$objServiceProductCartDetail = new ServiceProductCartDetail();
$cart_rules = $this->context->cart->getCartRules();
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
foreach ($order_detail_list as $key => $order_detail) {
/** @var OrderDetail $order_detail */
@@ -902,41 +903,34 @@ 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 ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $hotelTaxContext = TourismTax::resolveHotelAddressAndCollectionType(
+ (int) $objCartBookingData->id_hotel,
+ new Address((int) Cart::getIdAddressForTaxCalculation($idProduct))
+ );
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($objBookingDetail->date_from, $objBookingDetail->date_to));
+ $numAdults = (int) $objCartBookingData->adults;
+ $childAges = !empty($objCartBookingData->child_ages) ? (array) json_decode($objCartBookingData->child_ages, true) : array();
+ $unitPriceTaxExcl = (float) $total_price['total_price_tax_excl'] / $numNights;
+
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $hotelTaxContext['address'],
+ $unitPriceTaxExcl,
+ $objBookingDetail->date_from,
+ $numNights,
+ $numAdults,
+ $childAges,
+ (int) $this->context->cart->id_currency,
+ $hotelTaxContext['collectionType'],
+ (int) $idLang,
+ 1,
+ $order->id,
+ (int) $id_order_detail,
+ $objBookingDetail->id,
+ 0,
+ (int) $objCartBookingData->id_hotel
);
- 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
@@ -1038,7 +1032,29 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->name = $product['name'];
$objServiceProductOrderDetail->quantity = $roomTypeService['quantity'];
$objServiceProductOrderDetail->auto_added = $product['auto_add_to_cart'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TourismTax::resolveServiceLineTaxContext($roomBookingDetail['id_hotel'], $roomBookingDetail['id'], new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id,
+ (int) $roomBookingDetail['id_hotel']
+ );
+ }
+ }
}
}
} elseif (Product::SELLING_PREFERENCE_HOTEL_STANDALONE == $product['selling_preference_type']) {
@@ -1071,7 +1087,29 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->hotel_name = $objHotelBranch->hotel_name;
}
$objServiceProductOrderDetail->quantity = $hotelProduct['quantity'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TourismTax::resolveServiceLineTaxContext($product['id_hotel'], 0, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id,
+ (int) $product['id_hotel']
+ );
+ }
+ }
}
}
@@ -1099,7 +1137,29 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->name = $standaloneProduct['name'];
$objServiceProductOrderDetail->option_name = $standaloneProduct['option_name'];
$objServiceProductOrderDetail->quantity = $standaloneProduct['quantity'];
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TourismTax::resolveServiceLineTaxContext(0, 0, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id,
+ 0
+ );
+ }
+ }
}
}
@@ -1156,7 +1216,31 @@ abstract class PaymentModuleCore extends Module
$objServiceProductOrderDetail->id_htl_booking_detail = $roomBookingDetail['id'];
$objServiceProductOrderDetail->auto_added = $product['auto_add_to_cart'];
}
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ $idHotel = $serviceProduct['id_hotel'] ? $serviceProduct['id_hotel'] : (isset($roomBookingDetail['id_hotel']) ? (int) $roomBookingDetail['id_hotel'] : 0);
+ $idHtlBookingDetail = $serviceProduct['id_hotel'] ? 0 : (isset($roomBookingDetail['id']) ? (int) $roomBookingDetail['id'] : 0);
+ if ($useTourismTax && ($idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct))) {
+ $tourismTaxContext = TourismTax::resolveServiceLineTaxContext($idHotel, $idHtlBookingDetail, new Address((int) Cart::getIdAddressForTaxCalculation($idProduct)));
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxContext['address'],
+ (float) $objServiceProductOrderDetail->unit_price_tax_excl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $tourismTaxContext['collectionType'],
+ (int) $idLang,
+ (int) $objServiceProductOrderDetail->quantity,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id,
+ $idHotel
+ );
+ }
+ }
}
}
}
@@ -1292,7 +1376,8 @@ abstract class PaymentModuleCore extends Module
$service_products_tax = ($standalone_products_price_tax_incl + $hotel_standalone_products_price_tax_incl) - ($standalone_products_price_tax_excl + $hotel_standalone_products_price_tax_excl);
- $total_order_tax = $room_tax + $additional_service_tax + $total_convenience_fee_tax + $service_products_tax;
+ $totalTourismTax = OrderTourismTax::getOrderTourismTaxTotal((int) $order->id);
+ $total_order_tax = max(0, $room_tax + $additional_service_tax + $total_convenience_fee_tax + $service_products_tax - $totalTourismTax);
$total_products = Tools::displayPrice(Product::getTaxCalculationMethod() == PS_TAX_EXC ? $order->total_products : $order->total_products_wt, $this->context->currency, false);
@@ -1318,6 +1403,8 @@ abstract class PaymentModuleCore extends Module
),
'service_products_tax' => Tools::displayPrice($service_products_tax, $this->context->currency, false),
'total_order_tax' => Tools::displayPrice($total_order_tax, $this->context->currency, false),
+ 'total_tourism_tax' => Tools::displayPrice($totalTourismTax, $this->context->currency, false),
+ 'has_tourism_tax' => $totalTourismTax > 0,
'total_paid' => Tools::displayPrice($order->total_paid, $this->context->currency, false),
'total_products' => Tools::displayPrice($total_products, $this->context->currency, false),
'total_discounts' => Tools::displayPrice(-$order->total_discounts, $this->context->currency, false),
diff --git a/classes/Product.php b/classes/Product.php
index 4f96668ac..76245cc31 100644
--- a/classes/Product.php
+++ b/classes/Product.php
@@ -5416,6 +5416,31 @@ class ProductCore extends ObjectModel
return Cache::retrieve($key);
}
+ /**
+ * Returns the tourism tax rules group assigned to a product, mirroring
+ * getIdTaxRulesGroupByIdProduct() for tourism tax's own product_shop column.
+ *
+ * @param int $id_product
+ * @param Context|null $context
+ * @return int
+ */
+ public static function getIdTourismTaxRulesGroupByIdProduct($id_product, ?Context $context = null)
+ {
+ if (!$context) {
+ $context = Context::getContext();
+ }
+ $key = 'product_id_tourism_tax_rules_group_'.(int)$id_product.'_'.(int)$context->shop->id;
+ if (!Cache::isStored($key)) {
+ $result = Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue('
+ SELECT `id_tourism_tax_rules_group`
+ FROM `'._DB_PREFIX_.'product_shop`
+ WHERE `id_product` = '.(int)$id_product.' AND id_shop='.(int)$context->shop->id);
+ Cache::store($key, (int)$result);
+ return (int)$result;
+ }
+ return Cache::retrieve($key);
+ }
+
/**
* Returns tax rate.
*
diff --git a/classes/order/OrderInvoice.php b/classes/order/OrderInvoice.php
index 616e01bde..ec97d3a57 100644
--- a/classes/order/OrderInvoice.php
+++ b/classes/order/OrderInvoice.php
@@ -860,12 +860,6 @@ class OrderInvoiceCore extends ObjectModel
return $wrapping_breakdown;
}
- /**
- * Returns the ecotax taxes breakdown
- *
- * @since 1.5
- * @return array
- */
/**
* Return tourism tax breakdown grouped by id_tax for invoice display.
*
@@ -873,9 +867,15 @@ class OrderInvoiceCore extends ObjectModel
*/
public function getTourismTaxBreakdown()
{
- return HotelOrderTourismTax::getBreakdownForInvoice((int) $this->id_order);
+ return OrderTourismTax::getBreakdownForInvoice((int) $this->id_order);
}
+ /**
+ * Returns the ecotax taxes breakdown
+ *
+ * @since 1.5
+ * @return array
+ */
public function getEcoTaxTaxesBreakdown()
{
$result = Db::getInstance()->executeS('
diff --git a/classes/order/OrderReturn.php b/classes/order/OrderReturn.php
index cbcca6cb9..f12633d51 100644
--- a/classes/order/OrderReturn.php
+++ b/classes/order/OrderReturn.php
@@ -211,7 +211,8 @@ class OrderReturnCore extends ObjectModel
$calcServicePriceFirst = true;
}
- $onlineTourismTaxByBooking = Configuration::get('QLO_USE_TOURISM_TAX') ? HotelOrderTourismTax::getOnlineTotalsByBooking($idOrder) : array();
+ $appliedTourismTaxByBooking = OrderTourismTax::getAppliedTotalsByBooking($idOrder);
+ $appliedServiceTourismTaxByBooking = OrderTourismTax::getAppliedServiceTotalsByBooking($idOrder);
foreach ($returnDetails as $key => &$bookingRow) {
if ($skipReqCompletedNonRefunded) {
@@ -226,7 +227,7 @@ 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_applied_amount'] = 0;
$bookingRow['tourism_tax_paid_amount'] = 0;
$roomSelectedDemands = $objBookingDemands->getRoomTypeBookingExtraDemands(
@@ -295,11 +296,11 @@ 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) {
+ $bookingRow['tourism_tax_applied_amount'] = (isset($appliedTourismTaxByBooking[$bookingRow['id_htl_booking']]) ? (float) $appliedTourismTaxByBooking[$bookingRow['id_htl_booking']] : 0.0)
+ + (isset($appliedServiceTourismTaxByBooking[$bookingRow['id_htl_booking']]) ? (float) $appliedServiceTourismTaxByBooking[$bookingRow['id_htl_booking']] : 0.0);
+ if ($bookingRow['tourism_tax_applied_amount'] > 0 && $objOrder->total_paid_real > 0) {
$denominator = $objOrder->total_paid_tax_incl + $objOrder->total_discounts_tax_incl;
- $bookingRow['tourism_tax_paid_amount'] = $denominator > 0 ? ($objOrder->total_paid_real * $tourismTaxOnline / $denominator) : 0.0;
+ $bookingRow['tourism_tax_paid_amount'] = $denominator > 0 ? ($objOrder->total_paid_real * $bookingRow['tourism_tax_applied_amount'] / $denominator) : 0.0;
}
if ($customerView) {
$dateJoin = $bookingRow['id_product'].'_'.strtotime($bookingRow['date_from']).strtotime($bookingRow['date_to']);
diff --git a/classes/pdf/HTMLTemplateBookingVoucher.php b/classes/pdf/HTMLTemplateBookingVoucher.php
index c4e5d17df..a2dfcf011 100644
--- a/classes/pdf/HTMLTemplateBookingVoucher.php
+++ b/classes/pdf/HTMLTemplateBookingVoucher.php
@@ -321,7 +321,9 @@ class HTMLTemplateBookingVoucherCore extends HTMLTemplate
$footer['total_without_discount_te'] = $footer['room_price_tax_excl'] + $footer['total_convenience_fee_te'] + $footer['additional_service_price_tax_excl'] + $footer['service_products_price_tax_excl'];
$totalWithoutDiscountTaxIncl = $roomsCostTaxIncl + $serviceProductsCostTaxIncl + $additionalServicesCostTaxIncl + $convenienceFeeTaxIncl;
- $footer['total_tax_without_discount'] = max(0, $totalWithoutDiscountTaxIncl - $footer['total_without_discount_te']);
+ $totalTourismTax = OrderTourismTax::getOrderTourismTaxTotal((int) $this->order->id);
+ $footer['total_tax_without_discount'] = max(0, $totalWithoutDiscountTaxIncl - $footer['total_without_discount_te'] - $totalTourismTax);
+ $footer['total_tourism_tax'] = $totalTourismTax;
$grandTotal = max(0.0, $totalWithoutDiscountTaxIncl - $productDiscountsTaxIncl);
$footer['product_discounts_tax_incl'] = $productDiscountsTaxIncl;
$footer['total_paid_tax_incl'] = $grandTotal;
diff --git a/classes/pdf/HTMLTemplateInvoice.php b/classes/pdf/HTMLTemplateInvoice.php
index 1a721b3b4..9d99b108b 100644
--- a/classes/pdf/HTMLTemplateInvoice.php
+++ b/classes/pdf/HTMLTemplateInvoice.php
@@ -687,27 +687,13 @@ class HTMLTemplateInvoiceCore extends HTMLTemplate
$footer['total_without_discount_te'] = $footer['room_price_tax_excl'] + $footer['total_convenience_fee_te'] + $footer['additional_service_price_tax_excl'] + $footer['service_products_price_tax_excl'];
$footer['total_without_discount_ti'] = $footer['room_price_tax_incl'] + $footer['total_convenience_fee_ti'] + $footer['additional_service_price_tax_incl'] + $footer['service_products_price_tax_incl'];
- $footer['total_tax_without_discount'] = $footer['total_without_discount_ti'] - $footer['total_without_discount_te'];
+ $footer['total_tourism_tax'] = OrderTourismTax::getOrderTourismTaxTotal((int) $this->order->id);
+
+ $footer['total_tax_without_discount'] = $footer['total_without_discount_ti'] - $footer['total_without_discount_te'] - $footer['total_tourism_tax'];
if ($footer['total_tax_without_discount'] < 0) {
$footer['total_tax_without_discount'] = 0;
}
- $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),
diff --git a/classes/tax/Tax.php b/classes/tax/Tax.php
index 13838239b..57d600e6a 100644
--- a/classes/tax/Tax.php
+++ b/classes/tax/Tax.php
@@ -39,7 +39,7 @@ 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) */
+ /** @var int 1 = this ps_tax row is a tourism tax (subtype params in tourism_tax) */
public $is_tourism_tax = 0;
/**
@@ -139,6 +139,10 @@ class TaxCore extends ObjectModel
SELECT `id_tax`
FROM `'._DB_PREFIX_.'order_detail_tax`
WHERE `id_tax` = '.(int)$this->id
+ ) || Db::getInstance()->getValue('
+ SELECT `id_tax`
+ FROM `'._DB_PREFIX_.'order_tourism_tax`
+ WHERE `id_tax` = '.(int)$this->id
);
}
diff --git a/classes/tax/TaxCalculator.php b/classes/tax/TaxCalculator.php
index df2d2d6df..ee7a10f12 100644
--- a/classes/tax/TaxCalculator.php
+++ b/classes/tax/TaxCalculator.php
@@ -171,4 +171,141 @@ class TaxCalculatorCore
return $amount;
}
+
+ /**
+ * @param float $unitPriceTaxExcl Room/service unit_price_tax_excl (% base and tier lookup)
+ * @param string $checkInDate 'YYYY-MM-DD'
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges Ages at check-in date
+ * @param int $idCurrency
+ * @param int $collectionType HotelBranchInformation::tourism_tax_collection_type snapshot
+ * @param int $idLang
+ * @param int $quantity Line quantity multiplier. Rooms: always 1.
+ * @param bool $isManualApply true only from the admin Apply-button flow — bypasses the
+ * at-hotel collection-type gate. Every automatic call site
+ * (order creation, cart/product-page/list preview) leaves this
+ * false, so a manual-collection hotel computes/shows nothing
+ * until staff act.
+ * @return array ['tourism_tax_online' => float,
+ * 'rows' => [['id_tax','tax_name','num_nights','num_adults','total_amount','collection_type'], ...]]
+ */
+ public function getTourismTaxRows(
+ $unitPriceTaxExcl,
+ $checkInDate,
+ $numNights,
+ $numAdults,
+ $childrenAges,
+ $idCurrency,
+ $collectionType,
+ $idLang,
+ $quantity = 1,
+ $isManualApply = false
+ ) {
+ $result = array('tourism_tax_online' => 0.0, 'rows' => array());
+
+ $collectionType = (int) $collectionType;
+ if (!Configuration::get('QLO_USE_TOURISM_TAX')) {
+ return $result;
+ }
+ if ($collectionType === TourismTax::COLLECTION_TYPE_AT_HOTEL && !$isManualApply) {
+ return $result;
+ }
+
+ $checkIn = new DateTime($checkInDate);
+ $isoDayIndex = $checkIn->format('N') - 1;
+
+ $rows = array();
+
+ foreach ($this->taxes as $tax) {
+ $tourismTax = TourismTax::getByTaxId((int) $tax->id);
+ if (!$tourismTax) {
+ continue;
+ }
+
+ if (!empty($tourismTax->valid_from) && $tourismTax->valid_from !== '0000-00-00') {
+ $validFrom = new DateTime($tourismTax->valid_from);
+ if ($checkIn < $validFrom) {
+ continue;
+ }
+ }
+ if (!empty($tourismTax->valid_to) && $tourismTax->valid_to !== '0000-00-00') {
+ $validTo = new DateTime($tourismTax->valid_to);
+ if ($checkIn > $validTo) {
+ continue;
+ }
+ }
+ if ($tourismTax->special_days) {
+ $specialDays = json_decode($tourismTax->special_days, true);
+ if (!is_array($specialDays) || !in_array(TourismTax::DAY_KEYS[$isoDayIndex], $specialDays)) {
+ continue;
+ }
+ }
+
+ $isTiered = (bool) $tourismTax->is_tiered;
+ $taxType = (int) $tourismTax->tax_calc_type;
+ $isPerNight = (bool) $tourismTax->is_per_night;
+ $isPerPerson = (bool) $tourismTax->is_per_person;
+ $hasChildRate = (bool) $tourismTax->has_child_rate;
+
+ $baseValue = (float) $tourismTax->tax_value;
+ if ($isTiered) {
+ $tier = TourismTaxTier::getMatchingTier((int) $tax->id, $unitPriceTaxExcl);
+ if (!$tier) {
+ continue;
+ }
+ $baseValue = (float) $tier['tax_value'];
+ }
+
+ $adultMultiplier = 1;
+ if ($isPerNight) {
+ $adultMultiplier *= $numNights;
+ }
+ if ($isPerPerson) {
+ $adultMultiplier *= $numAdults;
+ }
+
+ if ($taxType === 0) {
+ $unitAmountAdult = $baseValue;
+ $totalAmountAdult = $baseValue * $adultMultiplier;
+ } else {
+ $unitAmountAdult = $unitPriceTaxExcl * ($baseValue / 100);
+ $totalAmountAdult = $unitAmountAdult * $adultMultiplier;
+ }
+
+ $totalAmountChild = 0.0;
+
+ if ($hasChildRate && !empty($childrenAges)) {
+ $contribution = TourismTaxChildRange::getChildContribution(
+ (int) $tax->id,
+ $childrenAges,
+ $taxType,
+ $unitPriceTaxExcl,
+ $baseValue
+ );
+ if ((int) $contribution['count'] > 0) {
+ $nightMultiplier = $isPerNight ? $numNights : 1;
+ $totalAmountChild = $contribution['total'] * $nightMultiplier;
+ }
+ }
+
+ $totalAmountAdult = Tools::convertPrice($totalAmountAdult, $idCurrency);
+ $totalAmountChild = Tools::convertPrice($totalAmountChild, $idCurrency);
+ $totalAmount = Tools::ps_round(($totalAmountAdult + $totalAmountChild) * max(1, $quantity), 6);
+
+ $rows[] = array(
+ 'id_tax' => (int) $tax->id,
+ 'tax_name' => (string) (isset($tax->name[$idLang]) ? $tax->name[$idLang] : ''),
+ 'num_nights' => $numNights,
+ 'num_adults' => $numAdults,
+ 'total_amount' => $totalAmount,
+ 'collection_type' => $collectionType,
+ );
+ }
+
+ $result['rows'] = $rows;
+ $result['tourism_tax_online'] = (float) array_sum(array_column($rows, 'total_amount'));
+
+ return $result;
+ }
}
diff --git a/classes/tax/TaxRulesGroup.php b/classes/tax/TaxRulesGroup.php
index 140130443..a442e1e29 100644
--- a/classes/tax/TaxRulesGroup.php
+++ b/classes/tax/TaxRulesGroup.php
@@ -166,8 +166,7 @@ class TaxRulesGroupCore extends ObjectModel
*/
public static function getTaxRulesGroupsForOptions($onlyActive = true, $isTourismTax = false)
{
- $placeholder = $isTourismTax ? Tools::displayError('None') : Tools::displayError('No tax');
- $tax_rules = array(array('id_tax_rules_group' => 0, 'name' => $placeholder));
+ $tax_rules[] = array('id_tax_rules_group' => 0, 'name' => Tools::displayError('No Tax'));
return array_merge($tax_rules, TaxRulesGroup::getTaxRulesGroups($onlyActive, $isTourismTax));
}
diff --git a/classes/tourismtax/OrderTourismTax.php b/classes/tourismtax/OrderTourismTax.php
new file mode 100644
index 000000000..0aeaa853f
--- /dev/null
+++ b/classes/tourismtax/OrderTourismTax.php
@@ -0,0 +1,1196 @@
+ '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_service_product_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
+ '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),
+ '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'),
+ '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'),
+ ),
+ );
+
+ /**
+ * Applied (non-exempted, non-refunded) tourism tax totals for every booking of an order, keyed by id_htl_booking — cached per order.
+ *
+ * @param int $idOrder
+ * @return array [id_htl_booking => float total_amount]
+ */
+ public static function getAppliedTotalsByBooking($idOrder)
+ {
+ $idOrder = (int) $idOrder;
+ $cacheId = 'OrderTourismTax::getAppliedTotalsByBooking-' . $idOrder;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $rows = Db::getInstance()->executeS(
+ 'SELECT `id_htl_booking`, SUM(`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `id_order` = ' . $idOrder . '
+ 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'];
+ }
+ }
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Applied (non-exempted, non-refunded) tourism tax totals for every service line of an order, keyed by the room booking it's attached to — cached per order.
+ *
+ * @param int $idOrder
+ * @return array [id_htl_booking_detail => float total_amount]
+ */
+ public static function getAppliedServiceTotalsByBooking($idOrder)
+ {
+ $idOrder = (int) $idOrder;
+ $cacheId = 'OrderTourismTax::getAppliedServiceTotalsByBooking-' . $idOrder;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $rows = Db::getInstance()->executeS(
+ 'SELECT spod.`id_htl_booking_detail`, SUM(tt.`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax` tt
+ INNER JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod
+ ON spod.`id_service_product_order_detail` = tt.`id_service_product_order_detail`
+ WHERE tt.`id_order` = ' . $idOrder . '
+ AND spod.`id_htl_booking_detail` != 0
+ AND tt.`is_exempted` = 0
+ AND tt.`is_refunded` = 0
+ GROUP BY spod.`id_htl_booking_detail`'
+ );
+ $result = array();
+ if ($rows) {
+ foreach ($rows as $row) {
+ $result[(int) $row['id_htl_booking_detail']] = (float) $row['total_amount'];
+ }
+ }
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Status + total (non-exempted, non-refunded) for every booking of an order in one query.
+ *
+ * @param int $idOrder
+ * @return array [id_htl_booking => ['status' => int, 'total' => float]]
+ */
+ public static function getStatusAndTotalByBooking($idOrder)
+ {
+ $rows = Db::getInstance()->executeS(
+ 'SELECT `id_htl_booking`,
+ SUM(CASE WHEN `is_exempted` = 0 THEN `total_amount` ELSE 0 END) AS total_amount,
+ SUM(CASE WHEN `is_exempted` = 0 THEN 1 ELSE 0 END) AS applied_count
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `id_order` = ' . (int) $idOrder . '
+ AND `is_refunded` = 0
+ GROUP BY `id_htl_booking`'
+ );
+ $result = array();
+ if ($rows) {
+ foreach ($rows as $row) {
+ $result[(int) $row['id_htl_booking']] = array(
+ 'status' => ((int) $row['applied_count'] > 0) ? self::STATUS_APPLIED : self::STATUS_EXEMPTED,
+ 'total' => (float) $row['total_amount'],
+ );
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * Mark all rows for a booking as refunded (used on room cancellation).
+ *
+ * @param int $idHtlBooking
+ * @return bool
+ */
+ public static function setRefundedByHtlBooking($idHtlBooking)
+ {
+ return self::setRefundedForScope('id_htl_booking', (int) $idHtlBooking);
+ }
+
+ /**
+ * Mark all rows for a service-product order-detail line as refunded (used on service refund).
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return bool
+ */
+ public static function setRefundedByServiceProductOrderDetail($idServiceProductOrderDetail)
+ {
+ return self::setRefundedForScope('id_service_product_order_detail', (int) $idServiceProductOrderDetail);
+ }
+
+ /**
+ * Shared core for setRefundedByHtlBooking()/setRefundedByServiceProductOrderDetail().
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function setRefundedForScope($scopeColumn, $scopeValue)
+ {
+ return Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_tourism_tax`
+ SET `is_refunded` = 1, `date_upd` = NOW()
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Delete all snapshot rows for a scope.
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function deleteForScope($scopeColumn, $scopeValue)
+ {
+ return Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Hard-delete every tourism tax row for a booking and its attached service lines (room removed from an already-placed order).
+ *
+ * @param int $idHtlBooking
+ * @param int[] $serviceLineIds ServiceProductOrderDetail ids attached to this booking
+ * @return void
+ */
+ public static function hardDeleteForBooking($idHtlBooking, array $serviceLineIds)
+ {
+ self::deleteForScope('id_htl_booking', (int) $idHtlBooking);
+ foreach ($serviceLineIds as $idServiceLine) {
+ self::deleteForScope('id_service_product_order_detail', (int) $idServiceLine);
+ }
+ }
+
+ /**
+ * Sum of applied (non-exempted) total_amount for a booking/service-line scope — exemption-aware.
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return float
+ */
+ protected static function getScopedTotal($scopeColumn, $scopeValue)
+ {
+ return (float) Db::getInstance()->getValue(
+ 'SELECT COALESCE(SUM(CASE WHEN `is_exempted` = 0 THEN `total_amount` ELSE 0 END), 0)
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue
+ );
+ }
+
+ /**
+ * Whether any row currently exists for this scope with is_exempted = 1.
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function wasScopeExempted($scopeColumn, $scopeValue)
+ {
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue . ' AND `is_exempted` = 1'
+ );
+ }
+
+ /**
+ * Compute via TaxCalculator::getTourismTaxRows() and persist tourism tax for one room-booking or service-product line.
+ *
+ * @param int $idTaxRulesGroup Tourism TRG assigned to the product
+ * @param Address $address Jurisdiction address (the hotel's own, not the customer's)
+ * @param float $unitPriceTaxExcl Room/service unit_price_tax_excl (% base and tier lookup)
+ * @param string $checkInDate 'YYYY-MM-DD'
+ * @param int $numNights
+ * @param int $numAdults
+ * @param int[] $childrenAges Ages at check-in date
+ * @param int $idCurrency
+ * @param int $collectionType HotelBranchInformation::tourism_tax_collection_type snapshot
+ * @param int $idLang
+ * @param int $quantity Line quantity multiplier. Rooms: always 1.
+ * @param int $idOrder
+ * @param int $idOrderDetail
+ * @param int $idHtlBooking 0 for service lines
+ * @param int $idServiceProductOrderDetail 0 for room bookings
+ * @param int $idHotel
+ * @param bool $isManualApply true only from the admin Apply-button flow
+ * @return void
+ */
+ public static function saveTourismTax(
+ $idTaxRulesGroup,
+ Address $address,
+ $unitPriceTaxExcl,
+ $checkInDate,
+ $numNights,
+ $numAdults,
+ array $childrenAges,
+ $idCurrency,
+ $collectionType,
+ $idLang,
+ $quantity,
+ $idOrder,
+ $idOrderDetail,
+ $idHtlBooking,
+ $idServiceProductOrderDetail,
+ $idHotel,
+ $isManualApply = false
+ ) {
+ $idTaxRulesGroup = (int) $idTaxRulesGroup;
+ $idOrderDetail = (int) $idOrderDetail;
+ if (!Configuration::get('QLO_USE_TOURISM_TAX') || !$idTaxRulesGroup || !$idOrderDetail) {
+ return;
+ }
+
+ $taxCalculator = TaxManagerFactory::getManager($address, $idTaxRulesGroup)->getTaxCalculator();
+ $rows = $taxCalculator->getTourismTaxRows(
+ $unitPriceTaxExcl,
+ $checkInDate,
+ $numNights,
+ $numAdults,
+ $childrenAges,
+ $idCurrency,
+ $collectionType,
+ $idLang,
+ $quantity,
+ $isManualApply
+ )['rows'];
+
+ $idOrder = (int) $idOrder;
+ $idHtlBooking = (int) $idHtlBooking;
+ $idHotel = (int) $idHotel;
+ $idCurrency = (int) $idCurrency;
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $scopeColumn = $idServiceProductOrderDetail ? 'id_service_product_order_detail' : 'id_htl_booking';
+ $scopeValue = $idServiceProductOrderDetail ?: $idHtlBooking;
+
+ $wasExempted = self::wasScopeExempted($scopeColumn, $scopeValue);
+ $prevTotal = self::getScopedTotal($scopeColumn, $scopeValue);
+ self::deleteForScope($scopeColumn, $scopeValue);
+
+ $totalTourismTax = 0.0;
+ foreach ($rows as $row) {
+ $rowAmount = (float) $row['total_amount'];
+
+ self::insertRow(
+ $idOrder,
+ $idOrderDetail,
+ $idHtlBooking,
+ $idServiceProductOrderDetail,
+ $idHotel,
+ (int) $row['id_tax'],
+ $idCurrency,
+ (int) $row['num_nights'],
+ (int) $row['num_adults'],
+ $rowAmount,
+ $wasExempted ? 1 : 0
+ );
+
+ if (!$wasExempted) {
+ $totalTourismTax += $rowAmount;
+ }
+ }
+
+ self::adjustOrderDetailTotals($idOrderDetail, $prevTotal, $totalTourismTax);
+ }
+
+ /**
+ * Insert one persisted tourism tax snapshot row for a matched tax on a booking/service line.
+ *
+ * @param int $idOrder
+ * @param int $idOrderDetail
+ * @param int $idHtlBooking 0 for service lines
+ * @param int $idServiceProductOrderDetail 0 for room bookings
+ * @param int $idHotel
+ * @param int $idTax
+ * @param int $idCurrency
+ * @param int $numNights
+ * @param int $numAdults
+ * @param float $totalAmount
+ * @param int $isExempted
+ * @return bool
+ */
+ protected static function insertRow(
+ $idOrder,
+ $idOrderDetail,
+ $idHtlBooking,
+ $idServiceProductOrderDetail,
+ $idHotel,
+ $idTax,
+ $idCurrency,
+ $numNights,
+ $numAdults,
+ $totalAmount,
+ $isExempted = 0
+ ) {
+ $row = new OrderTourismTax();
+ $row->id_order = (int) $idOrder;
+ $row->id_order_detail = (int) $idOrderDetail;
+ $row->id_htl_booking = (int) $idHtlBooking;
+ $row->id_service_product_order_detail = (int) $idServiceProductOrderDetail;
+ $row->id_hotel = (int) $idHotel;
+ $row->id_tax = (int) $idTax;
+ $row->id_currency = (int) $idCurrency;
+ $row->num_nights = (int) $numNights;
+ $row->num_adults = (int) $numAdults;
+ $row->total_amount = (float) $totalAmount;
+ $row->is_exempted = (int) $isExempted;
+ $row->is_refunded = 0;
+
+ return $row->add();
+ }
+
+ /**
+ * Delta-adjust order_detail's own tourism tax totals after (re)computing a booking/service line.
+ *
+ * @param int $idOrderDetail
+ * @param float $prevTotal Previous exemption-aware total_amount sum for this scope
+ * @param float $newTotal New exemption-aware total_amount sum for this scope
+ * @return bool
+ */
+ protected static function adjustOrderDetailTotals($idOrderDetail, $prevTotal, $newTotal)
+ {
+ $idOrderDetail = (int) $idOrderDetail;
+ $prevTotal = (float) $prevTotal;
+ $newTotal = (float) $newTotal;
+
+ return Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_detail`
+ SET `tourism_tax_amount` = GREATEST(0, `tourism_tax_amount`
+ - ' . $prevTotal . '
+ + ' . $newTotal . '),
+ `total_price_tax_incl` = `total_price_tax_incl`
+ - ' . $prevTotal . '
+ + ' . $newTotal . ',
+ `unit_price_tax_incl` = `unit_price_tax_incl`
+ + COALESCE((' . $newTotal . ' - ' . $prevTotal . ') / NULLIF(`product_quantity`, 0), 0)
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ }
+
+ /**
+ * Breakdown for invoice/PDF — grouped by id_tax, excluding exempted or refunded rows, cached per order+lang.
+ *
+ * @param int $idOrder
+ * @param int|null $idLang defaults to the current context language
+ * @return array
+ */
+ public static function getBreakdownForInvoice($idOrder, $idLang = null)
+ {
+ $idOrder = (int) $idOrder;
+ $idLang = $idLang !== null ? (int) $idLang : (int) Context::getContext()->language->id;
+ $cacheId = 'OrderTourismTax::getBreakdownForInvoice-' . $idOrder . '-' . $idLang;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $result = Db::getInstance()->executeS(
+ 'SELECT ott.`id_tax`, tl.`name` AS tax_name,
+ SUM(ott.`num_nights`) AS num_nights,
+ SUM(ott.`num_adults`) AS num_adults,
+ SUM(ott.`total_amount`) AS total_amount
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax` ott
+ LEFT JOIN `' . _DB_PREFIX_ . 'tax_lang` tl
+ ON tl.`id_tax` = ott.`id_tax` AND tl.`id_lang` = ' . $idLang . '
+ WHERE ott.`id_order` = ' . $idOrder . '
+ AND ott.`is_exempted` = 0
+ AND ott.`is_refunded` = 0
+ GROUP BY ott.`id_tax`, tl.`name`'
+ );
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * The single source of truth for an order's tourism tax total — applied, non-exempted, non-refunded.
+ *
+ * @param int $idOrder
+ * @return float
+ */
+ public static function getOrderTourismTaxTotal($idOrder)
+ {
+ return (float) array_sum(array_column(self::getBreakdownForInvoice((int) $idOrder), 'total_amount'));
+ }
+
+ /**
+ * Tourism tax status for a single booking — STATUS_NONE (no rows), STATUS_APPLIED (any non-exempted row), STATUS_EXEMPTED (all rows exempted).
+ *
+ * @param int $idHtlBooking
+ * @return string
+ */
+ protected static function getStatusForBooking($idHtlBooking)
+ {
+ return self::getStatusForScope('id_htl_booking', (int) $idHtlBooking);
+ }
+
+ /**
+ * Same state machine as getStatusForBooking(), scoped to a service product order-detail line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return string
+ */
+ protected static function getStatusForServiceLine($idServiceProductOrderDetail)
+ {
+ return self::getStatusForScope('id_service_product_order_detail', (int) $idServiceProductOrderDetail);
+ }
+
+ /**
+ * Shared core for getStatusForBooking()/getStatusForServiceLine() — same STATUS_NONE/APPLIED/EXEMPTED state machine, only the scope column differs.
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return string
+ */
+ protected static function getStatusForScope($scopeColumn, $scopeValue)
+ {
+ $rows = Db::getInstance()->executeS(
+ 'SELECT `is_exempted` FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue . '
+ 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;
+ }
+
+ /**
+ * Mark all non-refunded rows for a booking as exempted.
+ *
+ * @param int $idHtlBooking
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $note Reason for exemption, entered by the employee
+ * @return bool
+ */
+ protected static function exemptForBooking($idHtlBooking, $idEmployee = 0, $note = null)
+ {
+ return self::exemptForScope('id_htl_booking', (int) $idHtlBooking, $idEmployee, $note);
+ }
+
+ /**
+ * Same as exemptForBooking(), scoped to a service product order-detail line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @param int $idEmployee
+ * @param string|null $note
+ * @return bool
+ */
+ protected static function exemptForServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $note = null)
+ {
+ return self::exemptForScope('id_service_product_order_detail', (int) $idServiceProductOrderDetail, $idEmployee, $note);
+ }
+
+ /**
+ * Mark all non-refunded rows for a scope as exempted, writing one audit row per row exempted (not wrapped in a DB transaction).
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @param int $idEmployee PS employee id (0 = resolved from Context)
+ * @param string|null $note Reason for exemption, entered by the employee
+ * @return bool
+ */
+ protected static function exemptForScope($scopeColumn, $scopeValue, $idEmployee = 0, $note = null)
+ {
+ $scopeValue = (int) $scopeValue;
+ $db = Db::getInstance();
+ $rows = $db->executeS(
+ 'SELECT `id_order_tourism_tax`, `id_order`, `id_order_detail`, `id_htl_booking`,
+ `id_service_product_order_detail`, `total_amount`
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . $scopeValue . '
+ AND `is_exempted` = 0
+ AND `is_refunded` = 0'
+ );
+
+ if (empty($rows)) {
+ return true;
+ }
+
+ $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_tourism_tax`
+ SET `is_exempted` = 1, `date_upd` = NOW()
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . $scopeValue . '
+ AND `is_refunded` = 0'
+ );
+
+ $idOrderDetail = (int) $rows[0]['id_order_detail'];
+ $idOrder = (int) $rows[0]['id_order'];
+ $totalExemptedAmount = 0.0;
+ $idEmployeeResolved = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id;
+
+ foreach ($rows as $row) {
+ $totalExemptedAmount += (float) $row['total_amount'];
+ $exemption = new OrderTourismTaxExemption();
+ $exemption->id_order_tourism_tax = (int) $row['id_order_tourism_tax'];
+ $exemption->id_htl_booking = (int) $row['id_htl_booking'];
+ $exemption->id_service_product_order_detail = (int) $row['id_service_product_order_detail'];
+ $exemption->id_order = $idOrder;
+ $exemption->id_employee = $idEmployeeResolved;
+ $exemption->note = $note;
+ if (!$exemption->add()) {
+ PrestaShopLogger::addLog(
+ 'OrderTourismTax::exemptForScope - Failed to save exemption audit row',
+ 3,
+ null,
+ 'HotelBookingDetail',
+ $scopeValue,
+ true
+ );
+ return false;
+ }
+ }
+
+ self::adjustOrderDetailTotals($idOrderDetail, $totalExemptedAmount, 0.0);
+ self::adjustOrderAndInvoiceTotals($idOrder, -$totalExemptedAmount);
+
+ return true;
+ }
+
+ /**
+ * Remove exemption for all rows of a booking (restore applied state).
+ *
+ * @param int $idHtlBooking
+ * @return bool
+ */
+ protected static function unExemptForBooking($idHtlBooking)
+ {
+ return self::unExemptForScope('id_htl_booking', (int) $idHtlBooking);
+ }
+
+ /**
+ * Same as unExemptForBooking(), scoped to a service product order-detail line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return bool
+ */
+ protected static function unExemptForServiceLine($idServiceProductOrderDetail)
+ {
+ return self::unExemptForScope('id_service_product_order_detail', (int) $idServiceProductOrderDetail);
+ }
+
+ /**
+ * Remove exemption for all rows of a scope, recomputing tourism_tax_amount from every non-refunded, non-exempted row sharing the order_detail (not wrapped in a DB transaction).
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function unExemptForScope($scopeColumn, $scopeValue)
+ {
+ $scopeValue = (int) $scopeValue;
+ $db = Db::getInstance();
+ $rows = $db->executeS(
+ 'SELECT `id_order`, `id_order_detail`, `total_amount`
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . $scopeValue . '
+ AND `is_exempted` = 1
+ AND `is_refunded` = 0'
+ );
+
+ if (empty($rows)) {
+ return true;
+ }
+
+ $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_tourism_tax`
+ SET `is_exempted` = 0, `date_upd` = NOW()
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . $scopeValue . '
+ AND `is_refunded` = 0'
+ );
+
+ $idOrderDetail = (int) $rows[0]['id_order_detail'];
+ $idOrder = (int) $rows[0]['id_order'];
+ $restoredAmount = 0.0;
+
+ foreach ($rows as $row) {
+ $restoredAmount += (float) $row['total_amount'];
+ }
+
+ $restored = (float) $db->getValue(
+ 'SELECT SUM(`total_amount`)
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `id_order_detail` = ' . $idOrderDetail . '
+ AND `is_exempted` = 0
+ AND `is_refunded` = 0'
+ );
+
+ $productQuantity = (int) $db->getValue(
+ 'SELECT `product_quantity` FROM `' . _DB_PREFIX_ . 'order_detail`
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+ $unitDelta = $productQuantity > 0 ? ($restoredAmount / $productQuantity) : 0.0;
+
+ $db->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_detail`
+ SET `tourism_tax_amount` = ' . $restored . ',
+ `total_price_tax_incl` = `total_price_tax_incl` + ' . $restoredAmount . ',
+ `unit_price_tax_incl` = `unit_price_tax_incl` + ' . $unitDelta . '
+ WHERE `id_order_detail` = ' . $idOrderDetail
+ );
+
+ self::adjustOrderAndInvoiceTotals($idOrder, $restoredAmount);
+
+ return true;
+ }
+
+ /**
+ * Whether a scope has any refunded tourism tax rows — apply/exempt must not touch a cancelled booking/service line.
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @return bool
+ */
+ protected static function hasRefundedRowsForScope($scopeColumn, $scopeValue)
+ {
+ return (bool) Db::getInstance()->getValue(
+ 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue . ' AND `is_refunded` = 1'
+ );
+ }
+
+ /**
+ * Resolve every parameter saveTourismTax() needs for an already-placed room booking, from the booking's own hotel (not the order's single id_address_tax column).
+ *
+ * @param int $idHtlBooking
+ * @return array|false Ordered args for self::saveTourismTax(), or false when the
+ * booking/order/TRG/address can't be resolved
+ */
+ public static function buildRoomTaxParams($idHtlBooking)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $booking = new HotelBookingDetail($idHtlBooking);
+ if (!Validate::isLoadedObject($booking)) {
+ return false;
+ }
+
+ $order = new Order((int) $booking->id_order);
+ if (!Validate::isLoadedObject($order)) {
+ return false;
+ }
+
+ $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $booking->id_product);
+ if (!$idTaxRulesGroup) {
+ return false;
+ }
+
+ $idHotel = (int) $booking->id_hotel;
+ $hotelContext = TourismTax::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax));
+ $address = $hotelContext['address'];
+ if (!Validate::isLoadedObject($address)) {
+ return false;
+ }
+ $collectionType = $hotelContext['collectionType'];
+
+ $numNights = (int) HotelHelper::getNumberOfDays($booking->date_from, $booking->date_to);
+ if ($numNights <= 0) {
+ return false;
+ }
+
+ $unitPriceTaxExcl = Tools::ps_round((float) $booking->total_price_tax_excl / $numNights, 6);
+
+ $childrenAges = array();
+ if ($booking->child_ages) {
+ $decoded = json_decode($booking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+
+ return array(
+ 'idTaxRulesGroup' => $idTaxRulesGroup,
+ 'address' => $address,
+ 'unitPriceTaxExcl' => $unitPriceTaxExcl,
+ 'checkInDate' => $booking->date_from,
+ 'numNights' => $numNights,
+ 'numAdults' => (int) $booking->adults,
+ 'childrenAges' => $childrenAges,
+ 'idCurrency' => (int) $order->id_currency,
+ 'collectionType' => $collectionType,
+ 'idLang' => (int) Context::getContext()->language->id,
+ 'quantity' => 1,
+ 'idOrder' => (int) $booking->id_order,
+ 'idOrderDetail' => (int) $booking->id_order_detail,
+ 'idHtlBooking' => $idHtlBooking,
+ 'idServiceProductOrderDetail' => 0,
+ 'idHotel' => $idHotel,
+ );
+ }
+
+ /**
+ * Same as buildRoomTaxParams(), for an already-placed service product line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return array|false
+ */
+ public static function buildServiceLineTaxParams($idServiceProductOrderDetail)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail);
+ if (!Validate::isLoadedObject($serviceLine)) {
+ return false;
+ }
+
+ $order = new Order((int) $serviceLine->id_order);
+ if (!Validate::isLoadedObject($order)) {
+ return false;
+ }
+
+ $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $serviceLine->id_product);
+ if (!$idTaxRulesGroup) {
+ return false;
+ }
+
+ $idHotel = (int) $serviceLine->id_hotel;
+ $hotelContext = TourismTax::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax));
+ $address = $hotelContext['address'];
+ if (!Validate::isLoadedObject($address)) {
+ return false;
+ }
+ $collectionType = $hotelContext['collectionType'];
+
+ $idHtlBookingDetail = (int) $serviceLine->id_htl_booking_detail;
+ $roomBooking = $idHtlBookingDetail ? new HotelBookingDetail($idHtlBookingDetail) : null;
+ if ($roomBooking && Validate::isLoadedObject($roomBooking)) {
+ $checkInDate = $roomBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($roomBooking->date_from, $roomBooking->date_to));
+ $numAdults = (int) $roomBooking->adults;
+ $childrenAges = array();
+ if ($roomBooking->child_ages) {
+ $decoded = json_decode($roomBooking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+ } else {
+ $checkInDate = date('Y-m-d');
+ $numNights = 1;
+ $numAdults = 1;
+ $childrenAges = array();
+ }
+
+ return array(
+ 'idTaxRulesGroup' => $idTaxRulesGroup,
+ 'address' => $address,
+ 'unitPriceTaxExcl' => (float) $serviceLine->unit_price_tax_excl,
+ 'checkInDate' => $checkInDate,
+ 'numNights' => $numNights,
+ 'numAdults' => $numAdults,
+ 'childrenAges' => $childrenAges,
+ 'idCurrency' => (int) $order->id_currency,
+ 'collectionType' => $collectionType,
+ 'idLang' => (int) Context::getContext()->language->id,
+ 'quantity' => (int) $serviceLine->quantity,
+ 'idOrder' => (int) $serviceLine->id_order,
+ 'idOrderDetail' => (int) $serviceLine->id_order_detail,
+ 'idHtlBooking' => 0,
+ 'idServiceProductOrderDetail' => $idServiceProductOrderDetail,
+ 'idHotel' => $idHotel,
+ );
+ }
+
+ /**
+ * Apply or restore tourism tax for a booking's room based on its current status (exempted → restore, none → compute fresh, applied → no-op).
+ *
+ * @param int $idHtlBooking
+ * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NO_RULE | APPLY_ERROR_REFUNDED
+ */
+ protected static function applyForRoom($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 (self::hasRefundedRowsForScope('id_htl_booking', $idHtlBooking)) {
+ return self::APPLY_ERROR_REFUNDED;
+ }
+
+ $params = self::buildRoomTaxParams($idHtlBooking);
+ if (!$params) {
+ return self::APPLY_ERROR_NO_RULE;
+ }
+ self::saveTourismTax(
+ $params['idTaxRulesGroup'],
+ $params['address'],
+ $params['unitPriceTaxExcl'],
+ $params['checkInDate'],
+ $params['numNights'],
+ $params['numAdults'],
+ $params['childrenAges'],
+ $params['idCurrency'],
+ $params['collectionType'],
+ $params['idLang'],
+ $params['quantity'],
+ $params['idOrder'],
+ $params['idOrderDetail'],
+ $params['idHtlBooking'],
+ $params['idServiceProductOrderDetail'],
+ $params['idHotel'],
+ true
+ );
+ self::adjustOrderTotalsForNewBooking($idHtlBooking);
+ }
+
+ return self::APPLY_OK;
+ }
+
+ /**
+ * Same state machine as applyForRoom(), for a single service line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NO_RULE | APPLY_ERROR_REFUNDED
+ */
+ protected static function applyForServiceLine($idServiceProductOrderDetail)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $status = self::getStatusForServiceLine($idServiceProductOrderDetail);
+
+ if ($status === self::STATUS_EXEMPTED) {
+ return self::unExemptForServiceLine($idServiceProductOrderDetail) ? self::APPLY_OK : self::APPLY_ERROR_RESTORE;
+ }
+
+ if ($status === self::STATUS_NONE) {
+ if (self::hasRefundedRowsForScope('id_service_product_order_detail', $idServiceProductOrderDetail)) {
+ return self::APPLY_ERROR_REFUNDED;
+ }
+
+ $params = self::buildServiceLineTaxParams($idServiceProductOrderDetail);
+ if (!$params) {
+ return self::APPLY_ERROR_NO_RULE;
+ }
+ self::saveTourismTax(
+ $params['idTaxRulesGroup'],
+ $params['address'],
+ $params['unitPriceTaxExcl'],
+ $params['checkInDate'],
+ $params['numNights'],
+ $params['numAdults'],
+ $params['childrenAges'],
+ $params['idCurrency'],
+ $params['collectionType'],
+ $params['idLang'],
+ $params['quantity'],
+ $params['idOrder'],
+ $params['idOrderDetail'],
+ $params['idHtlBooking'],
+ $params['idServiceProductOrderDetail'],
+ $params['idHotel'],
+ true
+ );
+ self::adjustOrderTotalsForNewServiceLine($idServiceProductOrderDetail);
+ }
+
+ return self::APPLY_OK;
+ }
+
+ /**
+ * Apply or restore tourism tax for a booking's room and each of its active service lines independently.
+ *
+ * @param int $idHtlBooking
+ * @return array List of ['scope' => 'room', 'booking_id' => int, 'result' => int]
+ * or ['scope' => 'service', 'id_service_product_order_detail' => int, 'result' => int]
+ */
+ public static function applyForBooking($idHtlBooking)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $results = array(
+ array('scope' => 'room', 'booking_id' => $idHtlBooking, 'result' => self::applyForRoom($idHtlBooking)),
+ );
+
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking) as $idServiceLine) {
+ $results[] = array(
+ 'scope' => 'service',
+ 'id_service_product_order_detail' => $idServiceLine,
+ 'result' => self::applyForServiceLine($idServiceLine),
+ );
+ }
+
+ return $results;
+ }
+
+ /**
+ * Apply tourism tax for every room and service line of every booking in an order; returns only the failing target entries.
+ *
+ * @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) {
+ foreach (self::applyForBooking($booking['id']) as $targetResult) {
+ if ($targetResult['result'] !== self::APPLY_OK) {
+ $failures[] = $targetResult;
+ }
+ }
+ }
+ }
+ return $failures;
+ }
+
+ /**
+ * Exempt tourism tax for a booking's room only if it is currently STATUS_APPLIED.
+ *
+ * @param int $idHtlBooking
+ * @param int $idEmployee
+ * @param string|null $note Reason for exemption, entered by the employee
+ * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE | EXEMPT_ERROR_REFUNDED
+ */
+ protected static function exemptIfAppliedForRoom($idHtlBooking, $idEmployee = 0, $note = null)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $status = self::getStatusForBooking($idHtlBooking);
+ if ($status === self::STATUS_NONE) {
+ return self::hasRefundedRowsForScope('id_htl_booking', $idHtlBooking) ? self::EXEMPT_ERROR_REFUNDED : self::EXEMPT_ERROR_NO_RULE;
+ }
+ if ($status === self::STATUS_APPLIED) {
+ return self::exemptForBooking($idHtlBooking, $idEmployee, $note)
+ ? self::EXEMPT_OK
+ : self::EXEMPT_ERROR_SAVE;
+ }
+ return self::EXEMPT_OK;
+ }
+
+ /**
+ * Same state machine as exemptIfAppliedForRoom(), for a single service line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @param int $idEmployee
+ * @param string|null $note
+ * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE | EXEMPT_ERROR_REFUNDED
+ */
+ protected static function exemptIfAppliedForServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $note = null)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $status = self::getStatusForServiceLine($idServiceProductOrderDetail);
+ if ($status === self::STATUS_NONE) {
+ return self::hasRefundedRowsForScope('id_service_product_order_detail', $idServiceProductOrderDetail) ? self::EXEMPT_ERROR_REFUNDED : self::EXEMPT_ERROR_NO_RULE;
+ }
+ if ($status === self::STATUS_APPLIED) {
+ return self::exemptForServiceLine($idServiceProductOrderDetail, $idEmployee, $note)
+ ? self::EXEMPT_OK
+ : self::EXEMPT_ERROR_SAVE;
+ }
+ return self::EXEMPT_OK;
+ }
+
+ /**
+ * Exempt tourism tax for a booking's room and each of its active service lines — same one-call-multiple-targets shape as applyForBooking().
+ *
+ * @param int $idHtlBooking
+ * @param int $idEmployee
+ * @param string|null $note Reason for exemption, applied to every target touched
+ * @return array See applyForBooking() for the entry shape.
+ */
+ public static function exemptIfApplied($idHtlBooking, $idEmployee = 0, $note = null)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $results = array(
+ array('scope' => 'room', 'booking_id' => $idHtlBooking, 'result' => self::exemptIfAppliedForRoom($idHtlBooking, $idEmployee, $note)),
+ );
+
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking) as $idServiceLine) {
+ $results[] = array(
+ 'scope' => 'service',
+ 'id_service_product_order_detail' => $idServiceLine,
+ 'result' => self::exemptIfAppliedForServiceLine($idServiceLine, $idEmployee, $note),
+ );
+ }
+
+ return $results;
+ }
+
+ /**
+ * Exempt tourism tax for every room and service line of every booking in an order; returns only the failing target entries.
+ *
+ * @param int $idOrder
+ * @param int $idEmployee
+ * @param string|null $note Reason for exemption, applied to every booking in the order
+ * @return array
+ */
+ public static function exemptForOrder($idOrder, $idEmployee = 0, $note = null)
+ {
+ $failures = array();
+ $objBookingDetail = new HotelBookingDetail();
+ $bookings = $objBookingDetail->getBookingDataByOrderId((int) $idOrder);
+ if ($bookings) {
+ foreach ($bookings as $booking) {
+ foreach (self::exemptIfApplied($booking['id'], $idEmployee, $note) as $targetResult) {
+ if ($targetResult['result'] !== self::EXEMPT_OK) {
+ $failures[] = $targetResult;
+ }
+ }
+ }
+ }
+ return $failures;
+ }
+
+ /**
+ * After fresh-applying tourism tax to a STATUS_NONE booking, add the applied total to the order's own totals.
+ *
+ * @param int $idHtlBooking
+ * @return void
+ */
+ protected static function adjustOrderTotalsForNewBooking($idHtlBooking)
+ {
+ $idHtlBooking = (int) $idHtlBooking;
+ $booking = new HotelBookingDetail($idHtlBooking);
+ if (!Validate::isLoadedObject($booking)) {
+ return;
+ }
+ self::adjustOrderTotalsForNewRows('id_htl_booking', $idHtlBooking, (int) $booking->id_order);
+ }
+
+ /**
+ * Same as adjustOrderTotalsForNewBooking(), for a single service line.
+ *
+ * @param int $idServiceProductOrderDetail
+ * @return void
+ */
+ protected static function adjustOrderTotalsForNewServiceLine($idServiceProductOrderDetail)
+ {
+ $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail;
+ $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail);
+ if (!Validate::isLoadedObject($serviceLine)) {
+ return;
+ }
+ self::adjustOrderTotalsForNewRows('id_service_product_order_detail', $idServiceProductOrderDetail, (int) $serviceLine->id_order);
+ }
+
+ /**
+ * Shared core for adjustOrderTotalsForNewBooking()/adjustOrderTotalsForNewServiceLine().
+ *
+ * @param string $scopeColumn 'id_htl_booking' or 'id_service_product_order_detail'
+ * @param int $scopeValue
+ * @param int $idOrder
+ * @return void
+ */
+ protected static function adjustOrderTotalsForNewRows($scopeColumn, $scopeValue, $idOrder)
+ {
+ $appliedTotal = (float) Db::getInstance()->getValue(
+ 'SELECT SUM(`total_amount`)
+ FROM `' . _DB_PREFIX_ . 'order_tourism_tax`
+ WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue . '
+ AND `is_exempted` = 0
+ AND `is_refunded` = 0'
+ );
+ self::adjustOrderAndInvoiceTotals($idOrder, $appliedTotal);
+ }
+
+ /**
+ * Apply an applied-tourism-tax delta to both the order's own totals and every invoice already generated for it.
+ *
+ * @param int $idOrder
+ * @param float $delta Signed — positive when restoring/adding tax, negative when exempting it
+ */
+ protected static function adjustOrderAndInvoiceTotals($idOrder, $delta)
+ {
+ $delta = (float) $delta;
+ if (!$delta) {
+ return;
+ }
+
+ $idOrder = (int) $idOrder;
+ Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'orders`
+ SET `total_paid_tax_incl` = `total_paid_tax_incl` + ' . $delta . ',
+ `total_paid_tax_excl` = `total_paid_tax_excl` + ' . $delta . ',
+ `total_paid` = `total_paid` + ' . $delta . '
+ WHERE `id_order` = ' . $idOrder
+ );
+ Db::getInstance()->execute(
+ 'UPDATE `' . _DB_PREFIX_ . 'order_invoice`
+ SET `total_paid_tax_incl` = `total_paid_tax_incl` + ' . $delta . ',
+ `total_paid_tax_excl` = `total_paid_tax_excl` + ' . $delta . '
+ WHERE `id_order` = ' . $idOrder
+ );
+ }
+}
diff --git a/modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php b/classes/tourismtax/OrderTourismTaxExemption.php
similarity index 79%
rename from modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php
rename to classes/tourismtax/OrderTourismTaxExemption.php
index 1db4a235b..7588889e8 100644
--- a/modules/hotelreservationsystem/classes/HotelBookingTaxExemption.php
+++ b/classes/tourismtax/OrderTourismTaxExemption.php
@@ -21,37 +21,33 @@
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
-if (!defined('_PS_VERSION_')) {
- exit;
-}
-
-/**
- * Append-only audit trail for tourism tax exemption actions.
- * Rows are never updated after creation.
- */
-class HotelBookingTaxExemption extends ObjectModel
+class OrderTourismTaxExemptionCore extends ObjectModel
{
public $id_exemption;
public $id_order_tourism_tax;
public $id_htl_booking;
+ public $id_service_product_order_detail;
public $id_order;
public $id_employee;
+ public $note;
public $date_add;
public static $definition = array(
- 'table' => 'htl_booking_tax_exemption',
+ 'table' => 'order_tourism_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_service_product_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'),
'id_order' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
'id_employee' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true),
+ 'note' => array('type' => self::TYPE_STRING, 'validate' => 'isMessage', 'allow_null' => true),
'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'),
),
);
/**
- * Return all exemption records for a given htl_order_tourism_tax row.
+ * Return all exemption records for a given order_tourism_tax row.
*
* @param int $idOrderTourismTax
* @return array
@@ -59,10 +55,9 @@ class HotelBookingTaxExemption extends ObjectModel
public static function getByOrderTourismTax($idOrderTourismTax)
{
return Db::getInstance()->executeS(
- 'SELECT e.* FROM `' . _DB_PREFIX_ . 'htl_booking_tax_exemption` e
+ 'SELECT e.* FROM `' . _DB_PREFIX_ . 'order_tourism_tax_exemption` e
WHERE e.`id_order_tourism_tax` = ' . (int) $idOrderTourismTax . '
ORDER BY e.`date_add` ASC'
);
}
-
}
diff --git a/classes/tourismtax/TourismTax.php b/classes/tourismtax/TourismTax.php
new file mode 100644
index 000000000..0e83e6cf1
--- /dev/null
+++ b/classes/tourismtax/TourismTax.php
@@ -0,0 +1,346 @@
+ 'tourism_tax',
+ 'primary' => 'id_tax',
+ 'fields' => array(
+ 'tax_calc_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', 'allow_null' => true),
+ 'valid_to' => array('type' => self::TYPE_DATE, 'validate' => 'isDate', 'allow_null' => true),
+ 'special_days' => array('type' => self::TYPE_STRING),
+ ),
+ );
+
+ /**
+ * Override add() because id_tax is a FK-as-PK — the base ObjectModel::add() never writes the primary key column, which would default to 0 and create phantom rows.
+ *
+ * @param bool $auto_date ignored — table has no date_add/date_upd
+ * @param bool $null_values ignored
+ * @return bool
+ */
+ public function add($auto_date = true, $null_values = false)
+ {
+ $idTax = (int) $this->id;
+ if (!$idTax) {
+ return false;
+ }
+ $result = Db::getInstance()->insert(
+ 'tourism_tax',
+ array(
+ 'id_tax' => $idTax,
+ 'tax_calc_type' => (int) $this->tax_calc_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,
+ 'special_days' => $this->special_days ? pSQL($this->special_days) : null,
+ )
+ );
+ if ($result) {
+ $this->id_tax = $idTax;
+ }
+ return $result;
+ }
+
+ /**
+ * Load a tourism tax subtype row by id_tax, cached per request.
+ *
+ * @param int $idTax
+ * @return TourismTax|false
+ */
+ public static function getByTaxId($idTax)
+ {
+ $idTax = (int) $idTax;
+ $cacheId = 'TourismTax::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $obj = new TourismTax($idTax);
+ $result = Validate::isLoadedObject($obj) ? $obj : false;
+ Cache::store($cacheId, $result);
+
+ return $result;
+ }
+
+ /**
+ * Shared core for cleanFromTourismTaxRulesGroups()/cleanFromVatTaxRulesGroups().
+ *
+ * @param int $idTax
+ * @param bool $isTourismTaxRulesGroup true = clean from tourism tax rules groups, false = clean from VAT tax rules groups
+ * @return int
+ */
+ protected static function cleanTaxRulesGroupsByTourismFlag($idTax, $isTourismTaxRulesGroup)
+ {
+ $flag = $isTourismTaxRulesGroup ? 1 : 0;
+ $db = Db::getInstance();
+ $db->execute(
+ 'DELETE tr FROM `' . _DB_PREFIX_ . 'tax_rule` tr
+ INNER JOIN `' . _DB_PREFIX_ . 'tax_rules_group` trg
+ ON trg.`id_tax_rules_group` = tr.`id_tax_rules_group`
+ WHERE tr.`id_tax` = ' . (int) $idTax . '
+ AND trg.`is_tourism_tax_rule` = ' . $flag
+ );
+ return $db->Affected_Rows();
+ }
+
+ /**
+ * Remove this tax from all tourism tax rules groups.
+ *
+ * @param int $idTax
+ * @return int
+ */
+ public static function cleanFromTourismTaxRulesGroups($idTax)
+ {
+ return self::cleanTaxRulesGroupsByTourismFlag($idTax, true);
+ }
+
+ /**
+ * Remove this tax from all standard (non-tourism) tax rules groups — symmetric counterpart of cleanFromTourismTaxRulesGroups().
+ *
+ * @param int $idTax
+ * @return int
+ */
+ public static function cleanFromVatTaxRulesGroups($idTax)
+ {
+ return self::cleanTaxRulesGroupsByTourismFlag($idTax, false);
+ }
+
+ /**
+ * Format a tax rate/amount for display, VAT as a percentage and tourism tax per its own calc type.
+ *
+ * @param mixed $value ps_tax.rate (used for non-tourism rows)
+ * @param array $row List row — must include is_tourism_tax, tourism_tax_calc_type, tourism_tax_value
+ * @param Currency $currency
+ * @return string
+ */
+ public static function getFormattedRateForDisplay($value, array $row, Currency $currency)
+ {
+ if (!(int) $row['is_tourism_tax']) {
+ return Tools::safeOutput($value) . '%';
+ }
+ $displayValue = Tools::safeOutput((float) $row['tourism_tax_value']);
+ if ((int) $row['tourism_tax_calc_type'] === 1) {
+ return $displayValue . '%';
+ }
+ $currencySign = Tools::safeOutput(trim($currency->prefix . $currency->suffix));
+ return $currencySign . ' ' . $displayValue;
+ }
+
+ /**
+ * Delete the subtype row and all related tiers/child-ranges.
+ *
+ * @return bool
+ */
+ public function delete()
+ {
+ if (parent::delete()) {
+ $idTax = (int) $this->id;
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tourism_tax_tier`
+ WHERE `id_tax` = ' . $idTax
+ );
+ Db::getInstance()->execute(
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tourism_tax_child_range`
+ WHERE `id_tax` = ' . $idTax
+ );
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Whether tourism tax should be visually folded into the displayed room/service price.
+ *
+ * @param float $tourismTaxOnline
+ * @return bool
+ */
+ public static function isGrossedUp($tourismTaxOnline)
+ {
+ return (float) $tourismTaxOnline > 0 && (bool) Configuration::get('QLO_TOURISM_TAX_GROSSED_UP');
+ }
+
+ /**
+ * Resolve the hotel's own jurisdiction address and current tourism-tax collection type — shared by every cart, order, and preview caller that needs this context.
+ *
+ * @param int $idHotel 0 = no hotel context (fully standalone product)
+ * @param Address $fallbackAddress Used only when the hotel has no address on file
+ * @return array ['address' => Address, 'collectionType' => int]
+ */
+ public static function resolveHotelAddressAndCollectionType($idHotel, Address $fallbackAddress)
+ {
+ $idHotel = (int) $idHotel;
+
+ $address = null;
+ if ($idHotel) {
+ $hotelAddress = HotelBranchInformation::getAddress($idHotel);
+ if ($hotelAddress && !empty($hotelAddress['id_address'])) {
+ $address = new Address((int) $hotelAddress['id_address']);
+ }
+ }
+ if (!$address || !Validate::isLoadedObject($address)) {
+ $address = $fallbackAddress;
+ }
+
+ $collectionType = self::COLLECTION_TYPE_ONLINE;
+ if ($idHotel && Validate::isLoadedObject($hotelBranch = new HotelBranchInformation($idHotel))) {
+ $collectionType = (int) $hotelBranch->tourism_tax_collection_type;
+ }
+
+ return array(
+ 'address' => $address,
+ 'collectionType' => $collectionType,
+ );
+ }
+
+ /**
+ * Resolve the jurisdiction address, collection type, and occupancy (check-in/nights/adults/children) a service product line needs before computing its tourism tax.
+ *
+ * @param int $idHotel 0 = no hotel context (fully standalone product)
+ * @param int $idHtlBookingDetail 0 = not attached to an order-side room booking
+ * @param Address $fallbackAddress Used only when the hotel has no address on file
+ * @param int $idHtlCartBooking 0 = not attached to a cart-side room booking (pre-order preview only —
+ * ignored when $idHtlBookingDetail is given)
+ * @return array ['address' => Address, 'collectionType' => int, 'checkInDate' => string,
+ * 'numNights' => int, 'numAdults' => int, 'childrenAges' => int[]]
+ */
+ public static function resolveServiceLineTaxContext($idHotel, $idHtlBookingDetail, Address $fallbackAddress, $idHtlCartBooking = 0)
+ {
+ $idHotel = (int) $idHotel;
+ $idHtlBookingDetail = (int) $idHtlBookingDetail;
+ $idHtlCartBooking = (int) $idHtlCartBooking;
+
+ $hotelContext = self::resolveHotelAddressAndCollectionType($idHotel, $fallbackAddress);
+ $address = $hotelContext['address'];
+ $collectionType = $hotelContext['collectionType'];
+
+ $roomBooking = $idHtlBookingDetail ? new HotelBookingDetail($idHtlBookingDetail) : null;
+ $cartBooking = (!$roomBooking && $idHtlCartBooking) ? new HotelCartBookingData($idHtlCartBooking) : null;
+ if ($roomBooking && Validate::isLoadedObject($roomBooking)) {
+ $checkInDate = $roomBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($roomBooking->date_from, $roomBooking->date_to));
+ $numAdults = (int) $roomBooking->adults;
+ $childrenAges = array();
+ if ($roomBooking->child_ages) {
+ $decoded = json_decode($roomBooking->child_ages, true);
+ if (is_array($decoded)) {
+ $childrenAges = array_map('intval', $decoded);
+ }
+ }
+ } elseif ($cartBooking && Validate::isLoadedObject($cartBooking)) {
+ $checkInDate = $cartBooking->date_from;
+ $numNights = max(1, (int) HotelHelper::getNumberOfDays($cartBooking->date_from, $cartBooking->date_to));
+ $numAdults = (int) $cartBooking->adults;
+ $childrenAges = !empty($cartBooking->child_ages) ? (array) json_decode($cartBooking->child_ages, true) : array();
+ } else {
+ $checkInDate = date('Y-m-d');
+ $numNights = 1;
+ $numAdults = 1;
+ $childrenAges = array();
+ }
+
+ return array(
+ 'address' => $address,
+ 'collectionType' => $collectionType,
+ 'checkInDate' => $checkInDate,
+ 'numNights' => $numNights,
+ 'numAdults' => $numAdults,
+ 'childrenAges' => $childrenAges,
+ );
+ }
+
+ /**
+ * Preview parameters for a tax rules group — a deliberate 1-adult/1-night approximation for js/admin/price.js's live price preview.
+ *
+ * @param int $idTaxRulesGroup
+ * @param Address $address
+ * @param int $idLang
+ * @return array List of ['tax_calc_type','is_tiered','tax_value','tiers']
+ */
+ public static function getPreviewParams($idTaxRulesGroup, Address $address, $idLang)
+ {
+ $idTaxRulesGroup = (int) $idTaxRulesGroup;
+ if (!$idTaxRulesGroup || Tax::excludeTaxeOption()) {
+ return array();
+ }
+
+ $taxCalculator = TaxManagerFactory::getManager($address, $idTaxRulesGroup)->getTaxCalculator();
+ $preview = array();
+
+ foreach ($taxCalculator->taxes as $tax) {
+ $tourismTax = self::getByTaxId((int) $tax->id);
+ if (!$tourismTax) {
+ continue;
+ }
+
+ $tiers = array();
+ if ((bool) $tourismTax->is_tiered) {
+ foreach (TourismTaxTier::getByTaxId((int) $tax->id) as $tier) {
+ $tiers[] = array(
+ 'min_amount' => (float) $tier['min_amount'],
+ 'max_amount' => (float) $tier['max_amount'],
+ 'tax_value' => (float) $tier['tax_value'],
+ );
+ }
+ }
+ $preview[] = array(
+ 'tax_calc_type' => (int) $tourismTax->tax_calc_type,
+ 'is_tiered' => (bool) $tourismTax->is_tiered,
+ 'tax_value' => (float) $tourismTax->tax_value,
+ 'tiers' => $tiers,
+ );
+ }
+
+ return $preview;
+ }
+}
diff --git a/modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php b/classes/tourismtax/TourismTaxChildRange.php
similarity index 51%
rename from modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php
rename to classes/tourismtax/TourismTaxChildRange.php
index 6149e6409..d0cedf49d 100644
--- a/modules/hotelreservationsystem/classes/HotelTourismTaxChildRange.php
+++ b/classes/tourismtax/TourismTaxChildRange.php
@@ -21,54 +21,51 @@
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
-if (!defined('_PS_VERSION_')) {
- exit;
-}
-
-/**
- * Age-band definitions for tourism taxes with has_child_rate = 1.
- * Child age is measured at check-in date.
- * All ranges for a tax share the parent's child_tax_value.
- */
-class HotelTourismTaxChildRange extends ObjectModel
+class TourismTaxChildRangeCore extends ObjectModel
{
public $id_child_range;
public $id_tax;
public $min_age;
public $max_age;
public $tax_value;
- public $position;
public static $definition = array(
- 'table' => 'htl_tourism_tax_child_range',
+ 'table' => '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.
+ * All child age ranges for a tax in entry order (id_child_range order), cached per request.
*
* @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'
+ $idTax = (int) $idTax;
+ $cacheId = 'TourismTaxChildRange::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $ranges = Db::getInstance()->executeS(
+ 'SELECT * FROM `' . _DB_PREFIX_ . 'tourism_tax_child_range`
+ WHERE `id_tax` = ' . $idTax . '
+ ORDER BY `id_child_range` ASC'
);
+ Cache::store($cacheId, $ranges);
+
+ return $ranges;
}
/**
- * Replace all child range rows for $idTax with the submitted arrays.
- * Skips entries where tax_value is empty.
+ * Replace all child range rows for $idTax with the submitted arrays, skipping entries where tax_value is empty.
*
* @param int $idTax
* @param array $mins
@@ -79,90 +76,60 @@ class HotelTourismTaxChildRange extends ObjectModel
public static function saveAll($idTax, array $mins, array $maxs, array $values)
{
Db::getInstance()->execute(
- 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_child_range`
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tourism_tax_child_range`
WHERE `id_tax` = ' . (int) $idTax
);
foreach ($values as $i => $value) {
if ($value === '' || $value === false) {
continue;
}
- $range = new HotelTourismTaxChildRange();
+ $range = new TourismTaxChildRange();
$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]
+ * Sum per-band tax contributions for the given child ages — each child matched to the first qualifying band, unmatched children pay the adult rate if given, else 0.
*
* @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 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
+ * @return array ['total' => float, 'count' => int]
*/
- public static function computeChildContribution($idTax, array $ages, $taxType, $unitPrice, $adultBaseValue = null)
+ public static function getChildContribution($idTax, array $ages, $taxType, $unitPrice, $adultBaseValue = null)
{
+ $taxType = (int) $taxType;
+ $unitPrice = (float) $unitPrice;
$ranges = self::getByTaxId((int) $idTax);
$total = 0.0;
$count = 0;
foreach ($ages as $age) {
+ $age = (int) $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']);
+ $maxAge = (int) $range['max_age'];
+ $inLower = ($age >= (int) $range['min_age']);
+ $inUpper = ($maxAge == 0 || $age <= $maxAge);
if ($inLower && $inUpper) {
$bandValue = (float) $range['tax_value'];
- $total += ((int) $taxType === 0)
- ? $bandValue
- : ((float) $unitPrice * ($bandValue / 100));
+ $total += ($taxType === 0) ? $bandValue : ($unitPrice * ($bandValue / 100));
$count++;
$matched = true;
break;
}
}
if (!$matched && $adultBaseValue !== null) {
- $total += ((int) $taxType === 0)
- ? (float) $adultBaseValue
- : ((float) $unitPrice * ((float) $adultBaseValue / 100));
+ $adultBaseValue = (float) $adultBaseValue;
+ $total += ($taxType === 0) ? $adultBaseValue : ($unitPrice * ($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/classes/tourismtax/TourismTaxTier.php
similarity index 51%
rename from modules/hotelreservationsystem/classes/HotelTourismTaxTier.php
rename to classes/tourismtax/TourismTaxTier.php
index 693d42180..21d0beb48 100644
--- a/modules/hotelreservationsystem/classes/HotelTourismTaxTier.php
+++ b/classes/tourismtax/TourismTaxTier.php
@@ -21,64 +21,65 @@
* @license https://opensource.org/license/osl-3-0-php Open Software License version 3.0
*/
-if (!defined('_PS_VERSION_')) {
- exit;
-}
-
-/**
- * Tiered price brackets for tourism taxes with is_tiered = 1.
- * Boundary rule: min_amount <= unit_price_tax_excl < max_amount (max_amount=0 = open-ended).
- */
-class HotelTourismTaxTier extends ObjectModel
+class TourismTaxTierCore extends ObjectModel
{
public $id_tier;
public $id_tax;
public $min_amount;
public $max_amount;
public $tax_value;
- public $position;
public static $definition = array(
- 'table' => 'htl_tourism_tax_tier',
+ 'table' => '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.
+ * All tiers for a tax in entry order (id_tier order), cached per request.
*
* @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'
+ $idTax = (int) $idTax;
+ $cacheId = 'TourismTaxTier::getByTaxId-' . $idTax;
+ if (Cache::isStored($cacheId)) {
+ return Cache::retrieve($cacheId);
+ }
+
+ $tiers = Db::getInstance()->executeS(
+ 'SELECT * FROM `' . _DB_PREFIX_ . 'tourism_tax_tier`
+ WHERE `id_tax` = ' . $idTax . '
+ ORDER BY `id_tier` ASC'
);
+ Cache::store($cacheId, $tiers);
+
+ return $tiers;
}
/**
- * Find the matching tier for a given unit price.
- * Returns the tier row or false when no tier matches.
+ * Find the matching tier for a given unit price, or false when no tier matches.
*
* @param int $idTax
* @param float $unitPrice
* @return array|false
*/
- public static function matchTier($idTax, $unitPrice)
+ public static function getMatchingTier($idTax, $unitPrice)
{
- $tiers = self::getByTaxId((int) $idTax);
+ $idTax = (int) $idTax;
+ $unitPrice = (float) $unitPrice;
+ $tiers = self::getByTaxId($idTax);
foreach ($tiers as $tier) {
- $inLower = ((float) $unitPrice >= (float) $tier['min_amount']);
- $inUpper = ((float) $tier['max_amount'] == 0 || (float) $unitPrice < (float) $tier['max_amount']);
+ $maxAmount = (float) $tier['max_amount'];
+ $inLower = ($unitPrice >= (float) $tier['min_amount']);
+ $inUpper = ($maxAmount == 0 || $unitPrice < $maxAmount);
if ($inLower && $inUpper) {
return $tier;
}
@@ -87,8 +88,7 @@ class HotelTourismTaxTier extends ObjectModel
}
/**
- * Replace all tier rows for $idTax with the submitted arrays.
- * Skips entries where tax_value is empty.
+ * Replace all tier rows for $idTax with the submitted arrays, skipping entries where tax_value is empty.
*
* @param int $idTax
* @param array $mins
@@ -96,50 +96,23 @@ class HotelTourismTaxTier extends ObjectModel
* @param array $values
* @return void
*/
- public static function saveAll($idTax, array $mins, array $maxs, array $values)
+ public static function saveAll($idTax, $mins, $maxs, $values)
{
+ $idTax = (int) $idTax;
Db::getInstance()->execute(
- 'DELETE FROM `' . _DB_PREFIX_ . 'htl_tourism_tax_tier`
- WHERE `id_tax` = ' . (int) $idTax
+ 'DELETE FROM `' . _DB_PREFIX_ . 'tourism_tax_tier`
+ WHERE `id_tax` = ' . $idTax
);
foreach ($values as $i => $value) {
if ($value === '' || $value === false) {
continue;
}
- $tier = new HotelTourismTaxTier();
- $tier->id_tax = (int) $idTax;
+ $tier = new TourismTaxTier();
+ $tier->id_tax = $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/classes/tourismtax/index.php b/classes/tourismtax/index.php
new file mode 100644
index 000000000..3127284d7
--- /dev/null
+++ b/classes/tourismtax/index.php
@@ -0,0 +1,35 @@
+
+* @copyright 2007-2017 PrestaShop SA
+* @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
+* International Registered Trademark & Property of PrestaShop SA
+*/
+
+header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
+header('Last-Modified: '.gmdate('D, d M Y H:i:s').' GMT');
+
+header('Cache-Control: no-store, no-cache, must-revalidate');
+header('Cache-Control: post-check=0, pre-check=0', false);
+header('Pragma: no-cache');
+
+header('Location: ../../');
+exit;
diff --git a/controllers/admin/AdminNormalProductsController.php b/controllers/admin/AdminNormalProductsController.php
index 83f33dc6c..d17fd6e92 100644
--- a/controllers/admin/AdminNormalProductsController.php
+++ b/controllers/admin/AdminNormalProductsController.php
@@ -587,6 +587,12 @@ class AdminNormalProductsControllerCore extends AdminController
if ($this->_list) {
$context = $this->context->cloneContext();
$context->shop = clone($context->shop);
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $tourismTaxContext = $useTourismTax ? TourismTax::resolveServiceLineTaxContext(
+ (int) Configuration::get('WK_PRIMARY_HOTEL'),
+ 0,
+ new Address(0)
+ ) : null;
/* update product final price */
for ($i = 0; $i < $nb; $i++) {
if (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP) {
@@ -598,6 +604,33 @@ class AdminNormalProductsControllerCore extends AdminController
$this->_list[$i]['price_tmp'] = Product::getPriceStatic($this->_list[$i]['id_product'], true, null,
(int)Configuration::get('PS_PRICE_DISPLAY_PRECISION'), null, false, true, 1, true, null, null, null, $nothing, true, true,
$context);
+
+ if ($useTourismTax) {
+ $unitPriceTaxExcl = Product::getPriceStatic($this->_list[$i]['id_product'], false, null,
+ 6, null, false, true, 1, true, null, null, null, $nothing, true, true,
+ $context);
+ $serviceTourismTax = array('tourism_tax_online' => 0.0);
+ $idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $this->_list[$i]['id_product']);
+ if ($idTourismTaxRulesGroup) {
+ $taxCalculator = TaxManagerFactory::getManager(
+ $tourismTaxContext['address'],
+ $idTourismTaxRulesGroup
+ )->getTaxCalculator();
+ $serviceTourismTax = $taxCalculator->getTourismTaxRows(
+ (float) $unitPriceTaxExcl,
+ $tourismTaxContext['checkInDate'],
+ $tourismTaxContext['numNights'],
+ $tourismTaxContext['numAdults'],
+ $tourismTaxContext['childrenAges'],
+ (int) $this->context->currency->id,
+ $tourismTaxContext['collectionType'],
+ (int) $id_lang,
+ 1,
+ true
+ );
+ }
+ $this->_list[$i]['price_tmp'] += (float) $serviceTourismTax['tourism_tax_online'];
+ }
}
}
@@ -3373,9 +3406,24 @@ class AdminNormalProductsControllerCore extends AdminController
// $data->assign('productFeaturePrices', $productFeaturePrices);
// }
- if (Configuration::get('QLO_USE_TOURISM_TAX')) {
- $data->assign('tourismTaxRulesGroups', TaxRulesGroup::getTaxRulesGroupsForOptions(true, true));
+ if (Configuration::get('QLO_USE_TOURISM_TAX') && !Tax::excludeTaxeOption()) {
+ $tourismTaxRulesGroups = TaxRulesGroup::getTaxRulesGroupsForOptions(true, true);
+ $data->assign('tourismTaxRulesGroups', $tourismTaxRulesGroups);
$data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0);
+
+ $tourismTaxRates = array();
+ foreach ($tourismTaxRulesGroups as $tourismTaxRulesGroup) {
+ $idTourismTaxRulesGroup = (int) $tourismTaxRulesGroup['id_tax_rules_group'];
+ if (!$idTourismTaxRulesGroup) {
+ continue;
+ }
+ $tourismTaxRates[$idTourismTaxRulesGroup] = TourismTax::getPreviewParams(
+ $idTourismTaxRulesGroup,
+ $address,
+ (int) $this->context->language->id
+ );
+ }
+ $data->assign('tourismTaxRatesByGroup', $tourismTaxRates);
}
$this->tpl_form_vars['custom_form'] = $data->fetch();
diff --git a/controllers/admin/AdminOrdersController.php b/controllers/admin/AdminOrdersController.php
index f76246c52..ac547a9d6 100644
--- a/controllers/admin/AdminOrdersController.php
+++ b/controllers/admin/AdminOrdersController.php
@@ -888,6 +888,7 @@ class AdminOrdersControllerCore extends AdminController
'invoices_collection' => $objOrder->getInvoicesCollection(),
'currency' => new Currency($objOrder->id_currency),
'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'),
+ 'use_tourism_tax' => (bool) Configuration::get('QLO_USE_TOURISM_TAX'),
)
);
$modal = array(
@@ -3398,10 +3399,9 @@ class AdminOrdersControllerCore extends AdminController
$totalDemandsPriceTI = 0;
$totalRefundedRooms = 0;
$orderConvenienceFeeServices = array();
- $bookingTourismTaxMap = array();
- if (Configuration::get('QLO_USE_TOURISM_TAX')) {
- $bookingTourismTaxMap = HotelOrderTourismTax::getOnlineTotalsByBooking($order->id);
- }
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $bookingTourismTaxStatusMap = OrderTourismTax::getStatusAndTotalByBooking($order->id);
+ $serviceTourismTaxMap = OrderTourismTax::getAppliedServiceTotalsByBooking($order->id);
if ($order_detail_data = $objBookingDetail->getOrderFormatedBookinInfoByIdOrder($order->id)) {
$objBookingDemand = new HotelBookingDemands();
$objHotelRoomType = new HotelRoomType();
@@ -3409,9 +3409,10 @@ class AdminOrdersControllerCore extends AdminController
$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']];
+ $bookingTourismTaxAmount = isset($bookingTourismTaxStatusMap[$value['id']]) ? (float) $bookingTourismTaxStatusMap[$value['id']]['total'] : 0.0;
+ if ($bookingTourismTaxAmount > 0) {
+ $order_detail_data[$key]['tourism_tax_amount'] = $bookingTourismTaxAmount;
+ $order_detail_data[$key]['total_room_price_ti'] += $bookingTourismTaxAmount;
}
$order_detail_data[$key]['extra_demands'] = $objBookingDemand->getRoomTypeBookingExtraDemands(
@@ -3590,10 +3591,12 @@ class AdminOrdersControllerCore extends AdminController
$order_detail_data[$key]['amt_with_qty_tax_incl'] = $value['total_price_tax_incl'];
$order_detail_data[$key]['room_type_info'] = $objHotelRoomType->getRoomTypeInfoByIdProduct($value['id_product']);
$order_detail_data[$key]['total_room_tax'] = $order_detail_data[$key]['total_room_price_ti'] - $order_detail_data[$key]['total_room_price_te'];
- 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']);
- }
+ $bookingTourismTaxStatus = isset($bookingTourismTaxStatusMap[$value['id']]) ? $bookingTourismTaxStatusMap[$value['id']] : null;
+ $order_detail_data[$key]['tourism_tax_status'] = $bookingTourismTaxStatus ? $bookingTourismTaxStatus['status'] : OrderTourismTax::STATUS_NONE;
+ $roomTourismTaxTotal = $bookingTourismTaxStatus ? $bookingTourismTaxStatus['total'] : 0.0;
+ $serviceTourismTaxTotal = isset($serviceTourismTaxMap[$value['id']]) ? $serviceTourismTaxMap[$value['id']] : 0.0;
+ $order_detail_data[$key]['tourism_tax_total'] = $roomTourismTaxTotal + $serviceTourismTaxTotal;
+ $order_detail_data[$key]['total_room_service_tax_only'] = $order_detail_data[$key]['total_room_tax'] - $roomTourismTaxTotal - $serviceTourismTaxTotal;
if (isset($value['refund_info'])
&& $value['refund_info']['refunded']
@@ -3610,10 +3613,10 @@ class AdminOrdersControllerCore extends AdminController
$orderTourismTaxHasApplied = false;
$orderTourismTaxHasUnapplied = false;
- if (Configuration::get('QLO_USE_TOURISM_TAX') && $order_detail_data) {
+ if ($useTourismTax && $order_detail_data) {
foreach ($order_detail_data as $bookingRow) {
if (isset($bookingRow['tourism_tax_status'])) {
- if ($bookingRow['tourism_tax_status'] === HotelOrderTourismTax::STATUS_APPLIED) {
+ if ($bookingRow['tourism_tax_status'] === OrderTourismTax::STATUS_APPLIED) {
$orderTourismTaxHasApplied = true;
} else {
$orderTourismTaxHasUnapplied = true;
@@ -3750,10 +3753,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'),
+ 'use_tourism_tax' => $useTourismTax,
+ 'tourism_tax_online_room' => array_sum(array_column($bookingTourismTaxStatusMap, 'total')),
+ 'tourism_tax_online_service' => array_sum($serviceTourismTaxMap),
'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,
+ 'tourism_tax_status_applied' => OrderTourismTax::STATUS_APPLIED,
+ 'tourism_tax_status_exempted' => OrderTourismTax::STATUS_EXEMPTED,
'order_tourism_tax_has_applied' => $orderTourismTaxHasApplied,
'order_tourism_tax_has_unapplied' => $orderTourismTaxHasUnapplied,
'max_child_age' => Configuration::get('WK_GLOBAL_CHILD_MAX_AGE'),
@@ -5471,6 +5476,55 @@ class AdminOrdersControllerCore extends AdminController
}
if ($objBookingDetail->save()) {
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $idTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct($idProduct);
+ if ($idTourismTaxRulesGroup) {
+ $tourismTaxAddress = null;
+ if ($objCartBookingData->id_hotel) {
+ $hotelAddress = HotelBranchInformation::getAddress($objCartBookingData->id_hotel);
+ if ($hotelAddress && !empty($hotelAddress['id_address'])) {
+ $tourismTaxAddress = new Address((int) $hotelAddress['id_address']);
+ }
+ }
+ if (!$tourismTaxAddress || !Validate::isLoadedObject($tourismTaxAddress)) {
+ $tourismTaxAddress = new Address((int) Cart::getIdAddressForTaxCalculation($idProduct));
+ }
+
+ $tourismTaxNumNights = max(1, (int) HotelHelper::getNumberOfDays($objBookingDetail->date_from, $objBookingDetail->date_to));
+ $tourismTaxChildAges = array();
+ if ($objBookingDetail->child_ages) {
+ $decodedChildAges = json_decode($objBookingDetail->child_ages, true);
+ if (is_array($decodedChildAges)) {
+ $tourismTaxChildAges = array_map('intval', $decodedChildAges);
+ }
+ }
+ $tourismTaxCollectionType = Validate::isLoadedObject($objHotelBranch)
+ ? (int) $objHotelBranch->tourism_tax_collection_type
+ : TourismTax::COLLECTION_TYPE_ONLINE;
+
+ OrderTourismTax::saveTourismTax(
+ $idTourismTaxRulesGroup,
+ $tourismTaxAddress,
+ (float) $objBookingDetail->total_price_tax_excl / $tourismTaxNumNights,
+ $objBookingDetail->date_from,
+ $tourismTaxNumNights,
+ (int) $objBookingDetail->adults,
+ $tourismTaxChildAges,
+ (int) $this->context->cart->id_currency,
+ $tourismTaxCollectionType,
+ $idLang,
+ 1,
+ $order->id,
+ (int) $objBookingDetail->id_order_detail,
+ $objBookingDetail->id,
+ 0,
+ (int) $objCartBookingData->id_hotel
+ );
+ // Whether the checkbox exempts this room+its services is decided once,
+ // after the auto-attached service loop below runs — see there for why.
+ }
+ }
+
$objRoomTypeServiceProduct = new RoomTypeServiceProduct();
$objRoomTypeServiceProductPrice = new RoomTypeServiceProductPrice();
$objServiceProductCartDetail = new ServiceProductCartDetail();
@@ -5546,9 +5600,42 @@ class AdminOrdersControllerCore extends AdminController
$objServiceProductOrderDetail->total_price_tax_incl = $totalPriceTaxIncl;
$objServiceProductOrderDetail->name = $service['name'];
$objServiceProductOrderDetail->quantity = 1;
- $objServiceProductOrderDetail->save();
+ if ($objServiceProductOrderDetail->save()) {
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $idServiceTourismTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $service['id_product']);
+ if ($idServiceTourismTaxRulesGroup) {
+ $serviceTourismTaxContext = TourismTax::resolveServiceLineTaxContext(
+ (int) $objCartBookingData->id_hotel,
+ $objBookingDetail->id,
+ new Address((int) Cart::getIdAddressForTaxCalculation((int) $service['id_product']))
+ );
+ OrderTourismTax::saveTourismTax(
+ $idServiceTourismTaxRulesGroup,
+ $serviceTourismTaxContext['address'],
+ $unitPriceTaxExcl,
+ $serviceTourismTaxContext['checkInDate'],
+ $serviceTourismTaxContext['numNights'],
+ $serviceTourismTaxContext['numAdults'],
+ $serviceTourismTaxContext['childrenAges'],
+ (int) $this->context->cart->id_currency,
+ $serviceTourismTaxContext['collectionType'],
+ $idLang,
+ 1,
+ $order->id,
+ (int) $objServiceProductOrderDetail->id_order_detail,
+ 0,
+ (int) $objServiceProductOrderDetail->id,
+ (int) $objCartBookingData->id_hotel
+ );
+ }
+ }
+ }
}
}
+
+ if (Configuration::get('QLO_USE_TOURISM_TAX') && empty($product_informations['apply_tourism_tax'])) {
+ OrderTourismTax::exemptIfApplied($objBookingDetail->id);
+ }
}
}
}
@@ -6338,6 +6425,54 @@ class AdminOrdersControllerCore extends AdminController
}
}
}
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ $roomTourismTaxParams = OrderTourismTax::buildRoomTaxParams((int) $obj_booking_detail->id);
+ if ($roomTourismTaxParams) {
+ OrderTourismTax::saveTourismTax(
+ $roomTourismTaxParams['idTaxRulesGroup'],
+ $roomTourismTaxParams['address'],
+ $roomTourismTaxParams['unitPriceTaxExcl'],
+ $roomTourismTaxParams['checkInDate'],
+ $roomTourismTaxParams['numNights'],
+ $roomTourismTaxParams['numAdults'],
+ $roomTourismTaxParams['childrenAges'],
+ $roomTourismTaxParams['idCurrency'],
+ $roomTourismTaxParams['collectionType'],
+ $roomTourismTaxParams['idLang'],
+ $roomTourismTaxParams['quantity'],
+ $roomTourismTaxParams['idOrder'],
+ $roomTourismTaxParams['idOrderDetail'],
+ $roomTourismTaxParams['idHtlBooking'],
+ $roomTourismTaxParams['idServiceProductOrderDetail'],
+ $roomTourismTaxParams['idHotel']
+ );
+ }
+
+ foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail((int) $obj_booking_detail->id) as $idActiveServiceLine) {
+ $serviceTourismTaxParams = OrderTourismTax::buildServiceLineTaxParams($idActiveServiceLine);
+ if (!$serviceTourismTaxParams) {
+ continue;
+ }
+ OrderTourismTax::saveTourismTax(
+ $serviceTourismTaxParams['idTaxRulesGroup'],
+ $serviceTourismTaxParams['address'],
+ $serviceTourismTaxParams['unitPriceTaxExcl'],
+ $serviceTourismTaxParams['checkInDate'],
+ $serviceTourismTaxParams['numNights'],
+ $serviceTourismTaxParams['numAdults'],
+ $serviceTourismTaxParams['childrenAges'],
+ $serviceTourismTaxParams['idCurrency'],
+ $serviceTourismTaxParams['collectionType'],
+ $serviceTourismTaxParams['idLang'],
+ $serviceTourismTaxParams['quantity'],
+ $serviceTourismTaxParams['idOrder'],
+ $serviceTourismTaxParams['idOrderDetail'],
+ $serviceTourismTaxParams['idHtlBooking'],
+ $serviceTourismTaxParams['idServiceProductOrderDetail'],
+ $serviceTourismTaxParams['idHotel']
+ );
+ }
+ }
}
$order->total_paid = Tools::ps_round($order->getOrderTotal(), _PS_PRICE_COMPUTE_PRECISION_);
@@ -6785,6 +6920,13 @@ class AdminOrdersControllerCore extends AdminController
// delete the demands od this booking
$objBookingDemand->deleteBookingDemands($idHotelBooking);
+ if (Configuration::get('QLO_USE_TOURISM_TAX')) {
+ OrderTourismTax::hardDeleteForBooking(
+ $idHotelBooking,
+ ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHotelBooking)
+ );
+ }
+
$objServiceProductOrderDetail->deleteSeviceProducts(0, $idHotelBooking);
/*By webkul to delete cart and order entries from cart and order tables of hotelreservationsystem when delete booking form the order line in order detaoil page*/
@@ -8983,7 +9125,27 @@ class AdminOrdersControllerCore extends AdminController
}
/**
- * Apply tourism tax to a single booking (id_htl_booking) or all bookings of an order (id_order).
+ * Map tourism tax apply/exempt target results to deduplicated, scope-labeled error messages.
+ *
+ * @param array $targetResults [{'scope', 'result'}, ...] — see OrderTourismTax::applyForBooking()
+ * @param array $messagesByResult [resultCode => '%s: ...' sprintf template]
+ * @return string[]
+ */
+ protected function tourismTaxErrorsFromResults(array $targetResults, array $messagesByResult)
+ {
+ $messages = array();
+ foreach ($targetResults as $result) {
+ if (!isset($messagesByResult[$result['result']])) {
+ continue;
+ }
+ $label = $result['scope'] === 'room' ? $this->l('Room') : $this->l('Service product');
+ $messages[$result['scope'] . '_' . $result['result']] = sprintf($messagesByResult[$result['result']], $label);
+ }
+ return array_values($messages);
+ }
+
+ /**
+ * Apply tourism tax to a booking's room+services (id_htl_booking) or a whole order (id_order) — see OrderTourismTax::applyForBooking().
*/
public function ajaxProcessApplyTourismTax()
{
@@ -8992,30 +9154,17 @@ class AdminOrdersControllerCore extends AdminController
$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.');
- }
+ $errors = $this->tourismTaxErrorsFromResults(OrderTourismTax::applyForBooking($idHtlBooking), array(
+ OrderTourismTax::APPLY_ERROR_RESTORE => $this->l('%s: could not restore tourism tax.'),
+ OrderTourismTax::APPLY_ERROR_NO_RULE => $this->l('%s: no tourism tax rule is assigned.'),
+ OrderTourismTax::APPLY_ERROR_REFUNDED => $this->l('%s: tourism tax cannot be applied, this was refunded.'),
+ ));
} 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.');
- }
+ $errors = $this->tourismTaxErrorsFromResults(OrderTourismTax::applyForOrder($idOrder), array(
+ OrderTourismTax::APPLY_ERROR_RESTORE => $this->l('%s: could not restore tourism tax for one or more bookings.'),
+ OrderTourismTax::APPLY_ERROR_NO_RULE => $this->l('%s: one or more do not have a tourism tax rule assigned.'),
+ OrderTourismTax::APPLY_ERROR_REFUNDED => $this->l('%s: tourism tax cannot be applied for one or more that were refunded.'),
+ ));
} else {
$errors[] = $this->l('Invalid request parameters.');
}
@@ -9027,40 +9176,29 @@ class AdminOrdersControllerCore extends AdminController
}
/**
- * Exempt tourism tax for a single booking (id_htl_booking) or all bookings of an order (id_order).
+ * Exempt tourism tax for a booking's room+services (id_htl_booking) or a whole order (id_order) — see OrderTourismTax::exemptIfApplied().
*/
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();
+ $note = trim((string) Tools::getValue('note', ''));
+ $note = ($note !== '') ? $note : null;
+ $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.');
- }
+ $errors = $this->tourismTaxErrorsFromResults(OrderTourismTax::exemptIfApplied($idHtlBooking, $idEmployee, $note), array(
+ OrderTourismTax::EXEMPT_ERROR_NO_RULE => $this->l('%s: no tourism tax rule is assigned.'),
+ OrderTourismTax::EXEMPT_ERROR_SAVE => $this->l('%s: could not exempt tourism tax.'),
+ OrderTourismTax::EXEMPT_ERROR_REFUNDED => $this->l('%s: tourism tax cannot be exempted, this was refunded.'),
+ ));
} 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.');
- }
+ $errors = $this->tourismTaxErrorsFromResults(OrderTourismTax::exemptForOrder($idOrder, $idEmployee, $note), array(
+ OrderTourismTax::EXEMPT_ERROR_NO_RULE => $this->l('%s: one or more do not have a tourism tax rule assigned.'),
+ OrderTourismTax::EXEMPT_ERROR_SAVE => $this->l('%s: could not exempt tourism tax for one or more.'),
+ OrderTourismTax::EXEMPT_ERROR_REFUNDED => $this->l('%s: tourism tax cannot be exempted for one or more that were refunded.'),
+ ));
} else {
$errors[] = $this->l('Invalid request parameters.');
}
diff --git a/controllers/admin/AdminProductsController.php b/controllers/admin/AdminProductsController.php
index 25fcc213e..2a92dabd3 100644
--- a/controllers/admin/AdminProductsController.php
+++ b/controllers/admin/AdminProductsController.php
@@ -243,7 +243,7 @@ class AdminProductsControllerCore extends AdminController
$this->_select .= ' a.`show_at_front`, (SELECT COUNT(hri.`id`) FROM `'._DB_PREFIX_.'htl_room_information` hri WHERE hri.`id_product` = a.`id_product`) as num_rooms, ';
$this->_select .= 'hrt.`adults`, hrt.`children`, hrt.`max_guests`, hb.`id` as id_hotel, aa.`city`, hbl.`hotel_name`, ';
$this->_select .= 'shop.`name` AS `shopname`, a.`id_shop_default`, ';
- $this->_select .= $alias_image.'.`id_image` AS `id_image`, cl.`name` AS `name_category`, '.$alias.'.`price`, 0 AS `price_final`, a.`is_virtual`, pd.`nb_downloadable`, sav.`quantity` AS `sav_quantity`, '.$alias.'.`active`, IF(sav.`quantity`<=0, 1, 0) AS `badge_danger`';
+ $this->_select .= $alias_image.'.`id_image` AS `id_image`, cl.`name` AS `name_category`, '.$alias.'.`price`, 0 AS `price_final`, a.`is_virtual`, pd.`nb_downloadable`, sav.`quantity` AS `sav_quantity`, '.$alias.'.`active`, IF(sav.`quantity`<=0, 1, 0) AS `badge_danger`, a.`id_tourism_tax_rules_group`';
$this->_select .= ', IFNULL(hap.`active`, 0) AS advance_payment';
$this->_select .= ', IF(IFNULL(hap.`active`, 0), 1, 0) badge_success, IF(IFNULL(hap.`active`, 0), 0, 1) badge_danger ';
@@ -663,7 +663,7 @@ class AdminProductsControllerCore extends AdminController
{
$prefix = $this->getCookieFilterPrefix();
$orderByPriceFinal = (empty($orderBy) ? ($this->context->cookie->__get($prefix.$this->table.'Orderby') ? $this->context->cookie->__get($prefix.$this->table.'Orderby') : 'id_'.$this->table) : $orderBy);
- $orderWayPriceFinal = (empty($orderWay) ? ($this->context->cookie->__get($prefix.$this->table.'Orderway') ? $this->context->cookie->__get($prefix.$this->table.'Orderby') : 'ASC') : $orderWay);
+ $orderWayPriceFinal = (empty($orderWay) ? ($this->context->cookie->__get($prefix.$this->table.'Orderway') ? $this->context->cookie->__get($prefix.$this->table.'Orderway') : 'ASC') : $orderWay);
if ($orderByPriceFinal == 'price_final') {
$orderBy = 'id_'.$this->table;
$orderWay = 'ASC';
@@ -710,24 +710,49 @@ class AdminProductsControllerCore extends AdminController
unset($this->fields_list['id_feature']);
unset($this->fields_list['id_service_product']);
}
- parent::getList($id_lang, $orderBy, $orderWay, $start, $limit, $this->context->shop->id);
+ parent::getList($id_lang, $orderBy, $orderWay, $start, $limit, $id_lang_shop ?: $this->context->shop->id);
/* update product quantity with attributes ...*/
$nb = count($this->_list);
if ($this->_list) {
$context = $this->context->cloneContext();
$context->shop = clone($context->shop);
+ $isAllShopsContext = (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP);
+ $priceDisplayPrecision = (int) Configuration::get('PS_PRICE_DISPLAY_PRECISION');
+ $useTourismTax = (bool) Configuration::get('QLO_USE_TOURISM_TAX');
+ $todayDate = date('Y-m-d');
+ $tomorrowDate = date('Y-m-d', strtotime('+1 day', strtotime($todayDate)));
+
/* update product final price */
for ($i = 0; $i < $nb; $i++) {
- if (Context::getContext()->shop->getContext() != Shop::CONTEXT_SHOP) {
+ if ($isAllShopsContext) {
$context->shop = new Shop((int)$this->_list[$i]['id_shop_default']);
}
// convert price with the currency from context
$this->_list[$i]['price'] = Tools::convertPrice($this->_list[$i]['price'], $this->context->currency, true, $this->context);
$this->_list[$i]['price_tmp'] = Product::getPriceStatic($this->_list[$i]['id_product'], true, null,
- (int)Configuration::get('PS_PRICE_DISPLAY_PRECISION'), null, false, true, 1, true, null, null, null, $nothing, true, true,
+ $priceDisplayPrecision, null, false, true, 1, true, null, null, null, $nothing, true, true,
$context);
+
+ $idTourismTaxRulesGroup = (int) $this->_list[$i]['id_tourism_tax_rules_group'];
+ $idHotel = (int) $this->_list[$i]['id_hotel'];
+ if ($useTourismTax && $idTourismTaxRulesGroup && $idHotel) {
+ $roomTotalPrice = HotelRoomTypeFeaturePricing::getRoomTypeTotalPrice(
+ $this->_list[$i]['id_product'],
+ $todayDate,
+ $tomorrowDate,
+ array(array('adults' => 1, 'children' => 0, 'child_ages' => array())),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ true
+ );
+ $this->_list[$i]['price_tmp'] += (float) $roomTotalPrice['tourism_tax_online'];
+ }
}
}
@@ -3269,7 +3294,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRuleGroup = new TaxRulesGroup(
$serviceProductPriceInfo['id_tax_rules_group'],
$this->context->language->id
- ))) {
+ )) && !$objTaxRuleGroup->is_tourism_tax_rule) {
$associationInfo['tax_rules_group_name'] = $objTaxRuleGroup->name;
}
$associationInfo['id_room_type_service_product_price'] = $serviceProductPriceInfo['id_room_type_service_product_price'];
@@ -3279,7 +3304,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRuleGroup = new TaxRulesGroup(
$objProduct->id_tax_rules_group,
$this->context->language->id
- ))) {
+ )) && !$objTaxRuleGroup->is_tourism_tax_rule) {
$associationInfo['default_tax_rules_group_name'] = $objTaxRuleGroup->name;
}
@@ -3293,7 +3318,7 @@ class AdminProductsControllerCore extends AdminController
if (Validate::isLoadedObject($objTaxRulesGroup = new TaxRulesGroup(
$serviceProduct['id_tax_rules_group'],
$this->context->language->id
- ))) {
+ )) && !$objTaxRulesGroup->is_tourism_tax_rule) {
$serviceProduct['tax_rules_group_name'] = $objTaxRulesGroup->name;
}
@@ -4157,9 +4182,24 @@ class AdminProductsControllerCore extends AdminController
$data->assign('productFeaturePrices', $productFeaturePrices);
}
- if (Configuration::get('QLO_USE_TOURISM_TAX')) {
- $data->assign('tourismTaxRulesGroups', TaxRulesGroup::getTaxRulesGroupsForOptions(true, true));
+ if (Configuration::get('QLO_USE_TOURISM_TAX') && !Tax::excludeTaxeOption()) {
+ $tourismTaxRulesGroups = TaxRulesGroup::getTaxRulesGroupsForOptions(true, true);
+ $data->assign('tourismTaxRulesGroups', $tourismTaxRulesGroups);
$data->assign('id_tourism_tax_rules_group', isset($product->id_tourism_tax_rules_group) ? (int) $product->id_tourism_tax_rules_group : 0);
+
+ $tourismTaxRates = array();
+ foreach ($tourismTaxRulesGroups as $tourismTaxRulesGroup) {
+ $idTourismTaxRulesGroup = (int) $tourismTaxRulesGroup['id_tax_rules_group'];
+ if (!$idTourismTaxRulesGroup) {
+ continue;
+ }
+ $tourismTaxRates[$idTourismTaxRulesGroup] = TourismTax::getPreviewParams(
+ $idTourismTaxRulesGroup,
+ $address,
+ (int) $this->context->language->id
+ );
+ }
+ $data->assign('tourismTaxRatesByGroup', $tourismTaxRates);
}
$this->tpl_form_vars['custom_form'] = $data->fetch();
diff --git a/controllers/admin/AdminTaxRulesGroupController.php b/controllers/admin/AdminTaxRulesGroupController.php
index 3a64f4d3f..d462545b1 100644
--- a/controllers/admin/AdminTaxRulesGroupController.php
+++ b/controllers/admin/AdminTaxRulesGroupController.php
@@ -103,10 +103,10 @@ class AdminTaxRulesGroupControllerCore extends AdminController
*/
protected function getTaxOptionsForGroup()
{
- $idTrg = (int) Tools::getValue('id_tax_rules_group');
+ $idTaxRulesGroup = (int) Tools::getValue('id_tax_rules_group');
$isTourism = false;
- if ($idTrg) {
- $trg = new TaxRulesGroup($idTrg);
+ if ($idTaxRulesGroup) {
+ $trg = new TaxRulesGroup($idTaxRulesGroup);
if (Validate::isLoadedObject($trg)) {
$isTourism = (bool) $trg->is_tourism_tax_rule;
}
@@ -117,8 +117,8 @@ class AdminTaxRulesGroupControllerCore extends AdminController
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'
+ . ' IFNULL(tourism_tax.`tax_calc_type`, 0) AS tourism_tax_calc_type,'
+ . ' IFNULL(tourism_tax.`tax_value`, 0) AS tourism_tax_value'
);
} else {
$sql->select('t.`id_tax`, tl.`name`, t.`rate`');
@@ -126,7 +126,7 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$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->leftJoin('tourism_tax', 'tourism_tax', 'tourism_tax.`id_tax` = t.`id_tax`');
}
$sql->where('t.`deleted` != 1 AND t.`active` = 1');
$sql->where('t.`is_tourism_tax` = ' . ($isTourism ? 1 : 0));
@@ -138,9 +138,9 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$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'];
+ $typeLabel = ((int) $row['tourism_tax_calc_type'] === 1)
+ ? (float) $row['tourism_tax_value'] . '%'
+ : $currencySign . ' ' . (float) $row['tourism_tax_value'];
$row['name'] = $row['name'] . ' (' . $typeLabel . ')';
}
unset($row);
@@ -199,8 +199,8 @@ class AdminTaxRulesGroupControllerCore extends AdminController
CONCAT_WS(" - ", a.`zipcode_from`, a.`zipcode_to`) AS zipcode,
t.`rate`,
t.`is_tourism_tax`,
- IFNULL(ht.`tax_type`, 0) AS ht_tax_type,
- IFNULL(ht.`tax_value`, 0) AS ht_tax_value';
+ IFNULL(tourism_tax.`tax_calc_type`, 0) AS tourism_tax_calc_type,
+ IFNULL(tourism_tax.`tax_value`, 0) AS tourism_tax_value';
$this->_join = '
LEFT JOIN `'._DB_PREFIX_.'country_lang` c
@@ -209,8 +209,8 @@ class AdminTaxRulesGroupControllerCore extends AdminController
ON (a.`id_state` = s.`id_state`)
LEFT JOIN `'._DB_PREFIX_.'tax` t
ON (a.`id_tax` = t.`id_tax`)
- LEFT JOIN `'._DB_PREFIX_.'htl_tourism_tax` ht
- ON (ht.`id_tax` = t.`id_tax`)';
+ LEFT JOIN `'._DB_PREFIX_.'tourism_tax` tourism_tax
+ ON (tourism_tax.`id_tax` = t.`id_tax`)';
$this->_where = 'AND `id_tax_rules_group` = '.(int)$id_group;
$this->_use_found_rows = false;
@@ -452,21 +452,12 @@ class AdminTaxRulesGroupControllerCore extends AdminController
* 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
+ * @param array $row Full list row — includes is_tourism_tax, tourism_tax_calc_type, tourism_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;
+ return TourismTax::getFormattedRateForDisplay($value, $row, $this->context->currency);
}
public function postProcess()
@@ -499,6 +490,19 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$this->display = 'edit';
return;
}
+
+ $hasTourismProductConflict = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue(
+ 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'product_shop`
+ WHERE `id_tourism_tax_rules_group` = ' . $idGroup
+ );
+ if ($hasTourismProductConflict) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a VAT group: one or more products still use it as their tourism tax rule. Reassign them first.'
+ );
+ $_POST['is_tourism_tax_rule'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
}
// VAT → Tourism: block while regular VAT tax rules still exist in this group.
@@ -520,6 +524,23 @@ class AdminTaxRulesGroupControllerCore extends AdminController
$this->display = 'edit';
return;
}
+
+ $hasRegularProductConflict = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue(
+ 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'product_shop`
+ WHERE `id_tax_rules_group` = ' . $idGroup
+ );
+ $hasServicePriceConflict = (int) Db::getInstance(_PS_USE_SQL_SLAVE_)->getValue(
+ 'SELECT COUNT(*) FROM `' . _DB_PREFIX_ . 'htl_room_type_service_product_price`
+ WHERE `id_tax_rules_group` = ' . $idGroup
+ );
+ if ($hasRegularProductConflict || $hasServicePriceConflict) {
+ $this->errors[] = $this->l(
+ 'Cannot convert this to a tourism group: one or more products or services still use it as their regular tax rule. Reassign them first.'
+ );
+ $_POST['is_tourism_tax_rule'] = $oldIsTourism;
+ $this->display = 'edit';
+ return;
+ }
}
}
}
diff --git a/controllers/admin/AdminTaxesController.php b/controllers/admin/AdminTaxesController.php
index 0997006c2..d405edcd0 100644
--- a/controllers/admin/AdminTaxesController.php
+++ b/controllers/admin/AdminTaxesController.php
@@ -29,6 +29,10 @@
*/
class AdminTaxesControllerCore extends AdminController
{
+ protected $invalidCoreFields = array();
+ protected $invalidTierFields = array();
+ protected $invalidChildFields = array();
+
public function __construct()
{
$this->bootstrap = true;
@@ -55,7 +59,7 @@ class AdminTaxesControllerCore extends AdminController
'class' => 'fixed-width-sm',
'callback' => 'displayTourismTaxBadge',
'orderby' => false,
- 'search' => false,
+ 'type' => 'bool',
),
'rate' => array(
'title' => $this->l('Rate'),
@@ -126,7 +130,7 @@ 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.'),
+ 'hint' => $this->l('Enable tourism tax calculation for hotel room bookings.'),
'validation' => 'isBool',
'cast' => 'intval',
'type' => 'bool',
@@ -135,7 +139,6 @@ class AdminTaxesControllerCore extends AdminController
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',
@@ -145,8 +148,8 @@ class AdminTaxesControllerCore extends AdminController
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->_join .= ' LEFT JOIN `'._DB_PREFIX_.'tourism_tax` tourism_tax ON tourism_tax.`id_tax` = a.`id_tax`';
+ $extraSelect = 'IFNULL(tourism_tax.`tax_calc_type`, 0) AS `tourism_tax_calc_type`, IFNULL(tourism_tax.`tax_value`, 0) AS `tourism_tax_value`';
$this->_select = ($this->_select ? $this->_select . ', ' : '') . $extraSelect;
}
@@ -222,6 +225,13 @@ class AdminTaxesControllerCore extends AdminController
return $tpl_enable->fetch();
}
+ public function renderList()
+ {
+ $this->_new_list_header_design = true;
+
+ return parent::renderList();
+ }
+
public function renderForm()
{
$idTax = (int) Tools::getValue('id_' . $this->table);
@@ -234,25 +244,31 @@ class AdminTaxesControllerCore extends AdminController
$isAnySubmit = Tools::isSubmit('submitAdd' . $this->table) || Tools::isSubmit('submitAdd' . $this->table . 'AndStay');
if ($idTax && !$isAnySubmit) {
- $subtype = HotelTourismTax::getByTaxId($idTax);
+ $subtype = TourismTax::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 ?: '';
+ if ($subtype->valid_from === '0000-00-00') {
+ $subtype->valid_from = null;
+ }
+ if ($subtype->valid_to === '0000-00-00') {
+ $subtype->valid_to = null;
+ }
+ $_POST['tax_calc_type'] = $subtype->tax_calc_type;
+ $_POST['is_per_night'] = $subtype->is_per_night;
+ $_POST['is_per_person'] = $subtype->is_per_person;
+ $_POST['tax_value'] = $subtype->tax_value;
+ $_POST['is_tiered'] = $subtype->is_tiered;
+ $_POST['has_child_rate'] = $subtype->has_child_rate;
+ $_POST['valid_from'] = $subtype->valid_from ?: '';
+ $_POST['valid_to'] = $subtype->valid_to ?: '';
- $tiers = HotelTourismTaxTier::getByTaxId($idTax);
- $childRanges = HotelTourismTaxChildRange::getByTaxId($idTax);
+ $tiers = TourismTaxTier::getByTaxId($idTax);
+ $childRanges = TourismTaxChildRange::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());
+ $postTierIds = (array) Tools::getValue('tier_id', array());
+ $postTierMins = (array) Tools::getValue('tier_min', array());
+ $postTierMaxs = (array) Tools::getValue('tier_max', array());
+ $postTierValues = (array) Tools::getValue('tier_value', array());
foreach ($postTierIds as $i => $tierId) {
$tiers[] = array(
'id_tier' => (int) $tierId,
@@ -262,10 +278,10 @@ class AdminTaxesControllerCore extends AdminController
);
}
- $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());
+ $postChildIds = (array) Tools::getValue('child_id', array());
+ $postChildMins = (array) Tools::getValue('child_min', array());
+ $postChildMaxs = (array) Tools::getValue('child_max', array());
+ $postChildValues = (array) Tools::getValue('child_value', array());
foreach ($postChildIds as $i => $childId) {
$childRanges[] = array(
'id_child_range' => (int) $childId,
@@ -276,26 +292,25 @@ class AdminTaxesControllerCore extends AdminController
}
}
- $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) {
+ $taxType = (int) Tools::getValue('tax_calc_type', 0);
+ $baseTaxValue = Tools::getValue('tax_value', $subtype ? $subtype->tax_value : 0);
+ $allDayKeys = TourismTax::DAY_KEYS;
+ if ($subtype && $subtype->special_days) {
$defaultSpecialDays = json_decode($subtype->special_days, true) ?: $allDayKeys;
} else {
$defaultSpecialDays = $allDayKeys;
}
- $specialDaysChecked = Tools::getValue('htl_valid_days', $defaultSpecialDays);
+ $specialDaysChecked = Tools::getValue('valid_days', $defaultSpecialDays);
if (!is_array($specialDaysChecked)) {
$specialDaysChecked = $allDayKeys;
}
$initialTypeSign = ($taxType == 1) ? '%' : $currencySign;
- $requiredMarker = '
* ';
$taxValueInputHtml = '
-
';
$priceBracketsHtml = '
-
-
- ' . $this->l('Min price (from)') . $requiredMarker . '
- ' . $this->l('Max price (up to, 0 = no cap)') . $requiredMarker . '
- ' . $this->l('Tax value') . $requiredMarker . '
-
+
+
+
+
+
+ ' . $this->l('Min price (from)') . '
+
+
+
+
+
+
+ ' . $this->l('Max price (up to)') . '
+
+
+
+
+
+
+ ' . $this->l('Tax value') . '
+
+
+
+ --
- ';
+ ';
foreach ($tiers as $tierRowIndex => $tier) {
$priceBracketsHtml .= $this->buildTierRow($tierRowIndex, $tier);
}
$priceBracketsHtml .= '
-