Add tests for API fixture cleanup and optimize customer trace removal logic

This commit is contained in:
Jeppe Bundgaard
2026-04-15 09:17:46 +02:00
parent 0b672e13a6
commit 40ded9ed95
5 changed files with 979 additions and 5 deletions
@@ -26,6 +26,7 @@ class economic_v2_distribution_service
private array $department_name_cache = [];
private array $department_excluded_cache = [];
private array $customer_name_cache = [];
private array $customer_inclusion_cache = [];
private array $product_cache = [];
private array $product_department_price_cache = [];
private array $discount_resolution_cache = [];
@@ -230,6 +231,9 @@ class economic_v2_distribution_service
$version_rows = $this->fetchVehicleSubscriptionVersionRows($from_ts, $to_ts);
foreach ($version_rows as $row) {
$customer_number = (int)$row['customer_number'];
if (!$this->shouldIncludeCustomerNumber($customer_number)) {
continue;
}
$reg = (string)$row['reg'];
$version_id = (int)$row['id'];
$vehicle_type = (int)$row['vehicle_type'];
@@ -360,6 +364,9 @@ class economic_v2_distribution_service
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
if (!$this->shouldIncludeCustomerNumber($customer_number)) {
continue;
}
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
@@ -637,11 +644,11 @@ class economic_v2_distribution_service
{
try {
return $this->buildCustomerEnvelope($customer_number, $transaction_map);
} catch (Exception $e) {
} catch (\Throwable $e) {
return [
'id' => null,
'customer_number' => $customer_number,
'customer_name' => $this->getCustomerName($customer_number),
'customer_name' => $this->customer_name_cache[$customer_number] ?? 'Unknown Customer',
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
@@ -673,6 +680,9 @@ class economic_v2_distribution_service
if ($invoice_id <= 0 || $customer_number <= 0 || $invoice_date === '') {
continue;
}
if (!$this->shouldIncludeCustomerNumber($customer_number)) {
continue;
}
$month_key = substr($invoice_date, 0, 7);
foreach ($this->parseBookedDepartment75InvoiceLines($invoice_id, (array)($invoice_lines[$invoice_id] ?? []), $warnings) as $line) {
@@ -1038,6 +1048,9 @@ class economic_v2_distribution_service
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
if (!$this->shouldIncludeCustomerNumber($customer_number)) {
continue;
}
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
@@ -1115,6 +1128,9 @@ class economic_v2_distribution_service
foreach ($orders as $order) {
$order_id = (int)$order['id'];
$customer_number = (int)$order['customer_id'];
if (!$this->shouldIncludeCustomerNumber($customer_number)) {
continue;
}
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
@@ -1589,6 +1605,32 @@ class economic_v2_distribution_service
return (string)$this->customer_name_cache[$customer_number];
}
protected function shouldIncludeCustomerNumber(int $customer_number): bool
{
if ($customer_number <= 0) {
return false;
}
if (array_key_exists($customer_number, $this->customer_inclusion_cache)) {
return (bool)$this->customer_inclusion_cache[$customer_number];
}
$rows = (new users_o())->getFieldsWhere(['customer_number' => $customer_number], ['id']);
if (empty($rows)) {
$this->customer_inclusion_cache[$customer_number] = false;
return false;
}
try {
$this->getCustomerName($customer_number);
} catch (\Throwable $e) {
$this->customer_inclusion_cache[$customer_number] = false;
return false;
}
$this->customer_inclusion_cache[$customer_number] = true;
return true;
}
protected function isDepartmentEligible(int $department_id): bool
{
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('removes generated customer traces during fixture cleanup', function (): void {
$db = api_test_runtime()->db();
api_fixture_cleanup_ensure_optional_trace_tables($db);
$department = api_fixtures()->createDepartment(['name' => 'Fixture Cleanup Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Fixture Cleanup Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Fixture Cleanup Cashier']);
$userId = (int)$customer['id'];
$customerNumber = (int)$customer['customer_number'];
$cashierId = (int)$cashier['id'];
$departmentId = (int)$department['id'];
api_fixture_cleanup_seed_customer_traces($db, $userId, $customerNumber, $cashierId, $departmentId);
expect(api_fixture_cleanup_trace_counts($db, $userId, $customerNumber))->toMatchArray([
'users' => 1,
'customer_attributes' => 1,
'tokens' => 1,
'user_key_value_pairs' => 1,
'price_overrides' => 1,
'customer_default_department' => 1,
'customer_fixed_pricing' => 1,
'customer_fixed_pricing_versions' => 1,
'customer_vehicle_subscription_versions' => 1,
'customer_discount_override_versions' => 1,
'system_search_economic_customer_index' => 1,
'subuser_grants' => 1,
'collected_order_invoices' => 1,
'orders' => 1,
'order_items' => 1,
'economic_module_orders' => 1,
'stripe_module_orders' => 1,
'stripe_payment_intents' => 1,
'customer_vehicles' => 1,
'customer_vehicles_addons' => 1,
'object_attachments' => 4,
]);
api_test_runtime()->endTest();
expect(api_fixture_cleanup_trace_counts($db, $userId, $customerNumber))->toMatchArray([
'users' => 0,
'customer_attributes' => 0,
'tokens' => 0,
'user_key_value_pairs' => 0,
'price_overrides' => 0,
'customer_default_department' => 0,
'customer_fixed_pricing' => 0,
'customer_fixed_pricing_versions' => 0,
'customer_vehicle_subscription_versions' => 0,
'customer_discount_override_versions' => 0,
'system_search_economic_customer_index' => 0,
'subuser_grants' => 0,
'collected_order_invoices' => 0,
'orders' => 0,
'order_items' => 0,
'economic_module_orders' => 0,
'stripe_module_orders' => 0,
'stripe_payment_intents' => 0,
'customer_vehicles' => 0,
'customer_vehicles_addons' => 0,
'object_attachments' => 0,
]);
});
function api_fixture_cleanup_ensure_optional_trace_tables(mysqli $db): void
{
$statements = [
<<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_default_department` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`department` INT NOT NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_default_department_customer_number` (`customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_fixed_pricing` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`price` INT NOT NULL,
`description` VARCHAR(255) NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_fixed_pricing_customer_number` (`customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_fixed_pricing_versions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`customer_number` INT NOT NULL,
`price` INT NOT NULL,
`description` VARCHAR(255) NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
`confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
`inferred` TINYINT(1) NOT NULL DEFAULT 0,
`metadata_json` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_fixed_pricing_versions_customer_number` (`customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_vehicle_subscription_versions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`vehicle_id` INT NULL,
`customer_number` INT NOT NULL,
`reg` VARCHAR(64) NOT NULL,
`vehicle_type` INT NOT NULL,
`wash_subscription` TINYINT(1) NOT NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
`confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
`inferred` TINYINT(1) NOT NULL DEFAULT 0,
`metadata_json` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_vehicle_subscription_versions_customer_number` (`customer_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `customer_discount_override_versions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`customer_number` INT NOT NULL,
`is_category` TINYINT(1) NOT NULL,
`object_id` VARCHAR(64) NOT NULL,
`discount` INT NOT NULL,
`effective_from` DATETIME NOT NULL,
`effective_to` DATETIME NULL,
`source` VARCHAR(64) NOT NULL DEFAULT 'fixture.test',
`confidence` DECIMAL(6,5) NOT NULL DEFAULT 1.00000,
`inferred` TINYINT(1) NOT NULL DEFAULT 0,
`metadata_json` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_customer_discount_override_versions_customer_number` (`customer_number`),
KEY `idx_customer_discount_override_versions_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `system_search_economic_customer_index` (
`customer_number` INT NOT NULL,
`user_id` INT NULL,
`local_display_name` VARCHAR(255) NULL,
`local_email` VARCHAR(255) NULL,
`local_phone` VARCHAR(64) NULL,
`economic_name` VARCHAR(255) NULL,
`economic_address` VARCHAR(255) NULL,
`economic_city` VARCHAR(255) NULL,
`economic_zip` VARCHAR(64) NULL,
`economic_email` VARCHAR(255) NULL,
`economic_cvr` VARCHAR(64) NULL,
`economic_mobile_phone` VARCHAR(64) NULL,
`economic_barred` TINYINT(1) NULL,
`search_text` TEXT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`customer_number`),
KEY `idx_system_search_economic_customer_index_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
];
foreach ($statements as $sql) {
$db->query($sql);
}
}
function api_fixture_cleanup_seed_customer_traces(
mysqli $db,
int $userId,
int $customerNumber,
int $cashierId,
int $departmentId
): void {
$now = '2026-04-14 12:00:00';
api_fixture_cleanup_insert($db, 'customer_attributes', [
'user_id' => $userId,
'attribute' => 'fixture_cleanup_attribute',
'created_at' => $now,
]);
api_fixture_cleanup_insert($db, 'tokens', [
'user_id' => $userId,
'type' => 'AUTH_TOKEN',
'description' => 'Fixture cleanup token',
'token' => 'fixture-cleanup-' . $userId,
'created_at' => $now,
]);
api_fixture_cleanup_insert($db, 'user_key_value_pairs', [
'user_id' => $userId,
'var' => 'fixture_cleanup_key',
'val' => 'fixture_cleanup_value',
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'price_overrides', [
'user_id' => $userId,
'is_category' => 1,
'product_or_category_id' => 'fixture_cleanup_category',
'percentage' => 25,
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'customer_default_department', [
'customer_number' => $customerNumber,
'department' => $departmentId,
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'customer_fixed_pricing', [
'customer_number' => $customerNumber,
'price' => 1999,
'description' => 'Fixture cleanup fixed pricing',
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'customer_fixed_pricing_versions', [
'customer_number' => $customerNumber,
'price' => 1999,
'description' => 'Fixture cleanup fixed pricing',
'effective_from' => $now,
'source' => 'fixture.test',
'confidence' => 1.00000,
'inferred' => 0,
'metadata_json' => '{}',
]);
api_fixture_cleanup_insert($db, 'customer_discount_override_versions', [
'user_id' => $userId,
'customer_number' => $customerNumber,
'is_category' => 1,
'object_id' => 'fixture_cleanup_category',
'discount' => 25,
'effective_from' => $now,
'source' => 'fixture.test',
'confidence' => 1.00000,
'inferred' => 0,
'metadata_json' => '{}',
]);
api_fixture_cleanup_insert($db, 'system_search_economic_customer_index', [
'customer_number' => $customerNumber,
'user_id' => $userId,
'local_display_name' => 'Fixture Cleanup Customer',
'local_email' => 'fixture-cleanup@example.test',
'search_text' => 'fixture cleanup customer',
]);
api_fixture_cleanup_insert($db, 'subuser_grants', [
'billing_customer_number' => $customerNumber,
'subuser' => $cashierId,
'enabled' => 1,
'note' => 'Fixture cleanup grant',
'permissions' => '[]',
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
$invoiceCollectionId = api_fixture_cleanup_insert($db, 'collected_order_invoices', [
'customer_number' => $customerNumber,
'name' => 'Fixture cleanup invoice',
'notes' => 'Fixture cleanup notes',
'processor' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$orderId = api_fixture_cleanup_insert($db, 'orders', [
'customer_id' => $customerNumber,
'cashier_id' => $cashierId,
'department_id' => $departmentId,
'reference' => 'FIXTURE-CLEANUP',
'notes' => 'Fixture cleanup order',
'reg_1' => 'FC12345',
'invoice_collection_id' => $invoiceCollectionId,
'using_hand_held' => 0,
'include_in_invoice' => 1,
'created_at' => $now,
'updated_at' => $now,
'completed_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'order_items', [
'order_id' => $orderId,
'product_id' => 61,
'reference' => 'FIXTURE-CLEANUP-ITEM',
'notes' => 'Fixture cleanup item',
'cashier_id' => $cashierId,
'price' => 250,
'quantity' => 1,
'include_in_invoice' => 1,
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'economic_module_orders', [
'id' => $orderId,
'invoice_draft_id' => 9001,
'invoice_id' => 9002,
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'stripe_module_orders', [
'id' => $orderId,
'invoice_id' => 'in_fixture_cleanup',
'customer_id' => 'cus_fixture_cleanup',
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'stripe_payment_intents', [
'order_id' => $orderId,
'payment_intent_id' => 'pi_fixture_cleanup',
'client_secret' => 'secret_fixture_cleanup',
'data' => '{}',
'created_at' => $now,
'updated_at' => $now,
]);
$vehicleId = api_fixture_cleanup_insert($db, 'customer_vehicles', [
'customer_id' => $customerNumber,
'type' => 61,
'reg' => 'FC12345',
'wash_subscription' => 1,
'notes' => 'Fixture cleanup vehicle',
'reference' => 'Fixture cleanup reference',
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'customer_vehicles_addons', [
'vehicle_id' => $vehicleId,
'addon_id' => 71,
'amount' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
api_fixture_cleanup_insert($db, 'customer_vehicle_subscription_versions', [
'vehicle_id' => $vehicleId,
'customer_number' => $customerNumber,
'reg' => 'FC12345',
'vehicle_type' => 61,
'wash_subscription' => 1,
'effective_from' => $now,
'source' => 'fixture.test',
'confidence' => 1.00000,
'inferred' => 0,
'metadata_json' => '{}',
]);
$attachmentPayload = '{"fixture_cleanup":true}';
api_fixture_cleanup_insert($db, 'object_attachments', [
'object_type' => 'users',
'object_id' => $userId,
'content' => $attachmentPayload,
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'object_attachments', [
'object_type' => 'orders',
'object_id' => $orderId,
'content' => $attachmentPayload,
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'object_attachments', [
'object_type' => 'customer_vehicles',
'object_id' => $vehicleId,
'content' => $attachmentPayload,
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
api_fixture_cleanup_insert($db, 'object_attachments', [
'object_type' => 'collected_order_invoices',
'object_id' => $invoiceCollectionId,
'content' => $attachmentPayload,
'created_at' => $now,
'updated_at' => $now,
'deleted_at' => null,
]);
}
/**
* @return array<string, int>
*/
function api_fixture_cleanup_trace_counts(mysqli $db, int $userId, int $customerNumber): array
{
return [
'users' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `users` WHERE `id` = {$userId}"),
'customer_attributes' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_attributes` WHERE `user_id` = {$userId}"),
'tokens' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `tokens` WHERE `user_id` = {$userId}"),
'user_key_value_pairs' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `user_key_value_pairs` WHERE `user_id` = {$userId}"),
'price_overrides' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `price_overrides` WHERE `user_id` = {$userId}"),
'customer_default_department' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_default_department` WHERE `customer_number` = {$customerNumber}"),
'customer_fixed_pricing' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_fixed_pricing` WHERE `customer_number` = {$customerNumber}"),
'customer_fixed_pricing_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_fixed_pricing_versions` WHERE `customer_number` = {$customerNumber}"),
'customer_vehicle_subscription_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicle_subscription_versions` WHERE `customer_number` = {$customerNumber}"),
'customer_discount_override_versions' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_discount_override_versions` WHERE `customer_number` = {$customerNumber}"),
'system_search_economic_customer_index' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `system_search_economic_customer_index` WHERE `customer_number` = {$customerNumber}"),
'subuser_grants' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `subuser_grants` WHERE `billing_customer_number` = {$customerNumber}"),
'collected_order_invoices' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `collected_order_invoices` WHERE `customer_number` = {$customerNumber}"),
'orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `orders` WHERE `customer_id` = {$customerNumber}"),
'order_items' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `order_items` WHERE `order_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"),
'economic_module_orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `economic_module_orders` WHERE `id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"),
'stripe_module_orders' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `stripe_module_orders` WHERE `id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"),
'stripe_payment_intents' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `stripe_payment_intents` WHERE `order_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber})"),
'customer_vehicles' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber}"),
'customer_vehicles_addons' => api_fixture_cleanup_count($db, "SELECT COUNT(*) AS c FROM `customer_vehicles_addons` WHERE `vehicle_id` IN (SELECT `id` FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber})"),
'object_attachments' => api_fixture_cleanup_count(
$db,
"SELECT COUNT(*) AS c
FROM `object_attachments`
WHERE (`object_type` = 'users' AND `object_id` = {$userId})
OR (`object_type` = 'orders' AND `object_id` IN (SELECT `id` FROM `orders` WHERE `customer_id` = {$customerNumber}))
OR (`object_type` = 'customer_vehicles' AND `object_id` IN (SELECT `id` FROM `customer_vehicles` WHERE `customer_id` = {$customerNumber}))
OR (`object_type` = 'collected_order_invoices' AND `object_id` IN (SELECT `id` FROM `collected_order_invoices` WHERE `customer_number` = {$customerNumber}))"
),
];
}
function api_fixture_cleanup_count(mysqli $db, string $sql): int
{
$result = $db->query($sql);
if ($result === false) {
throw new RuntimeException('Fixture cleanup assertion query failed: ' . $sql);
}
$row = $result->fetch_assoc();
$result->free();
return (int)($row['c'] ?? 0);
}
/**
* @param array<string, mixed> $data
*/
function api_fixture_cleanup_insert(mysqli $db, string $table, array $data): int
{
$filtered = [];
foreach ($data as $column => $value) {
if (api_fixture_cleanup_has_column($db, $table, $column)) {
$filtered[$column] = $value;
}
}
if ($filtered === []) {
throw new RuntimeException('No matching columns available for fixture cleanup insert into ' . $table . '.');
}
$columns = array_map(
static fn(string $column): string => '`' . $column . '`',
array_keys($filtered)
);
$values = array_map(
static fn(mixed $value): string => api_fixture_cleanup_sql_value($db, $value),
array_values($filtered)
);
$sql = 'INSERT INTO `' . $table . '` (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')';
if ($db->query($sql) === false) {
throw new RuntimeException('Fixture cleanup insert failed: ' . $sql);
}
return (int)$db->insert_id;
}
function api_fixture_cleanup_has_column(mysqli $db, string $table, string $column): bool
{
static $cache = [];
$cacheKey = $table . ':' . $column;
if (array_key_exists($cacheKey, $cache)) {
return $cache[$cacheKey];
}
$escapedTable = $db->real_escape_string($table);
$escapedColumn = $db->real_escape_string($column);
$result = $db->query(
"SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$escapedTable}'
AND COLUMN_NAME = '{$escapedColumn}'
LIMIT 1"
);
if ($result === false) {
return $cache[$cacheKey] = false;
}
$exists = $result->fetch_assoc() !== null;
$result->free();
return $cache[$cacheKey] = $exists;
}
function api_fixture_cleanup_sql_value(mysqli $db, mixed $value): string
{
if ($value === null) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
return "'" . $db->real_escape_string((string)$value) . "'";
}
@@ -11,6 +11,15 @@ use RuntimeException;
final class ApiFixtures
{
private static int $sequence = 0;
/**
* @var array<string, bool>
*/
private array $tableExistsCache = [];
/**
* @var array<string, bool>
*/
private array $tableColumnExistsCache = [];
public function __construct(
private readonly mysqli $db,
@@ -53,13 +62,13 @@ final class ApiFixtures
]);
$this->cleanup->add(function () use ($userId, $customerNumber): void {
$this->deleteWhere('customer_attributes', ['user_id' => $userId]);
$this->deleteWhere('tokens', ['user_id' => $userId]);
$this->deleteById('users', $userId);
$this->purgeCustomerTraceData($userId, $customerNumber);
$this->deleteRedisKey('user_id_from_customer_number_' . $customerNumber);
$this->deleteRedisKey('customer_number_from_user_id_' . $userId);
$this->deleteRedisKey('users_' . $customerNumber . '_economic_customer_name');
$this->deleteRedisKey('`users`_' . $customerNumber . '_economic_customer_name');
$this->deleteRedisKey('users_' . $userId . '_economic_customer');
$this->deleteRedisKey('`users`_' . $userId . '_economic_customer');
$this->deleteRedisPattern('perm:user:' . $userId . ':*');
});
@@ -604,6 +613,64 @@ final class ApiFixtures
$this->cleanup->add(fn() => $this->deleteWhere($table, $conditions));
}
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
{
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
'customer_number' => $customerNumber,
]);
$orderIds = $this->fetchIntColumnWhere('orders', 'id', [
'customer_id' => $customerNumber,
]);
$vehicleIds = $this->fetchIntColumnWhere('customer_vehicles', 'id', [
'customer_id' => $customerNumber,
]);
if ($orderIds !== []) {
$this->deleteWhereInIfPossible('order_items', 'order_id', $orderIds);
$this->deleteWhereInIfPossible('economic_module_orders', 'id', $orderIds);
$this->deleteWhereInIfPossible('stripe_module_orders', 'id', $orderIds);
$this->deleteWhereInIfPossible('stripe_payment_intents', 'order_id', $orderIds);
$this->deleteWhereInIfPossible('object_attachments', 'object_id', $orderIds, [
'object_type' => 'orders',
]);
}
if ($vehicleIds !== []) {
$this->deleteWhereInIfPossible('customer_vehicles_addons', 'vehicle_id', $vehicleIds);
$this->deleteWhereInIfPossible('object_attachments', 'object_id', $vehicleIds, [
'object_type' => 'customer_vehicles',
]);
}
if ($invoiceCollectionIds !== []) {
$this->deleteWhereInIfPossible('object_attachments', 'object_id', $invoiceCollectionIds, [
'object_type' => 'collected_order_invoices',
]);
}
$this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]);
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_fixed_pricing_versions', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicle_subscription_versions', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_discount_override_versions', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('customer_discount_override_versions', ['user_id' => $userId]);
$this->deleteWhereIfPossible('subuser_grants', ['billing_customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('system_search_economic_customer_index', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('system_search_economic_customer_index', ['user_id' => $userId]);
$this->deleteWhereIfPossible('object_attachments', [
'object_type' => 'users',
'object_id' => $userId,
]);
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
$this->deleteById('users', $userId);
}
private function seedCustomerNameCache(int $customerNumber, string $name): void
{
$payload = [
@@ -711,6 +778,139 @@ final class ApiFixtures
$statement->close();
}
/**
* @param array<string, mixed> $conditions
*/
private function deleteWhereIfPossible(string $table, array $conditions): void
{
if ($conditions === [] || !$this->tableExists($table)) {
return;
}
foreach (array_keys($conditions) as $column) {
if (!$this->tableHasColumn($table, (string)$column)) {
return;
}
}
$this->deleteWhere($table, $conditions);
}
/**
* @param array<string, mixed> $conditions
* @return array<int, int>
*/
private function fetchIntColumnWhere(string $table, string $column, array $conditions): array
{
if (!$this->tableExists($table) || !$this->tableHasColumn($table, $column)) {
return [];
}
foreach (array_keys($conditions) as $conditionColumn) {
if (!$this->tableHasColumn($table, (string)$conditionColumn)) {
return [];
}
}
$table = $this->sanitizeIdentifier($table);
$column = $this->sanitizeIdentifier($column);
$parts = [];
$types = '';
$values = [];
foreach ($conditions as $conditionColumn => $value) {
$conditionColumn = $this->sanitizeIdentifier((string)$conditionColumn);
if ($value === null) {
$parts[] = '`' . $conditionColumn . '` IS NULL';
continue;
}
$parts[] = '`' . $conditionColumn . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$values[] = $normalizedValue;
}
$sql = 'SELECT `' . $column . '` FROM `' . $table . '`';
if ($parts !== []) {
$sql .= ' WHERE ' . implode(' AND ', $parts);
}
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare select for ' . $table . '.');
}
if ($values !== []) {
$statement->bind_param($types, ...$values);
}
$statement->execute();
$statement->bind_result($selectedValue);
$selected = [];
while ($statement->fetch()) {
$selectedValue = (int)$selectedValue;
if ($selectedValue > 0) {
$selected[] = $selectedValue;
}
}
$statement->close();
return array_values(array_unique($selected));
}
/**
* @param array<int, int> $values
* @param array<string, mixed> $conditions
*/
private function deleteWhereInIfPossible(string $table, string $column, array $values, array $conditions = []): void
{
$values = array_values(array_unique(array_filter(
array_map('intval', $values),
static fn(int $value): bool => $value > 0
)));
if ($values === [] || !$this->tableExists($table) || !$this->tableHasColumn($table, $column)) {
return;
}
foreach (array_keys($conditions) as $conditionColumn) {
if (!$this->tableHasColumn($table, (string)$conditionColumn)) {
return;
}
}
$table = $this->sanitizeIdentifier($table);
$column = $this->sanitizeIdentifier($column);
$parts = ['`' . $column . '` IN (' . implode(', ', array_fill(0, count($values), '?')) . ')'];
$types = str_repeat('i', count($values));
$boundValues = $values;
foreach ($conditions as $conditionColumn => $value) {
$conditionColumn = $this->sanitizeIdentifier((string)$conditionColumn);
if ($value === null) {
$parts[] = '`' . $conditionColumn . '` IS NULL';
continue;
}
$parts[] = '`' . $conditionColumn . '` = ?';
[$type, $normalizedValue] = $this->normalizeValue($value);
$types .= $type;
$boundValues[] = $normalizedValue;
}
$sql = 'DELETE FROM `' . $table . '` WHERE ' . implode(' AND ', $parts);
$statement = $this->db->prepare($sql);
if ($statement === false) {
throw new RuntimeException('Failed to prepare delete for ' . $table . '.');
}
$statement->bind_param($types, ...$boundValues);
$statement->execute();
$statement->close();
}
private function deleteById(string $table, int $id): void
{
$table = $this->sanitizeIdentifier($table);
@@ -912,6 +1112,65 @@ final class ApiFixtures
return date('Y-m-d H:i:s');
}
private function tableExists(string $table): bool
{
$table = $this->sanitizeIdentifier($table);
if (array_key_exists($table, $this->tableExistsCache)) {
return $this->tableExistsCache[$table];
}
$escapedTable = $this->db->real_escape_string($table);
$result = $this->db->query(
"SELECT 1
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$escapedTable}'
LIMIT 1"
);
if ($result === false) {
return $this->tableExistsCache[$table] = false;
}
$exists = $result->fetch_assoc() !== null;
$result->free();
return $this->tableExistsCache[$table] = $exists;
}
private function tableHasColumn(string $table, string $column): bool
{
$table = $this->sanitizeIdentifier($table);
$column = $this->sanitizeIdentifier($column);
$cacheKey = $table . ':' . $column;
if (array_key_exists($cacheKey, $this->tableColumnExistsCache)) {
return $this->tableColumnExistsCache[$cacheKey];
}
if (!$this->tableExists($table)) {
return $this->tableColumnExistsCache[$cacheKey] = false;
}
$escapedTable = $this->db->real_escape_string($table);
$escapedColumn = $this->db->real_escape_string($column);
$result = $this->db->query(
"SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$escapedTable}'
AND COLUMN_NAME = '{$escapedColumn}'
LIMIT 1"
);
if ($result === false) {
return $this->tableColumnExistsCache[$cacheKey] = false;
}
$exists = $result->fetch_assoc() !== null;
$result->free();
return $this->tableColumnExistsCache[$cacheKey] = $exists;
}
private function uniqueCustomerNumber(): int
{
return 80000000 + (++self::$sequence);
@@ -53,6 +53,7 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) {
public array $subscriptionPrices = [];
public array $stubBookedInvoices = [];
public array $stubBookedInvoiceLines = [];
public ?array $includedCustomers = null;
public function __construct(?economic_v2_versioning_service $versioning = null)
{
@@ -128,6 +129,15 @@ if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) {
return $department_id > 0 && $department_id !== 10;
}
protected function shouldIncludeCustomerNumber(int $customer_number): bool
{
if ($this->includedCustomers === null) {
return true;
}
return in_array($customer_number, $this->includedCustomers, true);
}
protected function parseDepartmentMap(array $department_map): array
{
$parsed = [];
@@ -54,6 +54,7 @@ if (!class_exists('TestableEconomicV2DistributionService')) {
public array $stubVersionRows = [];
public array $subscriptionPrices = [];
public array $versionTableState = [];
public ?array $includedCustomers = null;
public function __construct(?economic_v2_versioning_service $versioning = null)
{
@@ -89,6 +90,16 @@ if (!class_exists('TestableEconomicV2DistributionService')) {
return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0);
}
protected function getProductDepartmentPrice(int $product_id, int $department_id): float
{
return 100.0;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
{
return null;
}
protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
{
return [
@@ -118,6 +129,15 @@ if (!class_exists('TestableEconomicV2DistributionService')) {
return $department_id > 0 && $department_id !== 10;
}
protected function shouldIncludeCustomerNumber(int $customer_number): bool
{
if ($this->includedCustomers === null) {
return true;
}
return in_array($customer_number, $this->includedCustomers, true);
}
protected function parseDepartmentMap(array $department_map): array
{
$parsed = [];
@@ -261,3 +281,116 @@ it('falls back to system orders for wash subscriptions when regular orders yield
expect($result['collective_results']['total_subscription_price'])->toBe(299.0);
expect($result['warnings'])->toContain('System order fallback used for wash subscriptions (department 10).');
});
it('excludes orphaned customer traces from fixed pricing and customer price distributions', function (): void {
$versioning = new FakeEconomicV2DistributionVersioningService();
$versioning->fixedVersion = [
'id' => 91,
'price' => 499.95,
'description' => 'Fixed pricing agreement',
'source' => 'test.fixed',
'confidence' => 1.0,
'inferred' => false,
'effective_from' => '2026-01-01 00:00:00',
'effective_to' => null,
];
$service = new TestableEconomicV2DistributionService($versioning);
$service->includedCustomers = [12345];
$service->stubOrders = [
[
'id' => 501,
'customer_id' => 80000038,
'department_id' => 1,
'created_at' => '2026-01-15 10:00:00',
'include_in_invoice' => 1,
'reg_1' => '',
'reference' => '',
],
[
'id' => 502,
'customer_id' => 12345,
'department_id' => 1,
'created_at' => '2026-01-16 10:00:00',
'include_in_invoice' => 1,
'reg_1' => '',
'reference' => '',
],
];
$service->stubOrderItems = [
501 => [[
'product_id' => 61,
'price' => 250.0,
'quantity' => 1,
'reference' => '',
]],
502 => [[
'product_id' => 61,
'price' => 300.0,
'quantity' => 1,
'reference' => '',
]],
];
$fixedPricing = $service->getFixedPricingDistribution('2026-01-01', '2026-01-31');
$customerPrices = $service->getCustomerPricesDistribution('2026-01-01', '2026-01-31');
expect(array_column($fixedPricing['customers'], 'customer_number'))->toBe([12345]);
expect($fixedPricing['collective_results']['total_fixed_price'])->toBe(499.95);
expect($fixedPricing['collective_results']['total_original_price'])->toBe(300.0);
expect(array_column($customerPrices['customers'], 'customer_number'))->toBe([12345]);
expect($customerPrices['collective_results']['total_discount_amount'])->toBe(0.0);
});
it('excludes orphaned customers from subscription fallback versions', function (): void {
$versioning = new FakeEconomicV2DistributionVersioningService();
$service = new TestableEconomicV2DistributionService($versioning);
$service->includedCustomers = [12345];
$service->stubOrders = [[
'id' => 701,
'customer_id' => 12345,
'department_id' => 4,
'created_at' => '2026-01-15 10:00:00',
'include_in_invoice' => 1,
'reg_1' => 'AB12345',
'reference' => '',
]];
$service->subscriptionPrices = [
77 => 299.0,
];
$service->stubVersionRows = [
[
'id' => 41,
'customer_number' => 80000038,
'reg' => 'ZZ99999',
'vehicle_type' => 77,
'wash_subscription' => 1,
'source' => 'test.version',
'confidence' => 1.0,
'inferred' => false,
'effective_from' => '2026-01-01 00:00:00',
'effective_to' => null,
],
[
'id' => 42,
'customer_number' => 12345,
'reg' => 'AB12345',
'vehicle_type' => 77,
'wash_subscription' => 1,
'source' => 'test.version',
'confidence' => 1.0,
'inferred' => false,
'effective_from' => '2026-01-01 00:00:00',
'effective_to' => null,
],
];
$result = $service->getWashSubscriptionsDistribution('2026-01-01', '2026-01-31');
expect(array_column($result['customers'], 'customer_number'))->toBe([12345]);
expect($result['warnings'])->toBe([
'Fallback allocation used for subscription AB12345 customer 12345 in 2026-01',
]);
});