Add customer rule product restrictions

This commit is contained in:
Jeppe B
2026-07-16 11:50:52 +02:00
parent 879dfcf79a
commit e1fb79d9b6
19 changed files with 1816 additions and 354 deletions
+211 -13
View File
@@ -3801,13 +3801,19 @@ paths:
schema:
$ref: '#/components/schemas/OrderItemCreate'
responses:
'201':
'200':
description: Order item added successfully
content:
application/json:
schema: {}
'400':
$ref: '#/components/responses/BadRequest'
description: Invalid order item or product blocked by an active customer rule
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
- $ref: '#/components/schemas/Error'
put:
tags:
- Order Items
@@ -11069,15 +11075,63 @@ paths:
$ref: '#/components/schemas/Permission'
# Customer Management Endpoints
/superuser/customer-rules/product-restrictions:
get:
tags: [Superuser, Users]
summary: List global customer-rule product restrictions
operationId: listCustomerRuleProductRestrictions
responses:
'200':
description: Rule collections and product catalog retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionListResponse'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/customer-rules/product-restrictions/{attribute}:
put:
tags: [Superuser, Users]
summary: Atomically replace one customer rule's product collections
operationId: replaceCustomerRuleProductRestriction
parameters:
- name: attribute
in: path
required: true
schema:
$ref: '#/components/schemas/CustomerProductImpactAttribute'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionUpdateRequest'
responses:
'200':
description: Rule configuration replaced
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionResponse'
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/customer/attributes:
get:
tags:
- Users
summary: Get customer attributes
description: Get custom attributes for a customer
description: Get custom attributes for a customer. Authenticated customer accounts may read their own attributes without list_customer_attributes.
operationId: getCustomerAttributes
parameters:
- name: customer_id
- name: customer_number
in: query
schema:
type: integer
- name: user_id
in: query
schema:
type: integer
@@ -11086,7 +11140,8 @@ paths:
description: Customer attributes retrieved successfully
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/CustomerAttributesResponse'
post:
tags:
- Users
@@ -11094,12 +11149,13 @@ paths:
description: Add a custom attribute to a customer
operationId: addCustomerAttribute
requestBody:
required: false
required: true
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
responses:
'201':
'200':
description: Customer attribute added successfully
content:
application/json:
@@ -11110,11 +11166,17 @@ paths:
summary: Delete customer attribute
description: Remove a custom attribute from a customer
operationId: deleteCustomerAttribute
requestBody:
required: false
content:
application/json:
schema: {}
parameters:
- name: user_id
in: query
schema: { type: integer }
- name: customer_number
in: query
schema: { type: integer }
- name: attribute
in: query
required: true
schema: { type: string }
responses:
'200':
description: Customer attribute deleted successfully
@@ -14052,6 +14114,142 @@ components:
type: integer
description: HTTP status code
CustomerProductImpactAttribute:
type: string
enum: [restrictAdditionalServices, restrictTankCleaning, restrictSpotFree, restrictInteriorCleaning, onlyTankCleaning]
CustomerRuleProductCollection:
type: object
required: [id, name, sort_order, product_ids]
properties:
id: { type: integer }
name: { type: string, minLength: 1, maxLength: 191 }
sort_order: { type: integer }
product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProductCollectionInput:
type: object
required: [name, sort_order, product_ids]
properties:
id: { type: integer, nullable: true }
name: { type: string, minLength: 1, maxLength: 191 }
sort_order: { type: integer }
product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProductRestriction:
type: object
required: [attribute, version, collections, disabled_product_ids]
properties:
attribute: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
version: { type: integer, minimum: 1 }
collections:
type: array
items: { $ref: '#/components/schemas/CustomerRuleProductCollection' }
disabled_product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProductRestrictionUpdateRequest:
type: object
required: [version, collections]
properties:
version: { type: integer, minimum: 1 }
collections:
type: array
items: { $ref: '#/components/schemas/CustomerRuleProductCollectionInput' }
CustomerRuleProduct:
type: object
required: [id, name, category_id, category_name, active]
properties:
id: { type: integer }
name: { type: string }
category_id: { type: integer }
category_name: { type: string }
active: { type: boolean }
CustomerRuleProductRestrictionListResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data:
type: object
required: [rules, products]
properties:
rules:
type: array
items: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
products:
type: array
items: { $ref: '#/components/schemas/CustomerRuleProduct' }
CustomerRuleProductRestrictionResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
CustomerAttribute:
type: object
required: [id, user_id, attribute, product_restriction]
properties:
id: { type: integer }
user_id: { type: integer }
attribute: { type: string }
created_at: { type: string, nullable: true }
product_restriction:
nullable: true
allOf:
- $ref: '#/components/schemas/CustomerRuleProductRestriction'
CustomerAttributesResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data:
type: array
items: { $ref: '#/components/schemas/CustomerAttribute' }
CustomerAttributeMutationRequest:
type: object
required: [attribute]
properties:
user_id: { type: integer }
customer_number: { type: integer }
attribute: { type: string }
anyOf:
- required: [user_id]
- required: [customer_number]
CustomerRuleProductRestrictedResponse:
type: object
required: [success, data]
properties:
success: { type: boolean, enum: [false] }
data:
type: object
required: [code, message, product_id, rules, collections]
properties:
code: { type: string, enum: [CUSTOMER_RULE_PRODUCT_RESTRICTED] }
message: { type: string }
product_id: { type: integer }
rules:
type: array
items: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
collections:
type: array
items: { type: integer }
DepartmentCustomerPricingUpdateRequest:
type: object
required:
@@ -6,9 +6,6 @@ use RuntimeException;
class customer_order_product_policy
{
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
@@ -19,80 +16,31 @@ class customer_order_product_policy
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
$customerNumber = self::loadOrderCustomerNumber($orderId);
if ($customerNumber === null) {
return null;
}
if ((int)($context['product_id'] ?? 0) < 1) {
return null;
$violation = (new customer_rule_product_restriction_service())
->violationForCustomerProduct($customerNumber, $productId);
return $violation === null ? null : (string)$violation['message'];
}
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
? self::ONLY_TANKCLEANING_MESSAGE
: null;
}
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
{
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
}
public static function isTankCleaningProductRow(array $row): bool
{
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private static function loadOrderProductContext(int $orderId, int $productId): ?array
private static function loadOrderCustomerNumber(int $orderId): ?int
{
global $db;
if ($orderId < 1 || $productId < 1) {
if ($orderId < 1) {
return null;
}
$sql = "
SELECT
o.id AS order_id,
o.customer_id AS customer_number,
p.id AS product_id,
p.name AS product_name,
p.category AS product_category,
c.name AS category_name,
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
FROM orders o
LEFT JOIN products p ON p.id = {$productId}
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN users u ON u.customer_number = o.customer_id
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
WHERE o.id = {$orderId}
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
LIMIT 1
";
$result = $db->query($sql);
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
$customerNumber = (int)($row['customer_id'] ?? 0);
return $customerNumber > 0 ? $customerNumber : null;
}
private static function rowMatchesProductTerms(array $row, array $terms): bool
{
$haystack = strtolower(trim(
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
));
foreach ($terms as $term) {
if ($term !== '' && str_contains($haystack, strtolower($term))) {
return true;
}
}
return false;
}
}
@@ -3,20 +3,13 @@
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
public const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES = ['tillægsydelser', 'tillaegsydelser'];
/**
* @return array{rule:string,message:string}|null
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
@@ -24,130 +17,17 @@ class customer_product_rule_service
if (!$order->exists()) {
return null;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
(int)$order->customer_id->value(),
$productId
);
if ($violation === null) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& self::isStandaloneAdditionalServiceRow([
'related_item_id' => $relatedItemId,
'product_category' => $categoryId,
'category_name' => $categoryName,
])) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
public static function isStandaloneAdditionalServiceRow(array $row): bool
{
if ((int)($row['related_item_id'] ?? 0) > 0) {
return false;
}
$categoryId = (int)($row['product_category'] ?? $row['category'] ?? $row['category_id'] ?? 0);
if ($categoryId === self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
return true;
}
$categoryName = mb_strtolower(trim((string)($row['category_name'] ?? $row['categoryName'] ?? '')));
return in_array($categoryName, self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES, true);
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
{
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
return true;
}
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
// Keep the singular key during the API migration for existing invoice
// and logging consumers while also returning every matching rule.
return ['rule' => (string)$violation['rules'][0]] + $violation;
}
}
@@ -0,0 +1,291 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
/**
* Additive schema and the one-time legacy-to-exact-product migration for
* customer-rule product restrictions.
*/
class customer_rule_product_restriction_schema_bootstrap
{
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
private static bool $initialized = false;
/** @var array<string, string> */
private const RULES = [
'restrictAdditionalServices' => 'Additional services',
'restrictTankCleaning' => 'Tank cleaning',
'restrictSpotFree' => 'SpotFree',
'restrictInteriorCleaning' => 'Interior cleaning',
'onlyTankCleaning' => 'Non-tank products',
];
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::createTables($db);
self::deduplicateCustomerAttributes($db);
self::seedLegacyProductSets($db);
self::$initialized = true;
}
private static function createTables(object $db): void
{
$statements = [
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
attribute VARCHAR(191) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (attribute)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
attribute VARCHAR(191) NOT NULL,
name VARCHAR(191) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
CONSTRAINT fk_customer_rule_collection_attribute
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
collection_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id, product_id),
KEY idx_customer_rule_collection_product (product_id, collection_id),
CONSTRAINT fk_customer_rule_collection_product_collection
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
migration_key VARCHAR(191) NOT NULL,
details_json LONGTEXT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (migration_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
actor_user_id INT UNSIGNED NULL,
attribute VARCHAR(191) NOT NULL,
old_version INT UNSIGNED NOT NULL,
new_version INT UNSIGNED NOT NULL,
changes_json LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($statements as $statement) {
if ($db->query($statement) === false) {
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
}
}
}
private static function deduplicateCustomerAttributes(object $db): void
{
if (!self::tableExists($db, 'customer_attributes')) {
return;
}
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
return;
}
if ($db->query(
'DELETE duplicate_row FROM customer_attributes duplicate_row
INNER JOIN customer_attributes keep_row
ON keep_row.user_id = duplicate_row.user_id
AND keep_row.attribute = duplicate_row.attribute
AND keep_row.id < duplicate_row.id'
) === false) {
throw new RuntimeException('Unable to deduplicate customer attributes');
}
if ($db->query(
'ALTER TABLE customer_attributes
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
) === false) {
throw new RuntimeException('Unable to enforce unique customer attributes');
}
}
private static function seedLegacyProductSets(object $db): void
{
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
return;
}
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
$existing = $db->query(
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
);
if ($existing && (int)$existing->num_rows > 0) {
return;
}
if ($db->query('START TRANSACTION') === false) {
throw new RuntimeException('Unable to start customer-rule product migration');
}
try {
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
) === false) {
throw new RuntimeException('Unable to claim customer-rule product migration');
}
if (self::affectedRows($db) === 0) {
$db->query('ROLLBACK');
return;
}
foreach (array_keys(self::RULES) as $attribute) {
$safeAttribute = self::escape($db, $attribute);
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
VALUES ('{$safeAttribute}', 1)"
) === false) {
throw new RuntimeException("Unable to initialize restriction {$attribute}");
}
}
$counts = [];
$seededProductIds = [];
foreach (self::RULES as $attribute => $collectionName) {
$safeAttribute = self::escape($db, $attribute);
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
) === false) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$collectionId = (int)$db->insert_id();
if ($collectionId < 1) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$predicate = self::legacyPredicate($db, $attribute);
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
? "(p.deleted_at IS NULL OR p.deleted_at = '')"
: '1 = 1';
$insert = $db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
SELECT {$collectionId}, p.id
FROM products p
LEFT JOIN categories c ON c.id = p.category
WHERE ({$activePredicate}) AND ({$predicate})"
);
if ($insert === false) {
throw new RuntimeException("Unable to seed products for {$attribute}");
}
$counts[$attribute] = self::affectedRows($db);
$seeded = $db->query(
"SELECT product_id FROM customer_rule_product_collection_products
WHERE collection_id = {$collectionId} ORDER BY product_id"
);
$seededProductIds[$attribute] = [];
if ($seeded) {
while ($row = $seeded->fetch_assoc()) {
$seededProductIds[$attribute][] = (int)$row['product_id'];
}
}
}
$details = self::escape($db, (string)json_encode([
'counts' => $counts,
'product_ids' => $seededProductIds,
'seeded_at' => gmdate(DATE_ATOM),
], JSON_UNESCAPED_SLASHES));
if ($db->query(
"UPDATE customer_rule_product_migrations
SET details_json = '{$details}', applied_at = NOW()
WHERE migration_key = '{$migrationKey}'"
) === false) {
throw new RuntimeException('Unable to record customer-rule product migration');
}
if ($db->query('COMMIT') === false) {
throw new RuntimeException('Unable to commit customer-rule product migration');
}
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
private static function legacyPredicate(object $db, string $attribute): string
{
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
return match ($attribute) {
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
: ''),
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
default => '0 = 1',
};
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = self::escape($db, $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function indexExists(object $db, string $table, string $index): bool
{
$safeTable = str_replace('`', '', $table);
$safeIndex = self::escape($db, $index);
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = self::escape($db, $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
private static function escape(object $db, string $value): string
{
return method_exists($db, 'escape_string')
? $db->escape_string($value)
: addslashes($value);
}
private static function affectedRows(object $db): int
{
if (method_exists($db, 'conn')) {
$connection = $db->conn();
return (int)($connection->affected_rows ?? 0);
}
return (int)($db->affected_rows ?? 0);
}
}
@@ -0,0 +1,508 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class customer_rule_product_restriction_exception extends RuntimeException
{
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
{
parent::__construct($message);
$this->restrictionCode = $code;
}
private string $restrictionCode;
public function httpStatus(): int
{
return $this->httpStatus;
}
public function restrictionCode(): string
{
return $this->restrictionCode;
}
}
/**
* Source of truth for globally configured customer-rule product collections.
*/
class customer_rule_product_restriction_service
{
/** @var list<string> */
public const PRODUCT_IMPACT_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
];
/** @var list<string> */
public const SUPPORTED_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
'requiresReferenceNumber',
'requiresRegistrationNumbersInvoice',
'invoiceAllOrdersIndividually',
'invoiceWithStripe',
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
];
public function __construct()
{
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
}
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
public function listConfiguration(): array
{
return [
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
'products' => $this->productCatalog(),
];
}
/** @return array<string,mixed> */
public function ruleConfiguration(string $attribute): array
{
$this->assertSupportedAttribute($attribute);
global $db;
$safeAttribute = $this->escape($attribute);
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
}
$versionRow = $versionResult->fetch_assoc();
$result = $db->query(
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
FROM customer_rule_product_collections c
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE c.attribute = '{$safeAttribute}'
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
);
if (!$result) {
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
}
$collections = [];
$disabled = [];
while ($row = $result->fetch_assoc()) {
$collectionId = (int)$row['collection_id'];
if (!isset($collections[$collectionId])) {
$collections[$collectionId] = [
'id' => $collectionId,
'name' => (string)$row['name'],
'sort_order' => (int)$row['sort_order'],
'product_ids' => [],
];
}
if ($row['product_id'] !== null) {
$productId = (int)$row['product_id'];
$collections[$collectionId]['product_ids'][] = $productId;
$disabled[$productId] = true;
}
}
return [
'attribute' => $attribute,
'version' => max(1, (int)($versionRow['version'] ?? 1)),
'collections' => array_values($collections),
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
{
$this->assertSupportedAttribute($attribute);
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
global $db;
$safeAttribute = $this->escape($attribute);
if ($db->query('START TRANSACTION') === false) {
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
try {
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
}
$versionRow = $versionResult->fetch_assoc();
$currentVersion = (int)$versionRow['version'];
if ($currentVersion !== $expectedVersion) {
throw new customer_rule_product_restriction_exception(
'Customer rule configuration has changed; reload before saving',
409,
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
);
}
$old = $this->ruleConfiguration($attribute);
$existingIds = $this->existingCollectionIds($attribute);
foreach ($collections as $collection) {
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
}
}
// Avoid temporary unique-name collisions while two collections swap names.
foreach ($existingIds as $collectionId => $_) {
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$temporaryName}'
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$keptIds = [];
foreach ($collections as $collection) {
$name = $this->escape($collection['name']);
$sortOrder = (int)$collection['sort_order'];
$collectionId = $collection['id'];
if ($collectionId === null) {
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to create collection');
}
$collectionId = (int)$db->insert_id();
} else {
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$name}', sort_order = {$sortOrder}
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update collection');
}
}
$keptIds[$collectionId] = true;
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
foreach ($collection['product_ids'] as $productId) {
if ($db->query(
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to save collection products');
}
}
}
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
if ($removeIds !== []) {
if ($db->query(
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
implode(',', array_map('intval', $removeIds)) . ')'
) === false) {
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$newVersion = $currentVersion + 1;
if ($db->query(
"UPDATE customer_rule_product_restrictions
SET version = {$newVersion}, updated_at = NOW()
WHERE attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
$new = $this->ruleConfiguration($attribute);
$changes = $this->escape((string)json_encode([
'before' => $old,
'after' => $new,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if ($db->query(
"INSERT INTO customer_rule_product_audit_logs
(actor_user_id, attribute, old_version, new_version, changes_json)
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
if ($db->query('COMMIT') === false) {
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
return $new;
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
/**
* Return configured product restrictions for all active product-impact
* attributes belonging to any account with the customer number.
*
* @return list<array<string,mixed>>
*/
public function restrictionsForCustomerNumber(int $customerNumber): array
{
if ($customerNumber < 1) {
return [];
}
global $db;
$result = $db->query(
"SELECT DISTINCT ca.attribute
FROM users u
INNER JOIN customer_attributes ca ON ca.user_id = u.id
WHERE u.customer_number = {$customerNumber}"
);
if (!$result) {
throw new RuntimeException('Unable to load active customer-rule product restrictions');
}
$activeAttributes = [];
while ($row = $result->fetch_assoc()) {
$attribute = (string)$row['attribute'];
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$activeAttributes[$attribute] = true;
}
}
$active = [];
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
if (isset($activeAttributes[$attribute])) {
$active[] = $this->ruleConfiguration($attribute);
}
}
return $active;
}
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
{
if ($productId < 1) {
return null;
}
$rules = [];
$collections = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
continue;
}
$rules[] = (string)$restriction['attribute'];
foreach ($restriction['collections'] as $collection) {
if (in_array($productId, $collection['product_ids'], true)) {
$collections[] = (int)$collection['id'];
}
}
}
if ($rules === []) {
return null;
}
return [
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
'message' => customer_product_rule_service::BLOCK_MESSAGE,
'product_id' => $productId,
'rules' => array_values(array_unique($rules)),
'collections' => array_values(array_unique($collections)),
];
}
/**
* @param list<array<string,mixed>> $attributes
* @return list<array<string,mixed>>
*/
public function enrichAttributes(int $customerNumber, array $attributes): array
{
$restrictions = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
$restrictions[(string)$restriction['attribute']] = [
'attribute' => (string)$restriction['attribute'],
'version' => (int)$restriction['version'],
'collections' => $restriction['collections'],
'disabled_product_ids' => $restriction['disabled_product_ids'],
];
}
foreach ($attributes as &$attribute) {
$key = (string)($attribute['attribute'] ?? '');
$attribute['product_restriction'] = $restrictions[$key] ?? null;
}
unset($attribute);
return $attributes;
}
/** @return list<array<string,mixed>> */
private function productCatalog(): array
{
global $db;
$activeExpression = $this->columnExists('products', 'deleted_at')
? "CASE WHEN p.deleted_at IS NULL OR p.deleted_at = '' THEN 1 ELSE 0 END"
: '1';
$result = $db->query(
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
{$activeExpression} AS active
FROM products p
LEFT JOIN categories c ON c.id = p.category
ORDER BY c.name ASC, p.name ASC, p.id ASC"
);
if (!$result) {
throw new RuntimeException('Unable to load the customer-rule product catalog');
}
$products = [];
while ($row = $result->fetch_assoc()) {
$products[] = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'category_id' => (int)$row['category_id'],
'category_name' => (string)($row['category_name'] ?? ''),
'active' => (bool)$row['active'],
];
}
return $products;
}
/**
* @return array<int,true>
*/
private function existingCollectionIds(string $attribute): array
{
global $db;
$safeAttribute = $this->escape($attribute);
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
if (!$result) {
throw new RuntimeException("Unable to load existing collections for {$attribute}");
}
$ids = [];
while ($row = $result->fetch_assoc()) {
$ids[(int)$row['id']] = true;
}
return $ids;
}
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
private function validateCollections(string $attribute, mixed $value): array
{
if (!is_array($value)) {
throw new customer_rule_product_restriction_exception('collections must be an array');
}
$normalized = [];
$names = [];
$collectionIds = [];
$allProductIds = [];
foreach (array_values($value) as $index => $collection) {
if (!is_array($collection)) {
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
}
$name = trim((string)($collection['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 191) {
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
}
$nameKey = mb_strtolower($name);
if (isset($names[$nameKey])) {
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
}
$names[$nameKey] = true;
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
throw new customer_rule_product_restriction_exception('product_ids must be an array');
}
$productIds = [];
foreach ($collection['product_ids'] as $productId) {
$id = $this->positiveInt($productId, 'product_id');
$productIds[$id] = true;
$allProductIds[$id] = true;
}
$id = isset($collection['id']) && $collection['id'] !== null
? $this->positiveInt($collection['id'], 'collection id')
: null;
if ($id !== null && isset($collectionIds[$id])) {
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
}
if ($id !== null) {
$collectionIds[$id] = true;
}
$normalized[] = [
'id' => $id,
'name' => $name,
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
? (int)$collection['sort_order']
: $index,
'product_ids' => array_map('intval', array_keys($productIds)),
];
}
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
return $normalized;
}
/** @param list<int> $productIds */
private function assertProductsExist(array $productIds): void
{
if ($productIds === []) {
return;
}
global $db;
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
if (!$result) {
throw new customer_rule_product_restriction_exception(
'Unable to validate collection products',
500,
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
);
}
$found = [];
while ($row = $result->fetch_assoc()) {
$found[(int)$row['id']] = true;
}
$missing = array_values(array_diff($productIds, array_keys($found)));
if ($missing !== []) {
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
}
}
private function assertSupportedAttribute(string $attribute): void
{
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
}
}
private function positiveInt(mixed $value, string $field): int
{
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
}
return (int)$value;
}
private function escape(string $value): string
{
global $db;
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
}
private function columnExists(string $table, string $column): bool
{
global $db;
$safeTable = str_replace('`', '', $table);
$safeColumn = $this->escape($column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
@@ -22,7 +22,6 @@ class invoice_period_flag_service
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = [];
@@ -848,11 +847,16 @@ class invoice_period_flag_service
{
global $db;
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
$result = $db->query(
"SELECT u.customer_number, ca.attribute
"SELECT u.customer_number, ca.attribute, cp.product_id
FROM customer_attributes ca
JOIN users u ON u.id = ca.user_id
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE 1=1 {$customerFilter}"
);
@@ -863,7 +867,11 @@ class invoice_period_flag_service
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
$attributes[$customerNumber][(string)$row['attribute']] = true;
$attribute = (string)$row['attribute'];
$attributes[$customerNumber][$attribute] = true;
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
}
}
return $attributes;
@@ -874,6 +882,8 @@ class invoice_period_flag_service
$flags = [];
$orders = [];
$collectionOrders = [];
$matchingRules = static fn(int $customerNumber, int $productId): array =>
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
foreach ($rows as $row) {
$customerNumber = (int)$row['customer_number'];
@@ -899,57 +909,17 @@ class invoice_period_flag_service
continue;
}
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
&& customer_product_rule_service::isStandaloneAdditionalServiceRow($row)
&& (int)($row['item_price'] ?? 0) > 0) {
$flags[] = $this->automaticFlag(
'customer_rule_restrict_addon_services',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
$flags[] = $this->automaticFlag(
'customer_rule_restrict_tank_cleaning',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
$flags[] = $this->automaticFlag(
'customer_rule_only_tank_cleaning',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
$restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
$productRuleDefinitions = [
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
];
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
&& $this->rowMatchesProductTerms($row, $terms)) {
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
if (isset($productRuleDefinitions[$attribute])) {
$flags[] = $this->automaticFlag(
$definitionKey,
$productRuleDefinitions[$attribute],
'order_item',
(int)$row['order_item_id'],
null,
@@ -959,6 +929,19 @@ class invoice_period_flag_service
);
}
}
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
$flags[] = $this->automaticFlag(
'customer_rule_exempt_from_administration_fees',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
}
foreach ($orders as $orderId => $row) {
@@ -2098,11 +2081,6 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool
{
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
return true;
}
$haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
@@ -2115,11 +2093,6 @@ class invoice_period_flag_service
return false;
}
private function rowIsTankCleaningProduct(array $row): bool
{
return customer_order_product_policy::isTankCleaningProductRow($row);
}
private function isIncludedOrderItem(array $row): bool
{
$value = $row['item_include_in_invoice'] ?? 1;
+45 -28
View File
@@ -3,6 +3,7 @@
namespace objects;
use classes\db;
use classes\customer_rule_product_restriction_schema_bootstrap;
use classes\customer_name_cache_payload_builder;
use classes\object_property;
use classes\redis;
@@ -268,15 +269,19 @@ class users_o extends db
public function addAttribute(string $attribute, ?int $user_id = null): void
{
global $db;
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
if ($user_id === null) {
self::requireSelected();
$user_id = $this->id;
}
$attribute = $db->escape_string($attribute);
// To prevent two attributes with the same name for the same user, we will delete the old one if it exists
$this->deleteAttribute($attribute, $user_id);
$sql = "INSERT INTO customer_attributes (user_id, attribute) VALUES ($user_id, '$attribute')";
$db->query($sql);
$userIds = $this->attributeSiblingUserIds((int)$user_id);
foreach ($userIds as $targetUserId) {
$db->query(
"INSERT IGNORE INTO customer_attributes (user_id, attribute)
VALUES ({$targetUserId}, '{$attribute}')"
);
}
}
public function deleteAttribute(string $attribute, ?int $user_id = null): void
@@ -286,29 +291,9 @@ class users_o extends db
$user_id = $this->id;
}
$attribute = $db->escape_string($attribute);
// If the user has a customer number, we will delete the attribute from all associated customers
$customer_number = $this->customer_number->value();
if (!empty($customer_number)) {
$sql = "SELECT * FROM users WHERE customer_number = $customer_number";
$result = $db->query($sql);
$user_ids = [];
while ($row = $result->fetch_assoc()) {
$user_ids[] = (int)$row['id'];
foreach ($this->attributeSiblingUserIds((int)$user_id) as $targetUserId) {
$db->query("DELETE FROM customer_attributes WHERE user_id = {$targetUserId} AND attribute = '{$attribute}'");
}
// Delete the attribute from all users
foreach ($user_ids as $user_id) {
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
$db->query($sql);
}
return;
}
// Make sure the attribute exists
if (!$this->doesUserHaveAttribute((string)$attribute, (int)$user_id)) {
return;
}
// If the attribute exists, delete it, if it does not exist, nothing will happen
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
$db->query($sql);
}
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
@@ -318,7 +303,8 @@ class users_o extends db
$user_id = $this->id;
}
$attribute = $db->escape_string($attribute);
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
$userIds = $this->attributeSiblingUserIds((int)$user_id);
$sql = "SELECT id FROM customer_attributes WHERE user_id IN (" . implode(',', $userIds) . ") AND attribute = '$attribute' LIMIT 1";
$result = $db->query($sql);
if ($result->num_rows > 0) {
return true;
@@ -736,13 +722,44 @@ class users_o extends db
if ($user_id === null) {
$user_id = $this->id;
}
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id";
$userIds = $this->attributeSiblingUserIds((int)$user_id);
$sql = "SELECT MIN(id) AS id, MIN(user_id) AS user_id, attribute, MIN(created_at) AS created_at
FROM customer_attributes
WHERE user_id IN (" . implode(',', $userIds) . ")
GROUP BY attribute
ORDER BY attribute";
$result = $db->query($sql);
$array = $db->fetch_all($result);
$this->attributes = $array;
return $this->attributes;
}
/** @return list<int> */
private function attributeSiblingUserIds(int $userId): array
{
global $db;
if ($userId < 1) {
return [$userId];
}
$result = $db->query("SELECT customer_number FROM users WHERE id = {$userId} LIMIT 1");
if (!$result || $result->num_rows < 1) {
return [$userId];
}
$row = $result->fetch_assoc();
$customerNumber = (int)($row['customer_number'] ?? 0);
if ($customerNumber < 1) {
return [$userId];
}
$siblings = $db->query("SELECT id FROM users WHERE customer_number = {$customerNumber}");
$ids = [];
if ($siblings) {
while ($sibling = $siblings->fetch_assoc()) {
$ids[] = (int)$sibling['id'];
}
}
return $ids !== [] ? array_values(array_unique($ids)) : [$userId];
}
/**
* Get all discounts for the user (Include)
* @return void
+221 -11
View File
@@ -4130,13 +4130,19 @@ paths:
schema:
$ref: '#/components/schemas/OrderItemCreate'
responses:
'201':
'200':
description: Order item added successfully
content:
application/json:
schema: {}
'400':
$ref: '#/components/responses/BadRequest'
description: Invalid order item or product blocked by an active customer rule
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
- $ref: '#/components/schemas/Error'
put:
tags:
- Order Items
@@ -11740,6 +11746,50 @@ paths:
$ref: '#/components/schemas/Permission'
# Customer Management Endpoints
/superuser/customer-rules/product-restrictions:
get:
tags: [Superuser, Users]
summary: List global customer-rule product restrictions
operationId: listCustomerRuleProductRestrictions
responses:
'200':
description: Rule collections and product catalog retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionListResponse'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/superuser/customer-rules/product-restrictions/{attribute}:
put:
tags: [Superuser, Users]
summary: Atomically replace one customer rule's product collections
operationId: replaceCustomerRuleProductRestriction
parameters:
- name: attribute
in: path
required: true
schema:
$ref: '#/components/schemas/CustomerProductImpactAttribute'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionUpdateRequest'
responses:
'200':
description: Rule configuration replaced
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerRuleProductRestrictionResponse'
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/customer/attributes:
get:
tags:
@@ -11752,12 +11802,17 @@ paths:
in: query
schema:
type: integer
- name: user_id
in: query
schema:
type: integer
responses:
'200':
description: Customer attributes retrieved successfully
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/CustomerAttributesResponse'
post:
tags:
- Users
@@ -11765,12 +11820,13 @@ paths:
description: Add a custom attribute to a customer
operationId: addCustomerAttribute
requestBody:
required: false
required: true
content:
application/json:
schema: {}
schema:
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
responses:
'201':
'200':
description: Customer attribute added successfully
content:
application/json:
@@ -11781,11 +11837,17 @@ paths:
summary: Delete customer attribute
description: Remove a custom attribute from a customer
operationId: deleteCustomerAttribute
requestBody:
required: false
content:
application/json:
schema: {}
parameters:
- name: user_id
in: query
schema: { type: integer }
- name: customer_number
in: query
schema: { type: integer }
- name: attribute
in: query
required: true
schema: { type: string }
responses:
'200':
description: Customer attribute deleted successfully
@@ -14968,6 +15030,154 @@ components:
type: object
additionalProperties: true
CustomerProductImpactAttribute:
type: string
enum:
- restrictAdditionalServices
- restrictTankCleaning
- restrictSpotFree
- restrictInteriorCleaning
- onlyTankCleaning
CustomerRuleProductCollection:
type: object
required: [id, name, sort_order, product_ids]
properties:
id: { type: integer }
name: { type: string, minLength: 1, maxLength: 191 }
sort_order: { type: integer }
product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProductRestriction:
type: object
required: [attribute, version, collections, disabled_product_ids]
properties:
attribute:
$ref: '#/components/schemas/CustomerProductImpactAttribute'
version: { type: integer, minimum: 1 }
collections:
type: array
items:
$ref: '#/components/schemas/CustomerRuleProductCollection'
disabled_product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProductRestrictionUpdateRequest:
type: object
required: [version, collections]
properties:
version: { type: integer, minimum: 1 }
collections:
type: array
items:
$ref: '#/components/schemas/CustomerRuleProductCollectionInput'
CustomerRuleProductCollectionInput:
type: object
required: [name, sort_order, product_ids]
properties:
id: { type: integer, nullable: true }
name: { type: string, minLength: 1, maxLength: 191 }
sort_order: { type: integer }
product_ids:
type: array
uniqueItems: true
items: { type: integer }
CustomerRuleProduct:
type: object
required: [id, name, category_id, category_name, active]
properties:
id: { type: integer }
name: { type: string }
category_id: { type: integer }
category_name: { type: string }
active: { type: boolean }
CustomerRuleProductRestrictionListResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data:
type: object
required: [rules, products]
properties:
rules:
type: array
items:
$ref: '#/components/schemas/CustomerRuleProductRestriction'
products:
type: array
items:
$ref: '#/components/schemas/CustomerRuleProduct'
CustomerRuleProductRestrictionResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data:
$ref: '#/components/schemas/CustomerRuleProductRestriction'
CustomerAttribute:
type: object
required: [id, user_id, attribute, product_restriction]
properties:
id: { type: integer }
user_id: { type: integer }
attribute: { type: string }
created_at: { type: string, nullable: true }
product_restriction:
nullable: true
allOf:
- $ref: '#/components/schemas/CustomerRuleProductRestriction'
CustomerAttributesResponse:
type: object
required: [success, data]
properties:
success: { type: boolean }
data:
type: array
items:
$ref: '#/components/schemas/CustomerAttribute'
CustomerAttributeMutationRequest:
type: object
required: [attribute]
properties:
user_id: { type: integer }
customer_number: { type: integer }
attribute: { type: string }
anyOf:
- required: [user_id]
- required: [customer_number]
CustomerRuleProductRestrictedResponse:
type: object
required: [success, data]
properties:
success: { type: boolean, enum: [false] }
data:
type: object
required: [code, message, product_id, rules, collections]
properties:
code: { type: string, enum: [CUSTOMER_RULE_PRODUCT_RESTRICTED] }
message: { type: string }
product_id: { type: integer }
rules:
type: array
items: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
collections:
type: array
items: { type: integer }
SuperuserSecuritySummaryResponse:
type: object
properties:
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\customer_rule_product_restriction_service;
use objects\logs_o;
use objects\users_o;
use traits\route_t;
@@ -44,9 +45,10 @@ class customerAttributes
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
// Return the list of customer notes
$response->success(
$response->success((new customer_rule_product_restriction_service())->enrichAttributes(
(int)$target_user->customer_number->value(),
$target_user->getUserAttributes()
);
));
} else {
// Log the incident
(new logs_o())->add('customer_attributes', 'global', 1, 0, 'LIST_CUSTOMER_ATTRIBUTES', 'No user found, or invalid session');
@@ -76,6 +78,9 @@ class customerAttributes
if (!isset($data['attribute'])) {
$response->error('Attribute is required', 400);
}
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
$response->error('Unsupported customer attribute', 422);
}
// Add the note to the customer
(new users_o())->automaticGetTargetUserFromRequest()->addAttribute((string)$data['attribute']);
// Log the incident
@@ -111,6 +116,9 @@ class customerAttributes
if (!isset($data['attribute'])) {
$response->error('Attribute is required', 400);
}
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
$response->error('Unsupported customer attribute', 422);
}
// Check if the user exists
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
$response->error('Customer not found', 400);
@@ -92,7 +92,13 @@ class orderItemsRoute
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
);
$response->error($customerRuleViolation['message'], 400);
$response->error([
'code' => $customerRuleViolation['code'],
'message' => $customerRuleViolation['message'],
'product_id' => $customerRuleViolation['product_id'],
'rules' => $customerRuleViolation['rules'],
'collections' => $customerRuleViolation['collections'],
], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
@@ -0,0 +1,86 @@
<?php
namespace routes;
use classes\authentication;
use classes\customer_rule_product_restriction_exception;
use classes\customer_rule_product_restriction_service;
use Throwable;
use traits\route_t;
class superuserCustomerRuleProductRestrictionsRoute
{
use route_t;
public function run(): void
{
$this->get('/superuser/customer-rules/product-restrictions', function () {
global $response;
$this->requireClassicSuperuserAnyPermission([
'superuser_customer_rules_view',
'superuser_customer_rules_manage',
]);
$response->success((new customer_rule_product_restriction_service())->listConfiguration());
}, [
'superuser_customer_rules_view' => 'View global customer-rule product restrictions',
]);
$this->put('/superuser/customer-rules/product-restrictions/{attribute}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_customer_rules_manage');
$attribute = trim((string)$this->fromRoute('attribute'));
try {
$response->success((new customer_rule_product_restriction_service())->replaceRuleConfiguration(
$attribute,
$this->getParametersAsArray(),
$this->actorUserId()
));
} catch (customer_rule_product_restriction_exception $exception) {
$response->error([
'code' => $exception->restrictionCode(),
'message' => $exception->getMessage(),
], $exception->httpStatus());
} catch (Throwable $throwable) {
error_log('[customer_rule_product_restrictions] save failed: ' . $throwable->getMessage());
$response->error([
'code' => 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED',
'message' => 'Unable to save customer rule configuration',
], 500);
}
}, [
'superuser_customer_rules_manage' => 'Manage global customer-rule product restrictions',
]);
}
private function requireClassicSuperuserPermission(string $permission): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage customer rules.', 403);
}
$this->requirePermission('superuser');
return $this->requirePermission($permission);
}
/** @param list<string> $permissions */
private function requireClassicSuperuserAnyPermission(array $permissions): bool
{
global $response;
if ((new authentication())->get_subuser() !== false) {
$response->error('Subuser sessions cannot manage customer rules.', 403);
}
$this->requirePermission('superuser');
foreach ($permissions as $permission) {
if ($this->hasPermission($permission)) {
return true;
}
}
return $this->requirePermission($permissions[0]);
}
private function actorUserId(): int
{
$user = (new authentication())->get_user();
return $user !== false && $user->exists() ? (int)$user->id : 0;
}
}
@@ -16,6 +16,25 @@ function bulk_action_order_invoice_collection_id(int $orderId): int
return (int)($row['invoice_collection_id'] ?? 0);
}
function bulk_action_configure_rule_product(string $attribute, int $productId): void
{
new \classes\customer_rule_product_restriction_service();
$db = api_test_runtime()->db();
$safeAttribute = $db->real_escape_string($attribute);
$result = $db->query(
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
);
$collectionId = (int)$result->fetch_assoc()['id'];
$db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
);
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
'collection_id' => $collectionId,
'product_id' => $productId,
]);
}
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
@@ -35,6 +54,7 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
'name' => 'Spot Free rinse',
'price' => 80,
]);
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
$orderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $product['id'],
@@ -120,6 +140,8 @@ it('previews and applies customer rule cleanup for both spotfree addon products'
'category' => 4,
'price' => 39,
]);
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeVanProduct['id']);
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeTruckProduct['id']);
$vanOrderItem = api_fixtures()->createOrderItem([
'order_id' => $order['id'],
'product_id' => $spotfreeVanProduct['id'],
@@ -72,3 +72,78 @@ it('still lets attribute managers read another customer attributes', function ()
expect($attributes)->toContain('onlyTankCleaning');
});
it('returns exact product restrictions for product-impact attributes and null for workflow attributes', function (): void {
api_test_covers('GET /customer/attributes', 'product_restrictions');
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
$customer = api_fixtures()->createUser(['display_name' => 'Configured Attribute Customer']);
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'exemptFromAdministrationFee');
$product = api_fixtures()->createProduct(['name' => 'Configured exact rinse']);
new \classes\customer_rule_product_restriction_service();
$db = api_test_runtime()->db();
$collectionResult = $db->query(
"SELECT id FROM customer_rule_product_collections
WHERE attribute = 'restrictSpotFree' ORDER BY sort_order, id LIMIT 1"
);
$collectionId = (int)$collectionResult->fetch_assoc()['id'];
$db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, " . (int)$product['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
'collection_id' => $collectionId,
'product_id' => (int)$product['id'],
]);
$response = api_client()->get(
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
$session['headers']
);
$response->assertStatus(200)->assertEnvelope()->assertSuccess();
$byAttribute = [];
foreach ($response->data() as $attribute) {
$byAttribute[(string)$attribute['attribute']] = $attribute;
}
expect($byAttribute['restrictSpotFree']['product_restriction']['disabled_product_ids'] ?? [])
->toContain((int)$product['id'])
->and($byAttribute['restrictSpotFree']['product_restriction']['collections'] ?? [])->not->toBeEmpty()
->and($byAttribute['exemptFromAdministrationFee']['product_restriction'] ?? 'missing')->toBeNull();
});
it('keeps workflow-only customer attribute activation compatible', function (): void {
api_test_covers('POST /customer/attributes', 'workflow_compatibility');
api_test_covers('DELETE /customer/attributes', 'workflow_compatibility');
$session = api_fixtures()->createUserSession([
'list_customer_attributes',
'add_customer_attribute',
'delete_customer_attribute',
]);
$customer = api_fixtures()->createUser(['display_name' => 'Workflow Attribute Customer']);
api_client()->post('/customer/attributes', [
'user_id' => (int)$customer['id'],
'attribute' => 'exemptFromAdministrationFee',
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$listed = api_client()->get(
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
$session['headers']
);
expect(array_column($listed->data(), 'attribute'))->toContain('exemptFromAdministrationFee');
api_client()->delete(
'/customer/attributes?user_id=' . (int)$customer['id'] . '&attribute=exemptFromAdministrationFee',
$session['headers']
)
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('requires classic superuser view and manage permissions for global rule configuration', function (): void {
api_test_covers('GET /superuser/customer-rules/product-restrictions', 'permissions');
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'permissions');
$withoutView = api_fixtures()->createUserSession(['superuser']);
api_client()->get('/superuser/customer-rules/product-restrictions', $withoutView['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser_customer_rules_view']);
$viewOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_view']);
$configuration = api_client()->get('/superuser/customer-rules/product-restrictions', $viewOnly['headers']);
$configuration->assertStatus(200)->assertEnvelope()->assertSuccess();
expect($configuration->data()['rules'] ?? [])->toHaveCount(5)
->and($configuration->data()['products'] ?? null)->toBeArray();
$rule = $configuration->data()['rules'][0];
api_client()->put(
'/superuser/customer-rules/product-restrictions/' . $rule['attribute'],
['version' => $rule['version'], 'collections' => $rule['collections']],
$viewOnly['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser_customer_rules_manage']);
$manageOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_manage']);
api_client()->get('/superuser/customer-rules/product-restrictions', $manageOnly['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
});
it('atomically replaces collections and rejects stale versions and invalid products', function (): void {
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'versioned_atomic_replace');
$session = api_fixtures()->createUserSession([
'superuser',
'superuser_customer_rules_view',
'superuser_customer_rules_manage',
]);
$product = api_fixtures()->createProduct(['name' => 'Managed exact customer-rule product']);
$get = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
$rules = $get->data()['rules'];
$rule = array_values(array_filter(
$rules,
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
))[0];
$collectionName = 'API managed ' . bin2hex(random_bytes(5));
$collections = $rule['collections'];
$collections[] = [
'name' => $collectionName,
'sort_order' => 999,
'product_ids' => [(int)$product['id']],
];
$saved = api_client()->put(
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
['version' => $rule['version'], 'collections' => $collections],
$session['headers']
);
$saved->assertStatus(200)->assertEnvelope()->assertSuccess();
expect($saved->data()['version'])->toBe((int)$rule['version'] + 1)
->and($saved->data()['disabled_product_ids'])->toContain((int)$product['id']);
$managedCollection = array_values(array_filter(
$saved->data()['collections'],
static fn(array $collection): bool => $collection['name'] === $collectionName
))[0];
$collectionId = (int)$managedCollection['id'];
try {
api_client()->put(
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
['version' => $rule['version'], 'collections' => $collections],
$session['headers']
)
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Customer rule configuration has changed; reload before saving');
$invalidCollections = $saved->data()['collections'];
$invalidCollections[0]['product_ids'][] = 2147483647;
api_client()->put(
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
['version' => $saved->data()['version'], 'collections' => $invalidCollections],
$session['headers']
)
->assertStatus(422)
->assertEnvelope()
->assertSuccess(false);
$unchanged = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
$current = array_values(array_filter(
$unchanged->data()['rules'],
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
))[0];
expect($current['version'])->toBe($saved->data()['version'])
->and($current['disabled_product_ids'])->not->toContain(2147483647);
} finally {
$db = api_test_runtime()->db();
$db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}");
$db->query("DELETE FROM customer_rule_product_collections WHERE id = {$collectionId}");
$db->query(
"UPDATE customer_rule_product_restrictions SET version = " . (int)$rule['version'] .
" WHERE attribute = 'restrictInteriorCleaning'"
);
}
});
@@ -36,6 +36,33 @@ function post_order_item(array $order, array $product, array $headers, array $ov
], $overrides), $headers);
}
function configure_customer_rule_product(string $attribute, int $productId): void
{
new \classes\customer_rule_product_restriction_service();
$db = api_test_runtime()->db();
$safeAttribute = $db->real_escape_string($attribute);
$result = $db->query(
"SELECT id FROM customer_rule_product_collections
WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
);
$collectionId = $result && $result->num_rows > 0 ? (int)$result->fetch_assoc()['id'] : 0;
if ($collectionId < 1) {
$db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', 'API exact restriction', 0)"
);
$collectionId = (int)$db->insert_id;
}
$db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
);
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
'collection_id' => $collectionId,
'product_id' => $productId,
]);
}
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
{
$statement = api_test_runtime()->db()->prepare(
@@ -198,6 +225,7 @@ it('only allows tankcleaning products for only tankcleaning customers', function
'category' => 5,
]);
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
configure_customer_rule_product('onlyTankCleaning', (int)$washProduct['id']);
api_client()
->post('/order/items', [
@@ -330,6 +358,7 @@ it('blocks standalone category 8 products for customers restricted from addition
'category' => 8,
'price' => 50,
]);
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
@@ -370,7 +399,7 @@ it('allows standalone category 8 products when the customer is not restricted fr
->assertSuccess();
});
it('allows related addon order items for customers restricted from additional services', function (): void {
it('blocks configured related addon order items for customers restricted from additional services', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
@@ -390,13 +419,15 @@ it('allows related addon order items for customers restricted from additional se
'cashier_id' => $cashier['id'],
'price' => 200,
]);
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
])
->assertStatus(200)
->assertStatus(400)
->assertEnvelope()
->assertSuccess();
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('still blocks related add-ons covered by a specific customer product rule', function (): void {
@@ -412,6 +443,7 @@ it('still blocks related add-ons covered by a specific customer product rule', f
'cashier_id' => $cashier['id'],
'price' => 200,
]);
configure_customer_rule_product('restrictInteriorCleaning', (int)$interiorProduct['id']);
post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [
'related_item_id' => $primaryItem['id'],
@@ -448,7 +480,7 @@ it('does not infer additional-service restrictions from category 4, product name
->assertSuccess();
});
it('uses the exact Tillægsydelser category name as the legacy additional-service fallback', function (): void {
it('does not infer additional-service restrictions from the legacy Tillægsydelser category after migration', function (): void {
api_test_covers('POST /order/items', 'customer-rule-validation');
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
@@ -460,10 +492,9 @@ it('uses the exact Tillægsydelser category name as the legacy additional-servic
]);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
->assertStatus(200)
->assertEnvelope()
->assertSuccess(false)
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
->assertSuccess();
});
it('validates that related order items exist, are active, and belong to the target order', function (): void {
@@ -530,6 +561,7 @@ it('blocks named restricted service products for the selected customer', functio
$fixture = create_order_item_rule_fixture([$attribute]);
$product = api_fixtures()->createProduct($productAttributes);
configure_customer_rule_product($attribute, (int)$product['id']);
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
->assertStatus(400)
@@ -555,6 +587,7 @@ it('only allows tank cleaning products when the customer has the only tank clean
'category' => 5,
'price' => 300,
]);
configure_customer_rule_product('onlyTankCleaning', (int)$nonTankProduct['id']);
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
->assertStatus(400)
@@ -899,6 +899,58 @@ CREATE TABLE IF NOT EXISTS `customer_attributes` (
KEY `idx_customer_attributes_attribute` (`attribute`),
KEY `idx_customer_attributes_attribute_user` (`attribute`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'customer_rule_product_restrictions' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_rule_product_restrictions` (
`attribute` VARCHAR(191) NOT NULL,
`version` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`attribute`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'customer_rule_product_collections' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_rule_product_collections` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`attribute` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_customer_rule_collection_name` (`attribute`, `name`),
KEY `idx_customer_rule_collection_attribute_order` (`attribute`, `sort_order`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'customer_rule_product_collection_products' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_rule_product_collection_products` (
`collection_id` INT UNSIGNED NOT NULL,
`product_id` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`collection_id`, `product_id`),
KEY `idx_customer_rule_collection_product` (`product_id`, `collection_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'customer_rule_product_migrations' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_rule_product_migrations` (
`migration_key` VARCHAR(191) NOT NULL,
`details_json` LONGTEXT NULL,
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`migration_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'customer_rule_product_audit_logs' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_rule_product_audit_logs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`actor_user_id` INT UNSIGNED NULL,
`attribute` VARCHAR(191) NOT NULL,
`old_version` INT UNSIGNED NOT NULL,
`new_version` INT UNSIGNED NOT NULL,
`changes_json` LONGTEXT NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_rule_product_audit_attribute` (`attribute`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
'module_config' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `module_config` (
@@ -316,7 +316,10 @@ it('allows tank cleaning products for only tank cleaning customers', function ()
$onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$tankCleaningRow, $tankCleaningAddonRow, $washRow],
[424242 => ['onlyTankCleaning' => true]],
[424242 => [
'onlyTankCleaning' => true,
'__disabled_products' => [3 => ['onlyTankCleaning' => true]],
]],
]);
expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']);
@@ -324,14 +327,17 @@ it('allows tank cleaning products for only tank cleaning customers', function ()
$restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$tankCleaningRow, $washRow],
[424242 => ['restrictTankCleaning' => true]],
[424242 => [
'restrictTankCleaning' => true,
'__disabled_products' => [30 => ['restrictTankCleaning' => true]],
]],
]);
expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']);
expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801);
});
it('flags only standalone Tillægsydelser items for the additional-services customer rule', function (): void {
it('flags every exactly configured product regardless of standalone or addon context', function (): void {
$baseRow = [
'customer_number' => 424242,
'customer_name' => 'Additional Services Customer',
@@ -378,14 +384,22 @@ it('flags only standalone Tillægsydelser items for the additional-services cust
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$standaloneCategoryEight, $relatedCategoryEight, $namedCategoryFourAddon, $legacyCategoryName],
[424242 => ['restrictAdditionalServices' => true]],
[424242 => [
'restrictAdditionalServices' => true,
'__disabled_products' => [
91 => ['restrictAdditionalServices' => true],
71 => ['restrictAdditionalServices' => true],
94 => ['restrictAdditionalServices' => true],
],
]],
]);
expect(array_column($flags, 'definition_key'))->toBe([
'customer_rule_restrict_addon_services',
'customer_rule_restrict_addon_services',
'customer_rule_restrict_addon_services',
]);
expect(array_column($flags, 'target_id'))->toBe([811, 814]);
expect(array_column($flags, 'target_id'))->toBe([811, 812, 814]);
});
it('does not flag interior wash variants as historical primary product mismatches', function (): void {
@@ -2,39 +2,12 @@
declare(strict_types=1);
use classes\customer_order_product_policy;
it('delegates every lower-level order product decision to the exact configured restriction service', function (): void {
$policy = file_get_contents(app_path('classes/customer_order_product_policy.php'));
it('recognizes tankcleaning products by category and legacy names', function (): void {
expect(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 5,
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
'category_name' => 'Other',
]))->toBeTrue()
->and(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 3,
'product_name' => 'Tank cleaning 4 spulehoveder',
'category_name' => 'Other',
]))->toBeTrue()
->and(customer_order_product_policy::isTankCleaningProductRow([
'product_category' => 3,
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
'category_name' => 'Tankrens',
]))->toBeTrue();
});
it('detects only tankcleaning violations only for attributed customers and non-tank products', function (): void {
$washProduct = [
'product_category' => 4,
'product_name' => 'Forvogn',
'category_name' => 'Udvendig',
];
$tankCleaningProduct = [
'product_category' => 5,
'product_name' => 'Tank cleaning 4 spulehoveder',
'category_name' => 'Tank cleaning',
];
expect(customer_order_product_policy::onlyTankCleaningViolation(true, $washProduct))->toBeTrue()
->and(customer_order_product_policy::onlyTankCleaningViolation(true, $tankCleaningProduct))->toBeFalse()
->and(customer_order_product_policy::onlyTankCleaningViolation(false, $washProduct))->toBeFalse();
expect($policy)
->toContain('violationForCustomerProduct($customerNumber, $productId)')
->not->toContain('product_category')
->not->toContain('tankcleaning')
->not->toContain('rowMatchesProductTerms');
});
@@ -0,0 +1,50 @@
<?php
it('defines idempotent normalized product restriction schema and one-time exact legacy seeding', function (): void {
$schema = file_get_contents(app_path('classes/customer_rule_product_restriction_schema_bootstrap.php'));
expect($schema)
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions')
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_collections')
->toContain('CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products')
->toContain('legacy_exact_product_sets_v1')
->toContain('customer_rule_product_migrations')
->toContain('po.option_id = p.id')
->toContain('Unable to record customer-rule product migration');
});
it('uses configured exact product ids in runtime order and invoice enforcement', function (): void {
$productRules = file_get_contents(app_path('classes/customer_product_rule_service.php'));
$policy = file_get_contents(app_path('classes/customer_order_product_policy.php'));
$invoice = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($productRules)->toContain('violationForCustomerProduct')
->and($policy)->toContain('violationForCustomerProduct')
->and($invoice)->toContain("['__disabled_products']")
->and($invoice)->not->toContain('customer_product_rule_service::isStandaloneAdditionalServiceRow($row)')
->and($invoice)->not->toContain('$this->rowIsTankCleaningProduct($row)');
});
it('wires versioned management permissions and structured customer attributes', function (): void {
$route = file_get_contents(app_path('routes/superuserCustomerRuleProductRestrictionsRoute.php'));
$attributes = file_get_contents(app_path('routes/customerAttributes.php'));
expect($route)
->toContain('/superuser/customer-rules/product-restrictions')
->toContain('requireClassicSuperuserAnyPermission([')
->toContain("'superuser_customer_rules_view'")
->toContain("requireClassicSuperuserPermission('superuser_customer_rules_manage')")
->and($attributes)->toContain('enrichAttributes(')
->and($attributes)->toContain('SUPPORTED_ATTRIBUTES');
});
it('fails closed when configured restriction reads cannot be completed', function (): void {
$service = file_get_contents(app_path('classes/customer_rule_product_restriction_service.php'));
expect($service)
->toContain('Unable to load customer-rule restriction version')
->toContain('Unable to load customer-rule restriction collections')
->toContain('Unable to load active customer-rule product restrictions')
->toContain('foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute)')
->toContain('Unable to validate collection products');
});