Files
hotel/classes/order/OrderTaxDetail.php

1430 lines
64 KiB
PHP

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