'order_tax_detail', 'primary' => 'id_order_tax_detail', 'fields' => array( 'id_order' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), 'id_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), 'id_htl_booking' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), 'id_service_product_order_detail' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId'), 'id_tax' => array('type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true), 'unit_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'), 'total_amount' => array('type' => self::TYPE_FLOAT, 'validate' => 'isUnsignedFloat'), 'date_add' => array('type' => self::TYPE_DATE, 'validate' => 'isDate'), ), ); /** * Whether a booking's tourism tax (room + every attached service) is currently excluded from totals. * * @param int $idHtlBooking * @return bool */ public static function isBookingExempted($idHtlBooking) { return (bool) Db::getInstance()->getValue( 'SELECT 1 FROM `' . _DB_PREFIX_ . 'order_tax_exemption` WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' AND `id_service_product_order_detail` = 0' ); } /** * Whether a standalone (no room attachment) service line is currently excluded from totals. * * @param int $idServiceProductOrderDetail * @return bool */ public static function isServiceLineExempted($idServiceProductOrderDetail) { return (bool) Db::getInstance()->getValue( 'SELECT 1 FROM `' . _DB_PREFIX_ . 'order_tax_exemption` WHERE `id_htl_booking` = 0 AND `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail ); } /** * Delete the exemption marker for a booking or standalone service line — id_htl_booking=0 for the * latter, id_service_product_order_detail=0 for the former (their own two-shape storage key). * Kept as a shared helper since it's used 3 times (hardDeleteForBooking, applyBooking, applyServiceLine). * * @param int $idHtlBooking * @param int $idServiceProductOrderDetail * @return bool */ protected static function deleteExemptionMarker($idHtlBooking, $idServiceProductOrderDetail) { return Db::getInstance()->execute( 'DELETE FROM `' . _DB_PREFIX_ . 'order_tax_exemption` WHERE `id_htl_booking` = ' . (int) $idHtlBooking . ' AND `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail ); } /** * Applied (non-exempted, non-refunded) tourism tax totals for every booking of an order, keyed by id_htl_booking — cached per order. * * @param int $idOrder * @param int $scope self::SCOPE_ROOM (default) — float per room, keyed by id_htl_booking (room's own tax only) * self::SCOPE_ROOM_STATUS — ['status' => int, 'total' => float] per room, same keys * self::SCOPE_SERVICE — float per room, keyed by the attached room's id_htl_booking * @return array keyed as described above per $scope */ public static function getAppliedTourismTaxTotals($idOrder, $scope = self::SCOPE_ROOM) { $idOrder = (int) $idOrder; $cacheId = 'OrderTaxDetail::getAppliedTourismTaxTotals-' . $idOrder . '-' . $scope; if (Cache::isStored($cacheId)) { return Cache::retrieve($cacheId); } $result = array(); if ($scope === self::SCOPE_SERVICE) { $sql = 'SELECT spod.`id_htl_booking_detail`, SUM(tt.`total_amount`) AS total_amount FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1 INNER JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod ON spod.`id_service_product_order_detail` = tt.`id_service_product_order_detail` LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON (spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`) OR (spod.`id_htl_booking_detail` = 0 AND ex.`id_service_product_order_detail` = tt.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0) WHERE tt.`id_order` = ' . $idOrder . ' AND spod.`id_htl_booking_detail` != 0 AND spod.`is_refunded` = 0 AND ex.`id_order_tax_exemption` IS NULL GROUP BY spod.`id_htl_booking_detail`'; $rows = Db::getInstance()->executeS($sql); foreach ((array) $rows as $row) { $result[(int) $row['id_htl_booking_detail']] = (float) $row['total_amount']; } } else { $rows = Db::getInstance()->executeS( 'SELECT tt.`id_htl_booking`, SUM(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN tt.`total_amount` ELSE 0 END) AS total_amount, MAX(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN 1 ELSE 0 END) AS applied_count FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1 LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON ex.`id_htl_booking` = tt.`id_htl_booking` WHERE tt.`id_order` = ' . $idOrder . ' AND tt.`id_htl_booking` != 0 AND tt.`id_htl_booking` NOT IN (' . OrderReturn::getRefundedBookingIdsSubquery() . ') GROUP BY tt.`id_htl_booking`' ); foreach ((array) $rows as $row) { if ($scope === self::SCOPE_ROOM_STATUS) { $result[(int) $row['id_htl_booking']] = array( 'status' => ((int) $row['applied_count'] > 0) ? self::STATUS_APPLIED : self::STATUS_EXEMPTED, 'total' => (float) $row['total_amount'], ); } else { $result[(int) $row['id_htl_booking']] = (float) $row['total_amount']; } } } Cache::store($cacheId, $result); return $result; } /** * Applied (non-exempted, non-refunded) tourism tax status+total for every standalone (not attached * to a room) service line of an order, keyed by id_service_product_order_detail — the service-line * equivalent of getAppliedTourismTaxTotals(..., SCOPE_ROOM_STATUS), for lines with no room to fold * their status into. * * @param int $idOrder * @return array [id_service_product_order_detail => ['status' => int, 'total' => float]] */ public static function getStandaloneServiceTourismTaxStatuses($idOrder) { $idOrder = (int) $idOrder; $cacheId = 'OrderTaxDetail::getStandaloneServiceTourismTaxStatuses-' . $idOrder; if (Cache::isStored($cacheId)) { return Cache::retrieve($cacheId); } $result = array(); $rows = Db::getInstance()->executeS( 'SELECT tt.`id_service_product_order_detail`, SUM(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN tt.`total_amount` ELSE 0 END) AS total_amount, MAX(CASE WHEN ex.`id_order_tax_exemption` IS NULL THEN 1 ELSE 0 END) AS applied_count FROM `' . _DB_PREFIX_ . 'order_tax_detail` tt INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = tt.`id_tax` AND t.`is_tourism_tax` = 1 INNER JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod ON spod.`id_service_product_order_detail` = tt.`id_service_product_order_detail` LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON ex.`id_service_product_order_detail` = tt.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0 WHERE tt.`id_order` = ' . $idOrder . ' AND tt.`id_service_product_order_detail` != 0 AND spod.`id_htl_booking_detail` = 0 AND spod.`is_refunded` = 0 GROUP BY tt.`id_service_product_order_detail`' ); foreach ((array) $rows as $row) { $result[(int) $row['id_service_product_order_detail']] = array( 'status' => ((int) $row['applied_count'] > 0) ? self::STATUS_APPLIED : self::STATUS_EXEMPTED, 'total' => (float) $row['total_amount'], ); } Cache::store($cacheId, $result); return $result; } /** * Delete every row (VAT and tourism alike) for a scope — used only when the booking/service line * itself is being permanently removed. Unlike the tourism-only delete in saveTourismTax() (which * runs mid-lifecycle while VAT rows for the same scope still need to survive), this clears everything. * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return bool */ protected static function deleteAllForScope($scopeColumn, $scopeValue) { return Db::getInstance()->execute( 'DELETE FROM `' . _DB_PREFIX_ . 'order_tax_detail` WHERE `' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue ); } /** * Same as deleteAllForScope(), but tourism tax only — VAT rows scoped to the same booking/service * are left untouched. Used before a fresh recompute (re-applying after an exemption) so a changed * tourism tax rule is picked up instead of restoring whatever was stored before the exemption. * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return bool */ protected static function deleteTourismRowsForScope($scopeColumn, $scopeValue) { return Db::getInstance()->execute( 'DELETE otd FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1 WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue ); } /** * * @param int $idOrderDetail * @param int $idHtlBooking 0 for a service-line scope * @param int $idServiceProductOrderDetail 0 for a room-booking scope * @return bool */ public static function updateVatScoping($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail) { $idOrderDetail = (int) $idOrderDetail; $idHtlBooking = (int) $idHtlBooking; $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail; $db = Db::getInstance(); $rawUnscopedRows = $db->executeS( 'SELECT `id_order_tax_detail`, `id_order`, `id_tax`, `total_amount` FROM `' . _DB_PREFIX_ . 'order_tax_detail` WHERE `id_order_detail` = ' . $idOrderDetail . ' AND `id_htl_booking` = 0 AND `id_service_product_order_detail` = 0' ); if (!$rawUnscopedRows) { return true; } $unscopedRows = array(); foreach ($rawUnscopedRows as $row) { $idTax = (int) $row['id_tax']; if (isset($unscopedRows[$idTax])) { $unscopedRows[$idTax]['total_amount'] += (float) $row['total_amount']; $db->delete('order_tax_detail', '`id_order_tax_detail` = ' . (int) $row['id_order_tax_detail']); continue; } $unscopedRows[$idTax] = $row; $unscopedRows[$idTax]['total_amount'] = (float) $row['total_amount']; } if ($idHtlBooking) { $ownPriceTaxExcl = (float) $db->getValue( 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id` = ' . $idHtlBooking ); } elseif ($idServiceProductOrderDetail) { $ownPriceTaxExcl = (float) $db->getValue( 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_service_product_order_detail` = ' . $idServiceProductOrderDetail ); } else { $ownPriceTaxExcl = 0.0; } $ownAmounts = OrderDetail::getTaxCalculatorStatic($idOrderDetail)->getTaxesAmount($ownPriceTaxExcl); $result = true; foreach ($unscopedRows as $row) { $idTax = (int) $row['id_tax']; $remaining = (float) $row['total_amount']; $claimed = min($remaining, Tools::ps_round(isset($ownAmounts[$idTax]) ? (float) $ownAmounts[$idTax] : 0.0, 6)); $remaining = Tools::ps_round($remaining - $claimed, 6); $result = $db->execute( 'INSERT INTO `' . _DB_PREFIX_ . 'order_tax_detail` (id_order, id_order_detail, id_htl_booking, id_service_product_order_detail, id_tax, unit_amount, total_amount, date_add) VALUES (' . (int) $row['id_order'] . ', ' . $idOrderDetail . ', ' . (int) $idHtlBooking . ', ' . (int) $idServiceProductOrderDetail . ', ' . $idTax . ', ' . $claimed . ', ' . $claimed . ', NOW())' ) && $result; if ($remaining <= 0.000001) { $result = $db->delete('order_tax_detail', '`id_order_tax_detail` = ' . (int) $row['id_order_tax_detail']) && $result; } else { $result = $db->execute( 'UPDATE `' . _DB_PREFIX_ . 'order_tax_detail` SET `total_amount` = ' . $remaining . ' WHERE `id_order_tax_detail` = ' . (int) $row['id_order_tax_detail'] ) && $result; } } return $result; } /** * * @param int $idOrderDetail * @return bool */ public static function rescopeVatAfterReset($idOrderDetail) { $idOrderDetail = (int) $idOrderDetail; $db = Db::getInstance(); $result = true; $roomIds = $db->executeS( 'SELECT `id` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id_order_detail` = ' . $idOrderDetail ); foreach ($roomIds as $row) { $result = self::updateVatScoping($idOrderDetail, (int) $row['id'], 0) && $result; } $serviceIds = $db->executeS( 'SELECT `id_service_product_order_detail` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_order_detail` = ' . $idOrderDetail ); foreach ($serviceIds as $row) { $result = self::updateVatScoping($idOrderDetail, 0, (int) $row['id_service_product_order_detail']) && $result; } if ($roomIds || $serviceIds) { $result = self::recomputeInclusivePrice($idOrderDetail, 0, 0) && $result; } return $result; } /** * Hard-delete every tax row — VAT and tourism alike — for a booking and its attached service * lines (room removed from an already-placed order), plus the exemption marker if one exists. * * @param int $idHtlBooking * @param int[] $serviceLineIds ServiceProductOrderDetail ids attached to this booking * @return void */ public static function hardDeleteForBooking($idHtlBooking, array $serviceLineIds) { $idHtlBooking = (int) $idHtlBooking; self::deleteAllForScope(self::SCOPE_COLUMN_ROOM, $idHtlBooking); foreach ($serviceLineIds as $idServiceLine) { self::deleteAllForScope(self::SCOPE_COLUMN_SERVICE, (int) $idServiceLine); } self::deleteExemptionMarker($idHtlBooking, 0); } /** * Sum of total_amount for a booking/service-line scope (every row counts — exemption is never * stored on the row itself, it's derived from marker existence at read time). * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return float */ protected static function getScopedTotal($scopeColumn, $scopeValue) { return (float) Db::getInstance()->getValue( 'SELECT COALESCE(SUM(otd.`total_amount`), 0) FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1 WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue ); } /** * Whether tourism tax has never been computed for this scope — no rows exist yet, and none were * ever refunded either. True means a fresh compute (buildRoomTaxParams()/buildServiceLineTaxParams() * + saveTourismTaxFromParams()) is needed instead of restoring/reusing existing rows. * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return bool */ protected static function hasNeverBeenComputed($scopeColumn, $scopeValue) { if (self::hasRefundedRowsForScope($scopeColumn, $scopeValue)) { return false; } return !(bool) Db::getInstance()->getValue( 'SELECT otd.`id_order_tax_detail` FROM `' . _DB_PREFIX_ . 'order_tax_detail` otd INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = otd.`id_tax` AND t.`is_tourism_tax` = 1 WHERE otd.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue ); } /** * Whether a scope's tax is currently excluded from totals — resolves a service line to its parent * room's marker when attached, its own marker when standalone. Used by saveTourismTax() to decide * whether a freshly (re)computed amount should move the denormalized display totals. * * @param int $idHtlBooking 0 for a service-line scope * @param int $idServiceProductOrderDetail 0 for a room-booking scope * @return bool */ protected static function isScopeExempted($idHtlBooking, $idServiceProductOrderDetail) { if ($idHtlBooking) { return self::isBookingExempted($idHtlBooking); } if ($idServiceProductOrderDetail) { $serviceLine = new ServiceProductOrderDetail((int) $idServiceProductOrderDetail); if (Validate::isLoadedObject($serviceLine) && $serviceLine->id_htl_booking_detail) { return self::isBookingExempted((int) $serviceLine->id_htl_booking_detail); } return self::isServiceLineExempted($idServiceProductOrderDetail); } return false; } /** * Compute and save tourism tax for one room-booking or service-product line. * @param int $idTaxRulesGroup Tourism TRG assigned to the product * @param Address $address Jurisdiction address (the hotel's own, not the customer's) * @param float $unitPriceTaxExcl Room/service unit_price_tax_excl (% base and tier lookup) * @param string $checkInDate 'YYYY-MM-DD' * @param int $numNights * @param int $numAdults * @param int[] $childrenAges Ages at check-in date * @param int $idCurrency * @param int $collectionType HotelBranchInformation::tourism_tax_collection_type snapshot * @param int $idLang * @param int $quantity Line quantity multiplier. Rooms: always 1. * @param int $idOrder * @param int $idOrderDetail * @param int $idHtlBooking 0 for service lines * @param int $idServiceProductOrderDetail 0 for room bookings * @return void */ public static function saveTourismTax( $idTaxRulesGroup, Address $address, $unitPriceTaxExcl, $checkInDate, $numNights, $numAdults, array $childrenAges, $idCurrency, $collectionType, $idLang, $quantity, $idOrder, $idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail ) { $idTaxRulesGroup = (int) $idTaxRulesGroup; $idOrderDetail = (int) $idOrderDetail; $idOrder = (int) $idOrder; $idHtlBooking = (int) $idHtlBooking; $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail; if (!Configuration::get('QLO_USE_TOURISM_TAX') || !$idTaxRulesGroup || !$idOrderDetail) { return; } $taxAmounts = array(); $validNightsByTax = array(); if ($idTaxRulesGroup) { $taxCalculator = TaxManagerFactory::getManager($address, $idTaxRulesGroup)->getTaxCalculator(); $taxAmounts = $taxCalculator->getTaxesAmount( $unitPriceTaxExcl, $checkInDate, $numNights, $numAdults, $childrenAges, $collectionType, $quantity, $idCurrency, $validNightsByTax ); } $scopeColumn = $idServiceProductOrderDetail ? self::SCOPE_COLUMN_SERVICE : self::SCOPE_COLUMN_ROOM; $scopeValue = $idServiceProductOrderDetail ?: $idHtlBooking; self::deleteTourismRowsForScope($scopeColumn, $scopeValue); foreach ($taxAmounts as $idTax => $rowAmount) { $rowAmount = (float) $rowAmount; $tourismTax = TaxConfiguration::getByTaxId((int) $idTax); $divisor = ($tourismTax && $tourismTax->per_night) ? max(1, (int) ($validNightsByTax[$idTax] ?? $numNights)) : max(1, (int) $quantity); $taxRow = new OrderTaxDetail(); $taxRow->id_order = $idOrder; $taxRow->id_order_detail = $idOrderDetail; $taxRow->id_htl_booking = $idHtlBooking; $taxRow->id_service_product_order_detail = $idServiceProductOrderDetail; $taxRow->id_tax = (int) $idTax; $taxRow->unit_amount = Tools::ps_round($rowAmount / $divisor, 6); $taxRow->total_amount = $rowAmount; $taxRow->add(); } Db::getInstance()->execute( 'UPDATE `' . _DB_PREFIX_ . 'order_detail` SET `id_tourism_tax_rule_group` = ' . $idTaxRulesGroup . ' WHERE `id_order_detail` = ' . $idOrderDetail ); self::recomputeInclusivePrice($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail); } /** * Sum of VAT + tourism tax rows (exemption-aware) for one scope value of one order_tax_detail column. * * @param string $scopeColumn e.g. 'id_order_detail', self::SCOPE_COLUMN_ROOM, self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return float */ private static function computeScopedTaxSum($scopeColumn, $scopeValue) { return (float) Db::getInstance()->getValue( 'SELECT COALESCE(SUM( CASE WHEN t.`is_tourism_tax` = 1 AND ex.`id_order_tax_exemption` IS NOT NULL THEN 0 ELSE ott.`total_amount` END ), 0) FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax` LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail` LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`) OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`) OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0) WHERE ott.`' . bqSQL($scopeColumn) . '` = ' . (int) $scopeValue ); } /** * A WITH_ROOM auto-add service line's own VAT never gets an order_tax_detail row (it's baked into * the room's price via room-rate substitution instead — see saveTaxCalculator()'s WITH_ROOM skip). * This reconstructs that "phantom" VAT for one service instance so its own inclusive total is complete. * * @param array $serviceRow id_product, id_htl_booking_detail, total_price_tax_excl from service_product_order_detail * @param int $idOrder * @return float */ private static function computePhantomVatForServiceLine(array $serviceRow, $idOrder) { if (!$serviceRow['id_htl_booking_detail']) { return 0.0; } $roomBooking = new HotelBookingDetail((int) $serviceRow['id_htl_booking_detail']); if (!Validate::isLoadedObject($roomBooking)) { return 0.0; } $priceInfo = RoomTypeServiceProductPrice::getProductRoomTypePriceAndTax( (int) $serviceRow['id_product'], (int) $roomBooking->id_product, RoomTypeServiceProduct::WK_ELEMENT_TYPE_ROOM_TYPE ); if (!$priceInfo || !isset($priceInfo['id_tax_rules_group'])) { return 0.0; } $order = new Order((int) $idOrder); $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType( (int) $roomBooking->id_hotel, new Address((int) $order->id_address_tax) ); $address = $hotelContext['address']; if (!Validate::isLoadedObject($address)) { return 0.0; } $totalPriceTaxExcl = (float) $serviceRow['total_price_tax_excl']; $taxCalculator = TaxManagerFactory::getManager($address, (int) $priceInfo['id_tax_rules_group'])->getTaxCalculator(); return $taxCalculator->addTaxes($totalPriceTaxExcl) - $totalPriceTaxExcl; } /** * A room booking's own tax-inclusive total: its own price plus only ITS OWN order_tax_detail rows * (scoped by id_htl_booking) — never another booking's, even when several bookings of the same * room type share one order_detail row (same product, different date ranges). * * @param int $idHtlBooking * @return float */ private static function computeRoomBookingInclusivePrice($idHtlBooking) { $totalPriceTaxExcl = (float) Db::getInstance()->getValue( 'SELECT `total_price_tax_excl` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id` = ' . (int) $idHtlBooking ); return $totalPriceTaxExcl + self::computeScopedTaxSum(self::SCOPE_COLUMN_ROOM, $idHtlBooking); } /** * A service line instance's own tax-inclusive total: its own price plus only ITS OWN order_tax_detail * rows (scoped by id_service_product_order_detail) plus its own phantom VAT share if WITH_ROOM auto-add * — never another instance's, even when several instances of the same service share one order_detail row. * * @param int $idServiceProductOrderDetail * @param array $orderDetailRow product_auto_add, product_price_addition_type, id_order (shared by all * instances under this order_detail, since they're all the same product) * @return float */ private static function computeServiceLineInclusivePrice($idServiceProductOrderDetail, array $orderDetailRow) { $serviceRow = Db::getInstance()->getRow( 'SELECT `total_price_tax_excl`, `id_product`, `id_htl_booking_detail` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_service_product_order_detail` = ' . (int) $idServiceProductOrderDetail ); $totalPriceTaxExcl = (float) $serviceRow['total_price_tax_excl']; $phantomVat = 0.0; if ($orderDetailRow['product_auto_add'] && (int) $orderDetailRow['product_price_addition_type'] === Product::PRICE_ADDITION_TYPE_WITH_ROOM ) { $phantomVat = self::computePhantomVatForServiceLine($serviceRow, (int) $orderDetailRow['id_order']); } return $totalPriceTaxExcl + self::computeScopedTaxSum(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail) + $phantomVat; } /** * * @param int $idOrderDetail * @param int $idHtlBooking unused; kept for call-site compatibility * @param int $idServiceProductOrderDetail unused; kept for call-site compatibility * @return bool */ protected static function recomputeInclusivePrice($idOrderDetail, $idHtlBooking, $idServiceProductOrderDetail) { $idOrderDetail = (int) $idOrderDetail; $db = Db::getInstance(); $orderDetailRow = $db->getRow( 'SELECT `product_quantity`, `product_auto_add`, `product_price_addition_type`, `id_order` FROM `' . _DB_PREFIX_ . 'order_detail` WHERE `id_order_detail` = ' . $idOrderDetail ); $result = true; $aggregateTaxIncl = 0.0; $roomIds = $db->executeS( 'SELECT `id` FROM `' . _DB_PREFIX_ . 'htl_booking_detail` WHERE `id_order_detail` = ' . $idOrderDetail ); foreach ($roomIds as $row) { $roomTaxIncl = self::computeRoomBookingInclusivePrice((int) $row['id']); $aggregateTaxIncl += $roomTaxIncl; $result = $db->execute( 'UPDATE `' . _DB_PREFIX_ . 'htl_booking_detail` SET `total_price_tax_incl` = ' . $roomTaxIncl . ' WHERE `id` = ' . (int) $row['id'] ) && $result; } $serviceIds = $db->executeS( 'SELECT `id_service_product_order_detail`, `quantity` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_order_detail` = ' . $idOrderDetail ); foreach ($serviceIds as $row) { $serviceTaxIncl = self::computeServiceLineInclusivePrice((int) $row['id_service_product_order_detail'], $orderDetailRow); $aggregateTaxIncl += $serviceTaxIncl; $result = $db->execute( 'UPDATE `' . _DB_PREFIX_ . 'service_product_order_detail` SET `total_price_tax_incl` = ' . $serviceTaxIncl . ', `unit_price_tax_incl` = COALESCE(' . $serviceTaxIncl . ' / NULLIF(' . (int) $row['quantity'] . ', 0), 0) WHERE `id_service_product_order_detail` = ' . (int) $row['id_service_product_order_detail'] ) && $result; } $result = $db->execute( 'UPDATE `' . _DB_PREFIX_ . 'order_detail` SET `total_price_tax_incl` = ' . $aggregateTaxIncl . ', `unit_price_tax_incl` = COALESCE(' . $aggregateTaxIncl . ' / NULLIF(`product_quantity`, 0), 0) WHERE `id_order_detail` = ' . $idOrderDetail ) && $result; return $result; } /** * Breakdown for invoice/PDF — grouped by id_tax, excluding exempted (via marker) or refunded rows, cached per order+lang. * * @param int $idOrder * @param int|null $idLang defaults to the current context language * @return array */ public static function getBreakdownForInvoice($idOrder, $idLang = null) { $idOrder = (int) $idOrder; $idLang = $idLang !== null ? (int) $idLang : (int) Context::getContext()->language->id; $cacheId = 'OrderTaxDetail::getBreakdownForInvoice-' . $idOrder . '-' . $idLang; if (Cache::isStored($cacheId)) { return Cache::retrieve($cacheId); } $result = Db::getInstance()->executeS( 'SELECT ott.`id_tax`, tl.`name` AS tax_name, SUM(ott.`total_amount`) AS total_amount FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax` AND t.`is_tourism_tax` = 1 LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail` LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`) OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`) OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0) LEFT JOIN `' . _DB_PREFIX_ . 'tax_lang` tl ON tl.`id_tax` = ott.`id_tax` AND tl.`id_lang` = ' . $idLang . ' WHERE ott.`id_order` = ' . $idOrder . ' AND ((ott.`id_htl_booking` != 0 AND ott.`id_htl_booking` NOT IN (' . OrderReturn::getRefundedBookingIdsSubquery() . ')) OR (ott.`id_htl_booking` = 0 AND spod.`is_refunded` = 0)) AND ex.`id_order_tax_exemption` IS NULL GROUP BY ott.`id_tax`, tl.`name`' ); Cache::store($cacheId, $result); return $result; } /** * Applied (non-exempted, non-refunded) tourism tax total per order_detail line. Same * exemption/refund-aware join as getBreakdownForInvoice(), grouped by id_order_detail instead of * id_tax. * * @param int $idOrder * @return array [id_order_detail => total_amount] */ public static function getAmountsByOrderDetail($idOrder) { $idOrder = (int) $idOrder; $cacheId = 'OrderTaxDetail::getAmountsByOrderDetail-' . $idOrder; if (Cache::isStored($cacheId)) { return Cache::retrieve($cacheId); } $rows = Db::getInstance()->executeS( 'SELECT ott.`id_order_detail`, SUM(ott.`total_amount`) AS total_amount FROM `' . _DB_PREFIX_ . 'order_tax_detail` ott INNER JOIN `' . _DB_PREFIX_ . 'tax` t ON t.`id_tax` = ott.`id_tax` AND t.`is_tourism_tax` = 1 LEFT JOIN `' . _DB_PREFIX_ . 'service_product_order_detail` spod ON spod.`id_service_product_order_detail` = ott.`id_service_product_order_detail` LEFT JOIN `' . _DB_PREFIX_ . 'order_tax_exemption` ex ON (ott.`id_htl_booking` != 0 AND ex.`id_htl_booking` = ott.`id_htl_booking`) OR (ott.`id_htl_booking` = 0 AND spod.`id_htl_booking_detail` != 0 AND ex.`id_htl_booking` = spod.`id_htl_booking_detail`) OR (ott.`id_htl_booking` = 0 AND (spod.`id_htl_booking_detail` = 0 OR spod.`id_htl_booking_detail` IS NULL) AND ex.`id_service_product_order_detail` = ott.`id_service_product_order_detail` AND ex.`id_htl_booking` = 0) WHERE ott.`id_order` = ' . $idOrder . ' AND ((ott.`id_htl_booking` != 0 AND ott.`id_htl_booking` NOT IN (' . OrderReturn::getRefundedBookingIdsSubquery() . ')) OR (ott.`id_htl_booking` = 0 AND spod.`is_refunded` = 0)) AND ex.`id_order_tax_exemption` IS NULL GROUP BY ott.`id_order_detail`' ); $result = array(); foreach ((array) $rows as $row) { $result[(int) $row['id_order_detail']] = (float) $row['total_amount']; } Cache::store($cacheId, $result); return $result; } /** * The single source of truth for an order's tourism tax total — applied, non-exempted, non-refunded. * * @param int $idOrder * @return float */ public static function getOrderTourismTaxTotal($idOrder) { return (float) array_sum(array_column(self::getBreakdownForInvoice((int) $idOrder), 'total_amount')); } /** * Whether a scope is refunded — apply/exempt must not touch a cancelled booking/service line. * Reads the authoritative flag directly (htl_booking_detail/service_product_order_detail), * not a stored copy on order_tax_detail. * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @return bool */ protected static function hasRefundedRowsForScope($scopeColumn, $scopeValue) { $scopeValue = (int) $scopeValue; if ($scopeColumn === self::SCOPE_COLUMN_ROOM) { return (bool) Db::getInstance()->getValue( 'SELECT ' . $scopeValue . ' IN (' . OrderReturn::getRefundedBookingIdsSubquery() . ')' ); } return (bool) Db::getInstance()->getValue( 'SELECT `is_refunded` FROM `' . _DB_PREFIX_ . 'service_product_order_detail` WHERE `id_service_product_order_detail` = ' . $scopeValue ); } /** * Resolve every parameter saveTourismTax() needs for an already-placed room booking, from the booking's own hotel (not the order's single id_address_tax column). * * @param int $idHtlBooking * @return array|false Ordered args for self::saveTourismTax(), or false when the * booking/order/TRG/address can't be resolved */ public static function buildRoomTaxParams($idHtlBooking) { $idHtlBooking = (int) $idHtlBooking; $booking = new HotelBookingDetail($idHtlBooking); if (!Validate::isLoadedObject($booking)) { return false; } $order = new Order((int) $booking->id_order); if (!Validate::isLoadedObject($order)) { return false; } $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $booking->id_product); if (!$idTaxRulesGroup) { return false; } $idHotel = (int) $booking->id_hotel; $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax)); $address = $hotelContext['address']; if (!Validate::isLoadedObject($address)) { return false; } $collectionType = $hotelContext['collectionType']; $numNights = (int) HotelHelper::getNumberOfDays($booking->date_from, $booking->date_to); if ($numNights <= 0) { return false; } $unitPriceTaxExcl = Tools::ps_round((float) $booking->total_price_tax_excl / $numNights, 6); $childrenAges = array(); if ($booking->child_ages) { $decoded = json_decode($booking->child_ages, true); if (is_array($decoded)) { $childrenAges = array_map('intval', $decoded); } } return array( 'idTaxRulesGroup' => $idTaxRulesGroup, 'address' => $address, 'unitPriceTaxExcl' => $unitPriceTaxExcl, 'checkInDate' => $booking->date_from, 'numNights' => $numNights, 'numAdults' => (int) $booking->adults, 'childrenAges' => $childrenAges, 'idCurrency' => (int) $order->id_currency, 'collectionType' => $collectionType, 'idLang' => (int) Context::getContext()->language->id, 'quantity' => 1, 'idOrder' => (int) $booking->id_order, 'idOrderDetail' => (int) $booking->id_order_detail, 'idHtlBooking' => $idHtlBooking, 'idServiceProductOrderDetail' => 0, ); } /** * Same as buildRoomTaxParams(), for an already-placed service product line. * * @param int $idServiceProductOrderDetail * @return array|false */ public static function buildServiceLineTaxParams($idServiceProductOrderDetail) { $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail; $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail); if (!Validate::isLoadedObject($serviceLine)) { return false; } $order = new Order((int) $serviceLine->id_order); if (!Validate::isLoadedObject($order)) { return false; } $idTaxRulesGroup = Product::getIdTourismTaxRulesGroupByIdProduct((int) $serviceLine->id_product); if (!$idTaxRulesGroup) { return false; } $idHotel = (int) $serviceLine->id_hotel; $hotelContext = TaxConfiguration::resolveHotelAddressAndCollectionType($idHotel, new Address((int) $order->id_address_tax)); $address = $hotelContext['address']; if (!Validate::isLoadedObject($address)) { return false; } $collectionType = $hotelContext['collectionType']; $idHtlBookingDetail = (int) $serviceLine->id_htl_booking_detail; $roomBooking = $idHtlBookingDetail ? new HotelBookingDetail($idHtlBookingDetail) : null; if ($roomBooking && Validate::isLoadedObject($roomBooking)) { $checkInDate = $roomBooking->date_from; $numNights = max(1, (int) HotelHelper::getNumberOfDays($roomBooking->date_from, $roomBooking->date_to)); $numAdults = (int) $roomBooking->adults; $childrenAges = array(); if ($roomBooking->child_ages) { $decoded = json_decode($roomBooking->child_ages, true); if (is_array($decoded)) { $childrenAges = array_map('intval', $decoded); } } } else { $checkInDate = date('Y-m-d'); $numNights = 1; $numAdults = 1; $childrenAges = array(); } return array( 'idTaxRulesGroup' => $idTaxRulesGroup, 'address' => $address, 'unitPriceTaxExcl' => (float) $serviceLine->unit_price_tax_excl, 'checkInDate' => $checkInDate, 'numNights' => $numNights, 'numAdults' => $numAdults, 'childrenAges' => $childrenAges, 'idCurrency' => (int) $order->id_currency, 'collectionType' => $collectionType, 'idLang' => (int) Context::getContext()->language->id, 'quantity' => (int) $serviceLine->quantity, 'idOrder' => (int) $serviceLine->id_order, 'idOrderDetail' => (int) $serviceLine->id_order_detail, 'idHtlBooking' => 0, 'idServiceProductOrderDetail' => $idServiceProductOrderDetail, ); } /** * Call saveTourismTax() with a buildRoomTaxParams()/buildServiceLineTaxParams() result. * * @param array $params * @return void */ protected static function saveTourismTaxFromParams(array $params) { self::saveTourismTax( $params['idTaxRulesGroup'], $params['address'], $params['unitPriceTaxExcl'], $params['checkInDate'], $params['numNights'], $params['numAdults'], $params['childrenAges'], $params['idCurrency'], $params['collectionType'], $params['idLang'], $params['quantity'], $params['idOrder'], $params['idOrderDetail'], $params['idHtlBooking'], $params['idServiceProductOrderDetail'] ); } /** * * @param int $idOrder * @param int $idEmployee * @param string $autoMsg * @param string|null $note Optional remark appended as "| {$remarkLabel}: ..." — omit for apply actions * @param string $remarkLabel Pre-translated "Remark" label */ protected static function saveTourismTaxNote($idOrder, $idEmployee, $autoMsg, $note = null, $remarkLabel = 'Remark') { $noteMessage = $autoMsg . ($note && Validate::isMessage($note) ? ' | ' . $remarkLabel . ': ' . $note : ''); $message = new Message(); $message->message = $noteMessage; $message->id_order = (int) $idOrder; $message->id_employee = (int) $idEmployee; $message->private = 1; $message->save(); } /** * Exempt a booking's tourism tax — one marker row covers the room and every service attached to * it. Idempotent: exempting an already-exempted booking is a no-op success. * * @param int $idHtlBooking * @param int $idEmployee PS employee id (0 = resolved from Context) * @param string|null $note Reason for exemption, entered by the employee * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label * @param string $remarkLabel Pre-translated "Remark" label * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE */ public static function exemptBooking($idHtlBooking, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark') { $idHtlBooking = (int) $idHtlBooking; if (self::isBookingExempted($idHtlBooking)) { return self::EXEMPT_OK; } $booking = new HotelBookingDetail($idHtlBooking); if (!Validate::isLoadedObject($booking)) { return self::EXEMPT_ERROR_NO_RULE; } $objOrderTaxExemption = new OrderTaxExemption(); $objOrderTaxExemption->id_htl_booking = $idHtlBooking; $objOrderTaxExemption->id_service_product_order_detail = 0; $objOrderTaxExemption->id_order = (int) $booking->id_order; $objOrderTaxExemption->id_employee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id; $objOrderTaxExemption->note = $note; if (!$objOrderTaxExemption->add()) { return self::EXEMPT_ERROR_SAVE; } if ($autoMsgLabel !== null) { $roomTypeName = Product::getProductName((int) $booking->id_product, null, Context::getContext()->language->id); $autoMsg = $autoMsgLabel . ': ' . $booking->room_num . ' - ' . $roomTypeName . ': (' . date('d/m/Y', strtotime($booking->date_from)) . ' - ' . date('d/m/Y', strtotime($booking->date_to)) . ')'; self::saveTourismTaxNote((int) $booking->id_order, $objOrderTaxExemption->id_employee, $autoMsg, $note, $remarkLabel); } self::syncDenormalizedTotalsForBooking($idHtlBooking, false); return self::EXEMPT_OK; } /** * Apply (or re-apply) tourism tax for a booking — deletes the exemption marker if one exists, * restoring the room + every attached service's tax into the denormalized display totals. If * nothing has ever been computed for the room and/or a given service line (e.g. config was off * at order time), computes it fresh instead. * * @param int $idHtlBooking * @param int $idEmployee PS employee id (0 = resolved from Context) * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NOT_APPLICABLE */ public static function applyBooking($idHtlBooking, $idEmployee = 0, $autoMsgLabel = null) { $idHtlBooking = (int) $idHtlBooking; $booking = new HotelBookingDetail($idHtlBooking); $wasExempted = self::isBookingExempted($idHtlBooking); $changed = $wasExempted; if ($wasExempted) { if (!self::deleteExemptionMarker($idHtlBooking, 0)) { return self::APPLY_ERROR_RESTORE; } self::deleteTourismRowsForScope(self::SCOPE_COLUMN_ROOM, $idHtlBooking); } if (self::hasNeverBeenComputed(self::SCOPE_COLUMN_ROOM, $idHtlBooking)) { $params = self::buildRoomTaxParams($idHtlBooking); if ($params) { $params['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE; self::saveTourismTaxFromParams($params); $changed = true; if (!$wasExempted) { self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_ROOM, $idHtlBooking, $params['idOrder']); } } } $idServiceLines = ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking); foreach ($idServiceLines as $idServiceLine) { if ($wasExempted) { self::deleteTourismRowsForScope(self::SCOPE_COLUMN_SERVICE, $idServiceLine); } if (!self::hasNeverBeenComputed(self::SCOPE_COLUMN_SERVICE, $idServiceLine)) { continue; } $svcParams = self::buildServiceLineTaxParams($idServiceLine); if ($svcParams) { $svcParams['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE; self::saveTourismTaxFromParams($svcParams); $changed = true; if (!$wasExempted) { self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_SERVICE, $idServiceLine, $svcParams['idOrder']); } } } if ($wasExempted) { self::syncDenormalizedTotalsForBooking($idHtlBooking, true); } $hasAnyTourismTax = self::getScopedTotal(self::SCOPE_COLUMN_ROOM, $idHtlBooking) > 0; foreach ($idServiceLines as $idServiceLine) { if ($hasAnyTourismTax) { break; } $hasAnyTourismTax = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceLine) > 0; } if (!$hasAnyTourismTax) { return self::APPLY_ERROR_NOT_APPLICABLE; } if ($autoMsgLabel !== null && $changed) { $idEmployee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id; $roomTypeName = Product::getProductName((int) $booking->id_product, null, Context::getContext()->language->id); $autoMsg = $autoMsgLabel . ': ' . $booking->room_num . ' - ' . $roomTypeName . ': (' . date('d/m/Y', strtotime($booking->date_from)) . ' - ' . date('d/m/Y', strtotime($booking->date_to)) . ')'; self::saveTourismTaxNote((int) $booking->id_order, $idEmployee, $autoMsg); } return self::APPLY_OK; } /** * Exempt a single standalone (no room attachment) service line directly — the one case a * booking-level marker can't cover, since there's no booking to attach it to. * * @param int $idServiceProductOrderDetail * @param int $idEmployee * @param string|null $note * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label — null skips the Order Notes entry entirely * @param string $remarkLabel Pre-translated "Remark" label * @return int EXEMPT_OK | EXEMPT_ERROR_NO_RULE | EXEMPT_ERROR_SAVE */ public static function exemptServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark') { $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail; if (self::isServiceLineExempted($idServiceProductOrderDetail)) { return self::EXEMPT_OK; } $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail); if (!Validate::isLoadedObject($serviceLine) || $serviceLine->id_htl_booking_detail) { return self::EXEMPT_ERROR_NO_RULE; } $objOrderTaxExemption = new OrderTaxExemption(); $objOrderTaxExemption->id_htl_booking = 0; $objOrderTaxExemption->id_service_product_order_detail = $idServiceProductOrderDetail; $objOrderTaxExemption->id_order = (int) $serviceLine->id_order; $objOrderTaxExemption->id_employee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id; $objOrderTaxExemption->note = $note; if (!$objOrderTaxExemption->add()) { return self::EXEMPT_ERROR_SAVE; } if ($autoMsgLabel !== null) { $autoMsg = $autoMsgLabel . ': ' . $serviceLine->name; self::saveTourismTaxNote((int) $serviceLine->id_order, $objOrderTaxExemption->id_employee, $autoMsg, $note, $remarkLabel); } $total = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail); if ($total) { self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceProductOrderDetail); self::adjustOrderAndInvoiceTotals((int) $serviceLine->id_order, -$total); } return self::EXEMPT_OK; } /** * Apply (or re-apply) tourism tax for a single standalone service line. * * @param int $idServiceProductOrderDetail * @param int $idEmployee PS employee id (0 = resolved from Context) * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely * @return int APPLY_OK | APPLY_ERROR_RESTORE | APPLY_ERROR_NOT_APPLICABLE */ public static function applyServiceLine($idServiceProductOrderDetail, $idEmployee = 0, $autoMsgLabel = null) { $idServiceProductOrderDetail = (int) $idServiceProductOrderDetail; $serviceLine = new ServiceProductOrderDetail($idServiceProductOrderDetail); $wasExempted = self::isServiceLineExempted($idServiceProductOrderDetail); $changed = $wasExempted; if ($wasExempted) { if (!self::deleteExemptionMarker(0, $idServiceProductOrderDetail)) { return self::APPLY_ERROR_RESTORE; } self::deleteTourismRowsForScope(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail); } if (self::hasNeverBeenComputed(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail)) { $params = self::buildServiceLineTaxParams($idServiceProductOrderDetail); if ($params) { $params['collectionType'] = HotelBranchInformation::TAX_COLLECTION_TYPE_ONLINE; self::saveTourismTaxFromParams($params); $changed = true; if (!$wasExempted) { self::adjustOrderTotalsForNewRows(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail, $params['idOrder']); } } } if ($wasExempted) { $total = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail); if ($total && Validate::isLoadedObject($serviceLine)) { self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceProductOrderDetail); self::adjustOrderAndInvoiceTotals((int) $serviceLine->id_order, $total); } } if (self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceProductOrderDetail) <= 0) { return self::APPLY_ERROR_NOT_APPLICABLE; } if ($autoMsgLabel !== null && $changed) { $idEmployee = $idEmployee ? (int) $idEmployee : (int) Context::getContext()->employee->id; $autoMsg = $autoMsgLabel . ': ' . $serviceLine->name; self::saveTourismTaxNote((int) $serviceLine->id_order, $idEmployee, $autoMsg); } return self::APPLY_OK; } /** * Exempt tourism tax for every booking (room + attached services) and every standalone service line * of an order; returns only the failing entries. * * @param int $idOrder * @param int $idEmployee * @param string|null $note Reason for exemption, applied to every booking/line in the order * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Exempted" label — null skips the Order Notes entry entirely, for every booking/line in the order * @param string $remarkLabel Pre-translated "Remark" label * @return array [{'id_htl_booking' => int, 'result' => int}, ...] plus * [{'id_service_product_order_detail' => int, 'result' => int}, ...] — failures only */ public static function exemptForOrder($idOrder, $idEmployee = 0, $note = null, $autoMsgLabel = null, $remarkLabel = 'Remark') { $idOrder = (int) $idOrder; $failures = array(); $objBookingDetail = new HotelBookingDetail(); $bookings = $objBookingDetail->getBookingDataByOrderId($idOrder); if ($bookings) { $refundedIds = array_flip(array_column( Db::getInstance()->executeS(OrderReturn::getRefundedBookingIdsSubquery()), 'id_htl_booking' )); foreach ($bookings as $booking) { if (isset($refundedIds[$booking['id']]) || $booking['id_status'] == HotelBookingDetail::STATUS_CANCELLED) { continue; } $result = self::exemptBooking($booking['id'], $idEmployee, $note, $autoMsgLabel, $remarkLabel); if ($result !== self::EXEMPT_OK) { $failures[] = array('id_htl_booking' => (int) $booking['id'], 'result' => $result); } } } foreach (ServiceProductOrderDetail::getActiveStandaloneIdsByOrder($idOrder) as $idServiceLine) { $result = self::exemptServiceLine($idServiceLine, $idEmployee, $note, $autoMsgLabel, $remarkLabel); if ($result !== self::EXEMPT_OK) { $failures[] = array('id_service_product_order_detail' => $idServiceLine, 'result' => $result); } } return $failures; } /** * Apply tourism tax for every booking and every standalone service line of an order; returns only * the failing entries. A booking/line with nothing applicable (neither the room nor any attached * service, or the standalone line itself, has a tourism tax rule) is not a failure — it's silently * skipped, same as if it had simply been left alone. * * @param int $idOrder * @param int $idEmployee PS employee id (0 = resolved from Context) * @param string|null $autoMsgLabel Pre-translated "Tourism Tax Applied" label — null skips the Order Notes entry entirely, for every booking/line in the order * @return array [{'id_htl_booking' => int, 'result' => int}, ...] plus * [{'id_service_product_order_detail' => int, 'result' => int}, ...] — failures only */ public static function applyForOrder($idOrder, $idEmployee = 0, $autoMsgLabel = null) { $idOrder = (int) $idOrder; $failures = array(); $objBookingDetail = new HotelBookingDetail(); $bookings = $objBookingDetail->getBookingDataByOrderId($idOrder); if ($bookings) { $refundedIds = array_flip(array_column( Db::getInstance()->executeS(OrderReturn::getRefundedBookingIdsSubquery()), 'id_htl_booking' )); foreach ($bookings as $booking) { if (isset($refundedIds[$booking['id']]) || $booking['id_status'] == HotelBookingDetail::STATUS_CANCELLED) { continue; } $result = self::applyBooking($booking['id'], $idEmployee, $autoMsgLabel); if ($result !== self::APPLY_OK && $result !== self::APPLY_ERROR_NOT_APPLICABLE) { $failures[] = array('id_htl_booking' => (int) $booking['id'], 'result' => $result); } } } foreach (ServiceProductOrderDetail::getActiveStandaloneIdsByOrder($idOrder) as $idServiceLine) { $result = self::applyServiceLine($idServiceLine, $idEmployee, $autoMsgLabel); if ($result !== self::APPLY_OK && $result !== self::APPLY_ERROR_NOT_APPLICABLE) { $failures[] = array('id_service_product_order_detail' => $idServiceLine, 'result' => $result); } } return $failures; } /** * After fresh-computing tourism tax for a scope that had no prior rows (e.g. config was off at * order time), fold the newly-applied total into the order's own totals — recomputeInclusivePrice() * (called inside saveTourismTax()) only syncs order_detail/htl_booking_detail/service_product_order_detail, * never orders/order_invoice. * * @param string $scopeColumn self::SCOPE_COLUMN_ROOM or self::SCOPE_COLUMN_SERVICE * @param int $scopeValue * @param int $idOrder * @return void */ protected static function adjustOrderTotalsForNewRows($scopeColumn, $scopeValue, $idOrder) { $appliedTotal = self::getScopedTotal($scopeColumn, $scopeValue); self::adjustOrderAndInvoiceTotals($idOrder, $appliedTotal); } /** * Sum the room's own tax + every attached service line's tax and apply that whole delta once — * used on exemptBooking()/applyBooking() to move the booking's tourism tax in or out of the * denormalized display totals in one pass, rather than per-row. * * @param int $idHtlBooking * @param bool $nowIncluded true = restoring (apply), false = removing (exempt) * @return void */ protected static function syncDenormalizedTotalsForBooking($idHtlBooking, $nowIncluded) { $idHtlBooking = (int) $idHtlBooking; $booking = new HotelBookingDetail($idHtlBooking); if (!Validate::isLoadedObject($booking)) { return; } $totalDelta = 0.0; $roomTotal = self::getScopedTotal(self::SCOPE_COLUMN_ROOM, $idHtlBooking); if ($roomTotal) { $prev = $nowIncluded ? 0.0 : $roomTotal; $new = $nowIncluded ? $roomTotal : 0.0; self::recomputeInclusivePrice((int) $booking->id_order_detail, $idHtlBooking, 0); $totalDelta += ($new - $prev); } foreach (ServiceProductOrderDetail::getActiveIdsByHtlBookingDetail($idHtlBooking) as $idServiceLine) { $serviceTotal = self::getScopedTotal(self::SCOPE_COLUMN_SERVICE, $idServiceLine); if (!$serviceTotal) { continue; } $serviceLine = new ServiceProductOrderDetail($idServiceLine); if (!Validate::isLoadedObject($serviceLine)) { continue; } $prev = $nowIncluded ? 0.0 : $serviceTotal; $new = $nowIncluded ? $serviceTotal : 0.0; self::recomputeInclusivePrice((int) $serviceLine->id_order_detail, 0, $idServiceLine); $totalDelta += ($new - $prev); } self::adjustOrderAndInvoiceTotals((int) $booking->id_order, $totalDelta); } /** * Apply an applied-tourism-tax delta to both the order's own totals and every invoice already generated for it. * * @param int $idOrder * @param float $delta Signed — positive when restoring/adding tax, negative when exempting it */ protected static function adjustOrderAndInvoiceTotals($idOrder, $delta) { $delta = (float) $delta; if (!$delta) { return; } $idOrder = (int) $idOrder; Db::getInstance()->execute( 'UPDATE `' . _DB_PREFIX_ . 'orders` SET `total_paid_tax_incl` = GREATEST(0, `total_paid_tax_incl` + ' . $delta . '), `total_paid_tax_excl` = GREATEST(0, `total_paid_tax_excl` + ' . $delta . '), `total_paid` = GREATEST(0, `total_paid` + ' . $delta . ') WHERE `id_order` = ' . $idOrder ); Db::getInstance()->execute( 'UPDATE `' . _DB_PREFIX_ . 'order_invoice` SET `total_paid_tax_incl` = GREATEST(0, `total_paid_tax_incl` + ' . $delta . '), `total_paid_tax_excl` = GREATEST(0, `total_paid_tax_excl` + ' . $delta . ') WHERE `id_order` = ' . $idOrder ); } }