From 3fd5c2a920a0d6930a366842357e00b78c9194e7 Mon Sep 17 00:00:00 2001 From: Anthony MONTEBRUN <61311746+anthonymontebrun@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:43:33 +0200 Subject: [PATCH 1/2] Allow restricting credit vouchers to incidents and/or requests Add "Usable for incidents" and "Usable for requests" options on credit vouchers (both enabled by default, including for existing vouchers on migration). Vouchers not usable for the ticket type are filtered out of voucher dropdowns, default vouchers are ignored when not applicable, the consume block is hidden when no voucher applies, and consumption is rejected server side. --- CHANGELOG.md | 4 + front/ticket.form.php | 9 ++ inc/entity.class.php | 104 +++++++++++++++++++ inc/ticket.class.php | 24 ++++- inc/ticketconfig.class.php | 2 +- templates/creditentity.hmtl.twig | 14 +++ templates/creditentity_form.html.twig | 14 +++ tests/Units/TicketTypeVoucherTest.php | 142 ++++++++++++++++++++++++++ 8 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 tests/Units/TicketTypeVoucherTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba2cce..b525a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added + +- Credit vouchers can be restricted to incidents and/or requests (both enabled by default) + ### Fixed - Fix access denied error when consuming a credit voucher from the ticket Credit tab diff --git a/front/ticket.form.php b/front/ticket.form.php index c07fe19..753dc96 100644 --- a/front/ticket.form.php +++ b/front/ticket.form.php @@ -63,6 +63,15 @@ throw new AccessDeniedHttpException(); } +if (!PluginCreditEntity::canBeUsedForTicket((int) $_REQUEST['plugin_credit_entities_id'], $ticket)) { + Session::addMessageAfterRedirect( + __s('This credit voucher cannot be used for this type of ticket', 'credit'), + true, + ERROR, + ); + Html::back(); +} + $input = [ 'tickets_id' => $_REQUEST['tickets_id'], diff --git a/inc/entity.class.php b/inc/entity.class.php index 3cd4367..9226fbb 100644 --- a/inc/entity.class.php +++ b/inc/entity.class.php @@ -197,6 +197,8 @@ public static function showForItemtype(Entity $entity, $itemtype = 'Entity') 'quantity_remaining' => __('Quantity remaining', 'credit'), 'overconsumption_allowed' => __('Allow overconsumption', 'credit'), 'low_credit_alert' => __('Low credits alert', 'credit'), + 'is_for_incidents' => __('Usable for incidents', 'credit'), + 'is_for_requests' => __('Usable for requests', 'credit'), 'entities_id' => __('Entity'), 'is_recursive' => __('Child entities'), ]; @@ -342,6 +344,22 @@ public function rawSearchOptions() 'unit' => '%', ]; + $tab[] = [ + 'id' => 998, + 'table' => self::getTable(), + 'field' => 'is_for_incidents', + 'name' => __('Usable for incidents', 'credit'), + 'datatype' => 'bool', + ]; + + $tab[] = [ + 'id' => 999, + 'table' => self::getTable(), + 'field' => 'is_for_requests', + 'name' => __('Usable for requests', 'credit'), + 'datatype' => 'bool', + ]; + $tab[] = [ 'id' => 80, 'table' => 'glpi_entities', @@ -544,6 +562,8 @@ public static function install(Migration $migration) `quantity` int NOT NULL DEFAULT '0', `overconsumption_allowed` tinyint NOT NULL DEFAULT '0', `low_credit_alert` int DEFAULT NULL, + `is_for_incidents` tinyint NOT NULL DEFAULT '1', + `is_for_requests` tinyint NOT NULL DEFAULT '1', PRIMARY KEY (`id`), KEY `name` (`name`), KEY `entities_id` (`entities_id`), @@ -568,6 +588,10 @@ public static function install(Migration $migration) // 1.13.2 $migration->addField($table, 'low_credit_alert', 'int', ['update' => "NULL"]); + + // Restrict vouchers to incidents and/or requests + $migration->addField($table, 'is_for_incidents', 'bool', ['value' => '1', 'update' => '1']); + $migration->addField($table, 'is_for_requests', 'bool', ['value' => '1', 'update' => '1']); } return true; @@ -604,6 +628,86 @@ public static function getActiveFilter() ]; } + /** + * Get criteria restricting credit vouchers to those usable for the given ticket type. + * + * @param int $ticket_type Ticket type (Ticket::INCIDENT_TYPE or Ticket::DEMAND_TYPE) + * + * @return array + */ + public static function getTicketTypeFilter(int $ticket_type): array + { + return match ($ticket_type) { + Ticket::INCIDENT_TYPE => ['glpi_plugin_credit_entities.is_for_incidents' => 1], + Ticket::DEMAND_TYPE => ['glpi_plugin_credit_entities.is_for_requests' => 1], + default => [], + }; + } + + /** + * Get criteria restricting credit vouchers to active ones usable for the given ticket. + * + * @param Ticket $ticket + * + * @return array + */ + public static function getActiveFilterForTicket(Ticket $ticket): array + { + return array_merge( + self::getActiveFilter(), + self::getTicketTypeFilter((int) ($ticket->fields['type'] ?? 0)), + ); + } + + /** + * Check if at least one active credit voucher can be consumed on the given ticket. + * + * @param Ticket $ticket + * + * @return bool + */ + public static function hasUsableVoucherForTicket(Ticket $ticket): bool + { + return countElementsInTable(self::getTable(), [ + 'WHERE' => [ + getEntitiesRestrictCriteria(self::getTable(), '', $ticket->getEntityID(), true), + self::getActiveFilterForTicket($ticket), + ], + ]) > 0; + } + + /** + * Check if the credit voucher can be used for the given ticket type. + * + * @param int $ticket_type Ticket type (Ticket::INCIDENT_TYPE or Ticket::DEMAND_TYPE) + * + * @return bool + */ + public function isUsableForTicketType(int $ticket_type): bool + { + return match ($ticket_type) { + Ticket::INCIDENT_TYPE => (bool) $this->fields['is_for_incidents'], + Ticket::DEMAND_TYPE => (bool) $this->fields['is_for_requests'], + default => true, + }; + } + + /** + * Check if the credit voucher can be used for the given ticket, based on the ticket type. + * + * @param int $credit_id ID of the credit voucher + * @param Ticket $ticket + * + * @return bool + */ + public static function canBeUsedForTicket(int $credit_id, Ticket $ticket): bool + { + $credit_entity = new self(); + + return $credit_entity->getFromDB($credit_id) + && $credit_entity->isUsableForTicketType((int) ($ticket->fields['type'] ?? 0)); + } + /** * Check if current user can consume a credit voucher, based on the voucher entity. * diff --git a/inc/ticket.class.php b/inc/ticket.class.php index 16aaea0..581c8d5 100644 --- a/inc/ticket.class.php +++ b/inc/ticket.class.php @@ -265,7 +265,7 @@ public static function showForTicket(Ticket $ticket) 'type_name' => self::getTypeName(2), 'creditentityclass' => PluginCreditEntity::class, 'form_url' => self::getFormUrl(), - 'conditions' => PluginCreditEntity::getActiveFilter(), + 'conditions' => PluginCreditEntity::getActiveFilterForTicket($ticket), 'canedit' => $canedit, 'ID' => $ID, 'entries' => $entries, @@ -327,6 +327,11 @@ public static function displayVoucherInTicketProcessingForm($params) return; } + // No voucher can be consumed for this ticket type in this entity + if (!PluginCreditEntity::hasUsableVoucherForTicket($ticket)) { + return; + } + $canedit = $ticket->canEdit($ticket->getID()); if ( in_array($ticket->fields['status'], Ticket::getSolvedStatusArray()) @@ -351,10 +356,14 @@ public static function displayVoucherInTicketProcessingForm($params) if ($canedit) { //get default value for ticket $default_credit = PluginCreditTicketConfig::getDefaultForTicket($ticket->getID(), $item->getType()); - if ($default_credit == 0) { + if ($default_credit == 0 || !PluginCreditEntity::canBeUsedForTicket($default_credit, $ticket)) { //get default value for entity $default_credit = PluginCreditEntityConfig::getDefaultForEntityAndType($ticket->getEntityID(), $item->getType()); } + if ($default_credit != 0 && !PluginCreditEntity::canBeUsedForTicket($default_credit, $ticket)) { + // Default voucher cannot be used for this ticket type + $default_credit = 0; + } if ($default_credit != 0) { $max = PluginCreditEntity::getMaximumConsumptionForCredit($default_credit); @@ -368,7 +377,7 @@ public static function displayVoucherInTicketProcessingForm($params) 'default_credit_max' => $max ?? 0, 'entity_id' => $ticket->getEntityID(), 'type_name' => self::getTypeName(2), - 'condition' => PluginCreditEntity::getActiveFilter(), + 'condition' => PluginCreditEntity::getActiveFilterForTicket($ticket), 'creditentityclass' => PluginCreditEntity::class, 'plugin_credit_geturl' => plugin_credit_geturl(), ]); @@ -496,6 +505,15 @@ public static function consumeVoucher(CommonDBTM $item) return; } + if (!$credit_entity->isUsableForTicketType((int) $ticket->fields['type'])) { + Session::addMessageAfterRedirect( + __s('This credit voucher cannot be used for this type of ticket', 'credit'), + true, + ERROR, + ); + return; + } + $quantity_sold = (int) $credit_entity->fields['quantity']; $quantity_consumed = $credit_ticket->getConsumedForCreditEntity($item->input['plugin_credit_entities_id']); $quantity_remaining = max(0, $quantity_sold - $quantity_consumed); diff --git a/inc/ticketconfig.class.php b/inc/ticketconfig.class.php index 1ce4d52..a9d92ad 100644 --- a/inc/ticketconfig.class.php +++ b/inc/ticketconfig.class.php @@ -125,7 +125,7 @@ public static function showForTicket(Ticket $ticket, bool $embed_in_ticket_form 'uncollapsed' => $uncollapsed ?? false, 'type_name' => self::getTypeName(), 'creditentitytype' => PluginCreditEntity::class, - 'condition' => PluginCreditEntity::getActiveFilter(), + 'condition' => PluginCreditEntity::getActiveFilterForTicket($ticket), 'entity_id' => $ticket->getEntityID(), 'ticket' => $ticket, 'ticket_config' => $ticket_config, diff --git a/templates/creditentity.hmtl.twig b/templates/creditentity.hmtl.twig index 1e97baa..2b016cc 100644 --- a/templates/creditentity.hmtl.twig +++ b/templates/creditentity.hmtl.twig @@ -109,6 +109,18 @@ 'unit': '%' } ) }} + + {{ fields.dropdownYesNo( + 'is_for_incidents', + 1, + __('Usable for incidents', 'credit'), + ) }} + + {{ fields.dropdownYesNo( + 'is_for_requests', + 1, + __('Usable for requests', 'credit'), + ) }}