From aa21bad7bdb9865c3c5c4cd775b90480a49d5aed Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 16 Jul 2026 10:58:40 +0600 Subject: [PATCH] refactor(cart-customer): extract thin facades into Services managers Move cart mutations and customer field/order resolution out of Controllers facades so they match the Order + managers pattern without breaking web API. --- .../minishop3/src/Controllers/Cart/Cart.php | 351 ++++----------- .../src/Controllers/Customer/Customer.php | 413 +++--------------- .../minishop3/src/ServiceRegistry.php | 35 ++ .../src/Services/Cart/CartMutationHandler.php | 407 +++++++++++++++++ .../Customer/CustomerFieldManager.php | 206 +++++++++ .../Customer/CustomerOrderResolver.php | 203 +++++++++ .../tests/CartCustomerFacadeStructureTest.php | 107 +++++ 7 files changed, 1088 insertions(+), 634 deletions(-) create mode 100644 core/components/minishop3/src/Services/Cart/CartMutationHandler.php create mode 100644 core/components/minishop3/src/Services/Customer/CustomerFieldManager.php create mode 100644 core/components/minishop3/src/Services/Customer/CustomerOrderResolver.php create mode 100644 core/components/minishop3/tests/CartCustomerFacadeStructureTest.php diff --git a/core/components/minishop3/src/Controllers/Cart/Cart.php b/core/components/minishop3/src/Controllers/Cart/Cart.php index 715bc85a..bbffe4f6 100644 --- a/core/components/minishop3/src/Controllers/Cart/Cart.php +++ b/core/components/minishop3/src/Controllers/Cart/Cart.php @@ -4,8 +4,8 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msOrder; -use MiniShop3\Model\msOrderLog; use MiniShop3\Services\Cart\CartItemManager; +use MiniShop3\Services\Cart\CartMutationHandler; use MiniShop3\Services\Order\OrderDraftManager; use MiniShop3\Services\Order\OrderLogService; use MODX\Revolution\modX; @@ -42,6 +42,7 @@ class Cart // Services protected OrderDraftManager $draftManager; protected CartItemManager $itemManager; + protected CartMutationHandler $mutationHandler; protected ?OrderLogService $orderLog = null; public function __construct(MiniShop3 $ms3, array $config = []) @@ -69,6 +70,17 @@ protected function initializeServices(): void 'ms3_cart_item_manager', fn() => new CartItemManager($this->modx, $this->ms3) ); + + $this->mutationHandler = $this->getServiceFromDI( + 'ms3_cart_mutation_handler', + fn() => new CartMutationHandler( + $this->modx, + $this->ms3, + $this->draftManager, + $this->itemManager, + $this->getOrderLog() + ) + ); } /** @@ -164,75 +176,9 @@ public function add(int $id, int $count = 1, array $options = []): array $this->ensureDraft(); $this->loadCart(); - if (empty($id) || !is_numeric($id)) { - return $this->error('ms3_cart_add_err_id'); - } - - $count = (int)$count; - $options = $this->itemManager->normalizeOptions($options); - - if (!$this->itemManager->validateCount($count)) { - return $this->error('ms3_cart_add_err_count', $this->getStatus(), ['count' => $count]); - } - - $product = $this->itemManager->validateProduct($id); - if (!$product) { - return $this->error('ms3_cart_add_err_nf', $this->getStatus()); - } - - $response = $this->invokeEvent('msOnBeforeAddToCart', [ - 'msProduct' => $product, - 'count' => $count, - 'options' => $options, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - - $count = $response['data']['count']; - $options = $response['data']['options']; - - $productKey = $this->itemManager->generateProductKey($product->toArray(), $options); - - // If product already in cart - increase count - if (isset($this->cart[$productKey])) { - return $this->change($productKey, $this->cart[$productKey]['count'] + $count); - } - - $cartItem = $this->itemManager->addItem($this->draft, $product, $count, $options, $productKey); - - // Log product addition - $this->getOrderLog()->addEntry( - $this->draft->get('id'), - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'add', - 'product_id' => $id, - 'product_name' => $product->get('pagetitle'), - 'count' => $count, - 'price' => $cartItem->get('price'), - 'cost' => $cartItem->get('cost'), - ] - ); - - $this->draftManager->recalculate($this->draft); - $this->loadCart(); - - $response = $this->invokeEvent('msOnAddToCart', [ - 'msProduct' => $product, - 'count' => $count, - 'options' => $options, - 'product_key' => $productKey, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } + $response = $this->mutationHandler->add($this, $this->draft, $this->cart, $id, $count, $options); - return $this->success('ms3_cart_add_success', [ - 'last_key' => $productKey, - 'cart' => $this->cart, - 'status' => $this->getStatus(), - ], ['count' => $count]); + return $this->applyMutationResponse($response); } /** @@ -240,77 +186,15 @@ public function add(int $id, int $count = 1, array $options = []): array */ public function change(string $productKey, int $count): array { - if (empty($this->token)) { - return $this->error('ms3_err_token'); - } - - $this->initDraft(); - - if (!$this->draft) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $this->loadCart(); - - if (!isset($this->cart[$productKey])) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $count = (int)$count; - - if ($count <= 0) { - return $this->remove($productKey); - } - - if (!$this->itemManager->validateCount($count)) { - return $this->error('ms3_cart_add_err_count', $this->getStatus(), ['count' => $count]); - } - - $response = $this->invokeEvent('msOnBeforeChangeInCart', [ - 'product_key' => $productKey, - 'count' => $count, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - $count = $response['data']['count']; - - // Store old values for logging - $oldCount = $this->cart[$productKey]['count']; - $productId = $this->cart[$productKey]['product_id']; - $productName = $this->cart[$productKey]['name'] ?? ''; - - $this->itemManager->updateItemCount($this->draft, $productKey, $count); - - // Log quantity change - if ($oldCount != $count) { - $this->getOrderLog()->addEntry( - $this->draft->get('id'), - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'update', - 'product_id' => $productId, - 'product_name' => $productName, - 'changes' => [ - 'count' => ['old' => $oldCount, 'new' => $count], - ], - ] - ); - } - - $this->draftManager->recalculate($this->draft); - $this->loadCart(); - - $this->invokeEvent('msOnChangeInCart', [ - 'product_key' => $productKey, - 'count' => $count, - ]); - - return $this->success('ms3_cart_change_success', [ - 'last_key' => $productKey, - 'cart' => $this->cart, - 'status' => $this->getStatus(), - ], ['count' => $count]); + return $this->mutateExistingDraft( + fn(msOrder $draft, array $cart) => $this->mutationHandler->change( + $this, + $draft, + $cart, + $productKey, + $count + ) + ); } /** @@ -318,83 +202,15 @@ public function change(string $productKey, int $count): array */ public function changeOption(string $productKey, array $options): array { - if (empty($this->token)) { - return $this->error('ms3_err_token'); - } - - $this->initDraft(); - - if (!$this->draft) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $this->loadCart(); - - if (!isset($this->cart[$productKey])) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - if (empty($options)) { - return $this->error('ms3_cart_change_options_error', $this->getStatus()); - } - - $response = $this->invokeEvent('msOnBeforeChangeOptionsInCart', [ - 'product_key' => $productKey, - 'options' => $options, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - if (isset($response['data']['options']) && is_array($response['data']['options'])) { - $options = $response['data']['options']; - } - - $count = $this->cart[$productKey]['count']; - - // Check if new key already exists - $item = $this->itemManager->getItemByKey($this->draft, $productKey); - if ($item) { - $currentOptions = $item->get('options') ?? []; - foreach ($options as $key => $value) { - if (!empty($value)) { - $currentOptions[$key] = $value; - } else { - unset($currentOptions[$key]); - } - } - - $product = $item->getOne('Product'); - if ($product) { - $newProductKey = $this->itemManager->generateProductKey($product->toArray(), $currentOptions); - - // If new key exists, merge items - if ($newProductKey !== $productKey && isset($this->cart[$newProductKey])) { - $item->remove(); - return $this->change($newProductKey, $this->cart[$newProductKey]['count'] + $count); - } - } - } - - $newProductKey = $this->itemManager->updateItemOptions($this->draft, $productKey, $options); - - if (!$newProductKey) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $this->draftManager->recalculate($this->draft); - $this->loadCart(); - - $this->invokeEvent('msOnChangeOptionInCart', [ - 'old_product_key' => $productKey, - 'product_key' => $newProductKey, - 'options' => $options, - ]); - - return $this->success('ms3_cart_change_success', [ - 'last_key' => $newProductKey, - 'cart' => $this->cart, - 'status' => $this->getStatus(), - ], ['count' => $count]); + return $this->mutateExistingDraft( + fn(msOrder $draft, array $cart) => $this->mutationHandler->changeOption( + $this, + $draft, + $cart, + $productKey, + $options + ) + ); } /** @@ -402,66 +218,9 @@ public function changeOption(string $productKey, array $options): array */ public function remove(string $productKey): array { - if (empty($this->token)) { - return $this->error('ms3_err_token'); - } - - $this->initDraft(); - - if (!$this->draft) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $this->loadCart(); - - if (!isset($this->cart[$productKey])) { - return $this->error('ms3_cart_change_error', $this->getStatus()); - } - - $response = $this->invokeEvent('msOnBeforeRemoveFromCart', [ - 'product_key' => $productKey, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - - // Store data for logging - $itemData = $this->itemManager->getItemDataForLog($this->draft, $productKey); - $orderId = $this->draft->get('id'); - - $this->itemManager->removeItem($this->draft, $productKey); - - // Log product removal - $this->getOrderLog()->addEntry( - $orderId, - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'remove', - 'product_id' => $itemData['product_id'] ?? 0, - 'product_name' => $itemData['product_name'] ?? '', - 'count' => $itemData['count'] ?? 0, - 'price' => $itemData['price'] ?? 0, - ] + return $this->mutateExistingDraft( + fn(msOrder $draft, array $cart) => $this->mutationHandler->remove($this, $draft, $cart, $productKey) ); - - if ($this->draftManager->isEmpty($this->draft)) { - $this->draftManager->deleteDraft($this->draft); - $this->draft = null; - $this->cart = []; - } else { - $this->draftManager->recalculate($this->draft); - $this->loadCart(); - } - - $this->invokeEvent('msOnRemoveFromCart', [ - 'product_key' => $productKey, - ]); - - return $this->success('ms3_cart_remove_success', [ - 'last_key' => $productKey, - 'cart' => $this->cart, - 'status' => $this->getStatus(), - ]); } /** @@ -605,6 +364,46 @@ protected function loadCart(): void $this->cart = $this->itemManager->loadItems($this->draft); } + /** + * Run mutation against an existing draft cart + */ + protected function mutateExistingDraft(callable $handler): array + { + if (empty($this->token)) { + return $this->error('ms3_err_token'); + } + + $this->initDraft(); + + if (!$this->draft) { + return $this->error('ms3_cart_change_error', $this->getStatus()); + } + + $this->loadCart(); + + return $this->applyMutationResponse($handler($this->draft, $this->cart)); + } + + /** + * Sync facade draft/cart from handler payload (status already owned by handler). + */ + protected function applyMutationResponse(array $response): array + { + $data = $response['data'] ?? []; + + if (array_key_exists('draft', $data)) { + $this->draft = $data['draft']; + unset($data['draft']); + } + if (array_key_exists('cart', $data)) { + $this->cart = $data['cart']; + } + + $response['data'] = $data; + + return $response; + } + /** * Get cart status (totals) */ diff --git a/core/components/minishop3/src/Controllers/Customer/Customer.php b/core/components/minishop3/src/Controllers/Customer/Customer.php index 1157ee57..ed2f975b 100644 --- a/core/components/minishop3/src/Controllers/Customer/Customer.php +++ b/core/components/minishop3/src/Controllers/Customer/Customer.php @@ -8,13 +8,11 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msCustomer; -use MiniShop3\Model\msCustomerToken; use MiniShop3\Services\Customer\CustomerAddressManager; -use MiniShop3\Utils\CookieHelper; +use MiniShop3\Services\Customer\CustomerFieldManager; +use MiniShop3\Services\Customer\CustomerOrderResolver; use MODX\Revolution\modX; -use Rakit\Validation\Validator; - class Customer { /** @var modX $modx */ @@ -27,6 +25,9 @@ class Customer protected $validationRules = []; protected $validationMessages = []; + protected CustomerFieldManager $fieldManager; + protected CustomerOrderResolver $orderResolver; + /** * Cart constructor. * @@ -42,6 +43,36 @@ public function __construct(MiniShop3 $ms3, array $config = []) ], $config); $this->modx->lexicon->load('minishop3:customer'); + + $this->initializeServices(); + } + + /** + * Initialize services from DI container + */ + protected function initializeServices(): void + { + $this->fieldManager = $this->getServiceFromDI( + 'ms3_customer_field_manager', + fn() => new CustomerFieldManager($this->modx, $this->ms3) + ); + + $this->orderResolver = $this->getServiceFromDI( + 'ms3_customer_order_resolver', + fn() => new CustomerOrderResolver($this->modx, $this->ms3, $this->fieldManager) + ); + } + + /** + * Get service from DI container or use fallback factory + */ + protected function getServiceFromDI(string $serviceKey, callable $fallbackFactory): mixed + { + if ($this->modx->services->has($serviceKey)) { + return $this->modx->services->get($serviceKey); + } + + return $fallbackFactory(); } public function initialize(string $token = ''): bool @@ -57,6 +88,10 @@ public function initialize(string $token = ''): bool if (!empty($_SESSION['ms3']['validation']['messages'])) { $this->validationMessages = $_SESSION['ms3']['validation']['messages']; } + + $this->fieldManager->setValidationRules($this->validationRules); + $this->fieldManager->setValidationMessages($this->validationMessages); + return true; } @@ -115,6 +150,9 @@ public function registerValidation(array $rules = [], array $messages = []): voi $_SESSION['ms3']['validation']['rules'] = $this->validationRules; $_SESSION['ms3']['validation']['messages'] = $this->validationMessages; + + $this->fieldManager->setValidationRules($this->validationRules); + $this->fieldManager->setValidationMessages($this->validationMessages); } public function getFields(): array @@ -122,27 +160,17 @@ public function getFields(): array if (empty($this->token)) { return $this->error('ms3_err_token'); } - $msCustomer = $this->modx->getObject(msCustomer::class, [ - 'token' => $this->token, - ]); - if (!$msCustomer) { - return $this->success('', $this->modx->getFields(msCustomer::class)); - } - return $this->success('', $msCustomer->toArray()); + + $msCustomer = $this->getObject(); + + return $msCustomer + ? $this->success('', $msCustomer->toArray()) + : $this->success('', $this->modx->getFields(msCustomer::class)); } public function getObject(): object|null { - if (empty($this->token)) { - return null; - } - $msCustomer = $this->modx->getObject(msCustomer::class, [ - 'token' => $this->token, - ]); - if (!$msCustomer) { - return null; - } - return $msCustomer; + return $this->getByToken($this->token); } public function getByToken(string $token): ?msCustomer @@ -150,13 +178,8 @@ public function getByToken(string $token): ?msCustomer if (empty($token)) { return null; } - $msCustomer = $this->modx->getObject(msCustomer::class, [ - 'token' => $token, - ]); - if (!$msCustomer) { - return null; - } - return $msCustomer; + + return $this->modx->getObject(msCustomer::class, ['token' => $token]) ?: null; } public function set(array $data = []): array @@ -177,129 +200,12 @@ public function add(string $key, mixed $value): array return $this->error('ms3_err_token'); } - if (empty($key)) { - return $this->error('ms3_customer_key_empty'); - } - - $response = $this->ms3->utils->invokeEvent('msOnBeforeAddToCustomer', [ - 'key' => $key, - 'value' => $value, - 'customer' => $this, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - $value = $response['data']['value']; - - $response = $this->validate($key, $value); - if (is_array($response)) { - return $this->error($response[$key]); - } - - $validated = $response; - - $isNew = false; - $msCustomer = $this->modx->getObject(msCustomer::class, [ - 'token' => $this->token - ]); - if ($msCustomer) { - $msCustomer->set($key, $validated); - } else { - $isNew = true; - $userId = 0; - - // TODO how to correctly determine current system user if authenticated? - if ($this->modx->user->hasSessionContext($this->ms3->config['ctx'])) { - $userId = $this->modx->user->get('id'); - } - $msCustomer = $this->modx->newObject(msCustomer::class, [ - 'token' => $this->token, - $key => $validated, - 'user_id' => $userId - ]); - } - $msCustomer->save(); - - $response = $this->ms3->utils->invokeEvent('msOnAddToCustomer', [ - 'key' => $key, - 'value' => $validated, - 'customer' => $this, - 'msCustomer' => $msCustomer, - 'isNew' => $isNew, - ]); - if (!$response['success']) { - return $this->error($response['message']); - } - - return ($validated === false) - ? $this->error('', [$key => $value]) - : $this->success('', [$key => $validated]); + return $this->fieldManager->add($this, $this->token, $key, $value); } public function validate(string $key, mixed $value): mixed { - // Allow plugins to modify value before validation - $response = $this->ms3->utils->invokeEvent('msOnBeforeValidateCustomerValue', [ - 'key' => $key, - 'value' => $value, - 'customer' => $this, - ]); - if (!$response['success']) { - return [$key => $response['message']]; - } - $value = $response['data']['value']; - - // Standard validation - if (!empty($this->validationRules[$key])) { - $validator = new Validator(); - - $validation = $validator->validate( - [$key => $value], - [$key => $this->validationRules[$key]], - $this->validationMessages - ); - - $validation->validate(); - - if ($validation->fails()) { - $errors = $validation->errors(); - - // Allow plugins to handle validation errors. - // Contract (Utils::invokeEvent merges returnedValues into data): - // - success=false → caller gets [$key => message] from the wrapper. - // - success=true and data.errors is set (array, may be empty) → return that shape; - // omitted key keeps standard $errors->firstOfAll(). - $response = $this->ms3->utils->invokeEvent('msOnErrorValidateCustomerValue', [ - 'key' => $key, - 'value' => $value, - 'errors' => $errors->firstOfAll(), - 'customer' => $this, - ]); - - if (!$response['success']) { - return [$key => $response['message']]; - } - - $data = $response['data'] ?? []; - if (array_key_exists('errors', $data) && is_array($data['errors'])) { - return $data['errors']; - } - - return $errors->firstOfAll(); - } - } - - // Allow plugins to modify validated value - $response = $this->ms3->utils->invokeEvent('msOnValidateCustomerValue', [ - 'key' => $key, - 'value' => $value, - 'customer' => $this, - ]); - if (!$response['success']) { - return [$key => $response['message']]; - } - - return $response['data']['value']; + return $this->fieldManager->validate($this, $key, $value); } /** @@ -315,34 +221,7 @@ public function getId(): int public function create(array $customerData): msCustomer|null { - // Allow plugins to modify data before creation - $response = $this->ms3->utils->invokeEvent('msOnBeforeCreateCustomer', [ - 'customerData' => $customerData, - 'customer' => $this, - ]); - if (!$response['success']) { - return null; - } - $customerData = $response['data']['customerData']; - - $msCustomer = $this->modx->newObject(msCustomer::class, $customerData); - $save = $msCustomer->save(); - if (!$save) { - return null; - } - - // Allow plugins to act after customer creation - $response = $this->ms3->utils->invokeEvent('msOnCreateCustomer', [ - 'customerData' => $customerData, - 'msCustomer' => $msCustomer, - 'customer' => $this, - ]); - if (!$response['success']) { - // Customer already created, but plugins can log/handle errors - $this->modx->log(modX::LOG_LEVEL_WARN, '[Customer::create] msOnCreateCustomer event failed: ' . $response['message']); - } - - return $msCustomer; + return $this->fieldManager->create($this, $customerData); } /** @@ -403,189 +282,7 @@ protected function getAddressManager(): CustomerAddressManager */ public function getOrCreate(?array $orderData = null): int { - $msCustomer = null; - - $response = $this->ms3->utils->invokeEvent('msOnBeforeGetOrderCustomer', [ - 'controller' => $this->ms3->order, - 'msCustomer' => $msCustomer, - ]); - if (!$response['success']) { - return 0; - } - - if (!empty($response['data']['msCustomer']) && $response['data']['msCustomer'] instanceof msCustomer) { - $msCustomer = $response['data']['msCustomer']; - } else { - $msCustomer = $this->getObject(); - } - - if (empty($msCustomer)) { - if ($orderData === null) { - $orderResponse = $this->ms3->order->get(); - $orderData = $orderResponse['data']['order'] ?? []; - } - - $email = $orderData['address_email'] ?? ''; - - if (!empty($email)) { - $msCustomer = $this->findByEmail($email); - - if ($msCustomer) { - $msCustomer->set('token', $this->token); - $msCustomer->save(); - } - } - - if (empty($msCustomer)) { - $msCustomer = $this->createFromOrderData($orderData); - } - } - - $response = $this->ms3->utils->invokeEvent('msOnGetOrderCustomer', [ - 'controller' => $this->ms3->order, - 'msCustomer' => $msCustomer, - ]); - if (!$response['success']) { - return 0; - } - - if (!empty($msCustomer)) { - return (int)$msCustomer->get('id'); - } - - return 0; - } - - /** - * Auto-login customer after order creation - * - * Binds existing msCustomerToken to customer and sets session + cookie. - * - * @param msCustomer $msCustomer Customer to login - */ - protected function autoLoginCustomer(msCustomer $msCustomer): void - { - if (!isset($_SESSION['ms3'])) { - $_SESSION['ms3'] = []; - } - $_SESSION['ms3']['customer_id'] = $msCustomer->id; - - // Resolve current token: cookie → session → controller token - // ($this->token comes from $_REQUEST via middleware cookie injection) - $currentToken = CookieHelper::getTokenFromCookie(); - if (empty($currentToken)) { - $currentToken = $_SESSION['ms3']['customer_token'] ?? $this->token; - } - - if (!empty($currentToken)) { - $tokenObj = $this->modx->getObject(msCustomerToken::class, [ - 'token' => $currentToken, - 'type' => msCustomerToken::TYPE_API, - ]); - - if ($tokenObj) { - $tokenObj->set('customer_id', $msCustomer->id); - $tokenObj->save(); - } - - $_SESSION['ms3']['customer_token'] = $currentToken; - CookieHelper::setTokenCookie($this->modx, $currentToken); - } - } - - /** - * Find customer by email - * - * @param string $email Customer email - * @return msCustomer|null Customer object or null - */ - protected function findByEmail(string $email): ?msCustomer - { - if (empty($email)) { - return null; - } - - return $this->modx->getObject(msCustomer::class, ['email' => $email]); - } - - /** - * Create customer from order data - * - * Logic: - * 1. If auto-registration enabled (ms3_customer_auto_register_on_order = true) - * → creates via RegisterService (with password, email verification) - * 2. Fallback: creates without password (for backward compatibility) - * - * @param array $orderData Order data - * @return msCustomer|null Created customer or null - */ - protected function createFromOrderData(array $orderData): ?msCustomer - { - $email = $orderData['address_email'] ?? ''; - - if (empty($email)) { - return null; - } - - $msCustomer = null; - $autoRegister = (bool)$this->modx->getOption('ms3_customer_auto_register_on_order', null, true); - $autoLogin = (bool)$this->modx->getOption('ms3_customer_auto_login_on_order', null, true); - - if ($autoRegister) { - /** @var \MiniShop3\Services\Customer\RegisterService $registerService */ - $registerService = $this->modx->services->get('ms3_register_service'); - - if ($registerService) { - $registerData = [ - 'first_name' => $orderData['address_first_name'] ?? '', - 'last_name' => $orderData['address_last_name'] ?? '', - 'phone' => $orderData['address_phone'] ?? '', - 'email' => $email, - 'token' => $this->token, - 'privacy_accepted' => true, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', - ]; - - $registerResult = $registerService->register($registerData); - - if ($registerResult['success']) { - $msCustomer = $registerResult['customer']; - - if ($autoLogin) { - $this->autoLoginCustomer($msCustomer); - } - } else { - $msCustomer = $this->findByEmail($email); - - if ($msCustomer) { - $msCustomer->set('token', $this->token); - $msCustomer->save(); - - if ($autoLogin) { - $this->autoLoginCustomer($msCustomer); - } - } - } - } - } - - if (empty($msCustomer)) { - $customerData = [ - 'first_name' => $orderData['address_first_name'] ?? '', - 'last_name' => $orderData['address_last_name'] ?? '', - 'phone' => $orderData['address_phone'] ?? '', - 'email' => $email, - 'token' => $this->token, - ]; - - $msCustomer = $this->create($customerData); - - if ($msCustomer && $autoLogin) { - $this->autoLoginCustomer($msCustomer); - } - } - - return $msCustomer; + return $this->orderResolver->getOrCreate($this, $this->token, $orderData); } /** diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 44840b80..d93b5082 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -130,6 +130,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Cart\CartItemManager::class, 'interface' => null, ], + 'ms3_cart_mutation_handler' => [ + 'class' => \MiniShop3\Services\Cart\CartMutationHandler::class, + 'interface' => null, + ], 'ms3_token_service' => [ 'class' => \MiniShop3\Services\TokenService::class, 'interface' => null, @@ -186,6 +190,14 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Customer\CustomerAddressManager::class, 'interface' => null, ], + 'ms3_customer_field_manager' => [ + 'class' => \MiniShop3\Services\Customer\CustomerFieldManager::class, + 'interface' => null, + ], + 'ms3_customer_order_resolver' => [ + 'class' => \MiniShop3\Services\Customer\CustomerOrderResolver::class, + 'interface' => null, + ], 'ms3_grid_config' => [ 'class' => \MiniShop3\Services\GridConfigService::class, 'interface' => null, @@ -450,6 +462,7 @@ protected function registerService(string $serviceKey, array $config): bool 'ms3_order_finalize', 'ms3_cart_item_manager', 'ms3_customer_address_manager', + 'ms3_customer_field_manager', ]; // Services with complex dependencies (resolved via DI) @@ -458,6 +471,8 @@ protected function registerService(string $serviceKey, array $config): bool 'ms3_order_address_manager', 'ms3_order_submit_handler', 'ms3_order_status', + 'ms3_cart_mutation_handler', + 'ms3_customer_order_resolver', ]; if (in_array($serviceKey, $controllersWithMs3Only)) { @@ -547,6 +562,26 @@ protected function registerServiceWithDependencies(string $serviceKey, string $v }); break; + case 'ms3_cart_mutation_handler': + // CartMutationHandler(modX, MiniShop3, OrderDraftManager, CartItemManager, OrderLogService) + $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { + $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); + $draftManager = $modx->services->get('ms3_order_draft_manager'); + $itemManager = $modx->services->get('ms3_cart_item_manager'); + $orderLog = $modx->services->get('ms3_order_log'); + return new $validatedClass($modx, $ms3, $draftManager, $itemManager, $orderLog); + }); + break; + + case 'ms3_customer_order_resolver': + // CustomerOrderResolver(modX, MiniShop3, CustomerFieldManager) + $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { + $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); + $fieldManager = $modx->services->get('ms3_customer_field_manager'); + return new $validatedClass($modx, $ms3, $fieldManager); + }); + break; + default: // Fallback: create with modX only $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { diff --git a/core/components/minishop3/src/Services/Cart/CartMutationHandler.php b/core/components/minishop3/src/Services/Cart/CartMutationHandler.php new file mode 100644 index 00000000..69ea8569 --- /dev/null +++ b/core/components/minishop3/src/Services/Cart/CartMutationHandler.php @@ -0,0 +1,407 @@ +modx = $modx; + $this->ms3 = $ms3; + $this->draftManager = $draftManager; + $this->itemManager = $itemManager; + $this->orderLog = $orderLog; + } + + /** + * Add product to cart + * + * @param object $controller Cart facade (event payload BC) + */ + public function add( + object $controller, + msOrder $draft, + array $cart, + int $id, + int $count = 1, + array $options = [] + ): array { + if (empty($id) || !is_numeric($id)) { + return $this->ms3->utils->error('ms3_cart_add_err_id'); + } + + $count = (int)$count; + $options = $this->itemManager->normalizeOptions($options); + + if (!$this->itemManager->validateCount($count)) { + return $this->errorWithStatus( + $controller, + $cart, + 'ms3_cart_add_err_count', + ['count' => $count] + ); + } + + $product = $this->itemManager->validateProduct($id); + if (!$product) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_add_err_nf'); + } + + $response = $this->invokeEvent($controller, 'msOnBeforeAddToCart', [ + 'msProduct' => $product, + 'count' => $count, + 'options' => $options, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + + $count = $response['data']['count']; + $options = $response['data']['options']; + + $productKey = $this->itemManager->generateProductKey($product->toArray(), $options); + + if (isset($cart[$productKey])) { + return $this->change($controller, $draft, $cart, $productKey, $cart[$productKey]['count'] + $count); + } + + $cartItem = $this->itemManager->addItem($draft, $product, $count, $options, $productKey); + + $this->orderLog->addEntry( + $draft->get('id'), + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'add', + 'product_id' => $id, + 'product_name' => $product->get('pagetitle'), + 'count' => $count, + 'price' => $cartItem->get('price'), + 'cost' => $cartItem->get('cost'), + ] + ); + + $this->draftManager->recalculate($draft); + $cart = $this->reloadCart($draft); + + $response = $this->invokeEvent($controller, 'msOnAddToCart', [ + 'msProduct' => $product, + 'count' => $count, + 'options' => $options, + 'product_key' => $productKey, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + + return $this->successWithCart($controller, $draft, $cart, 'ms3_cart_add_success', [ + 'last_key' => $productKey, + ], ['count' => $count]); + } + + /** + * Change product quantity in cart + * + * @param object $controller Cart facade (event payload BC) + */ + public function change( + object $controller, + msOrder $draft, + array $cart, + string $productKey, + int $count + ): array { + if (!isset($cart[$productKey])) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_change_error'); + } + + $count = (int)$count; + + if ($count <= 0) { + return $this->remove($controller, $draft, $cart, $productKey); + } + + if (!$this->itemManager->validateCount($count)) { + return $this->errorWithStatus( + $controller, + $cart, + 'ms3_cart_add_err_count', + ['count' => $count] + ); + } + + $response = $this->invokeEvent($controller, 'msOnBeforeChangeInCart', [ + 'product_key' => $productKey, + 'count' => $count, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + $count = $response['data']['count']; + + $oldCount = $cart[$productKey]['count']; + $productId = $cart[$productKey]['product_id']; + $productName = $cart[$productKey]['name'] ?? ''; + + $this->itemManager->updateItemCount($draft, $productKey, $count); + + if ($oldCount != $count) { + $this->orderLog->addEntry( + $draft->get('id'), + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'update', + 'product_id' => $productId, + 'product_name' => $productName, + 'changes' => [ + 'count' => ['old' => $oldCount, 'new' => $count], + ], + ] + ); + } + + $this->draftManager->recalculate($draft); + $cart = $this->reloadCart($draft); + + $this->invokeEvent($controller, 'msOnChangeInCart', [ + 'product_key' => $productKey, + 'count' => $count, + ]); + + return $this->successWithCart($controller, $draft, $cart, 'ms3_cart_change_success', [ + 'last_key' => $productKey, + ], ['count' => $count]); + } + + /** + * Change product options in cart + * + * @param object $controller Cart facade (event payload BC) + */ + public function changeOption( + object $controller, + msOrder $draft, + array $cart, + string $productKey, + array $options + ): array { + if (!isset($cart[$productKey])) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_change_error'); + } + + if (empty($options)) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_change_options_error'); + } + + $response = $this->invokeEvent($controller, 'msOnBeforeChangeOptionsInCart', [ + 'product_key' => $productKey, + 'options' => $options, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + if (isset($response['data']['options']) && is_array($response['data']['options'])) { + $options = $response['data']['options']; + } + + $count = $cart[$productKey]['count']; + + $item = $this->itemManager->getItemByKey($draft, $productKey); + if ($item) { + $currentOptions = $item->get('options') ?? []; + foreach ($options as $key => $value) { + if (!empty($value)) { + $currentOptions[$key] = $value; + } else { + unset($currentOptions[$key]); + } + } + + $product = $item->getOne('Product'); + if ($product) { + $newProductKey = $this->itemManager->generateProductKey($product->toArray(), $currentOptions); + + if ($newProductKey !== $productKey && isset($cart[$newProductKey])) { + $item->remove(); + return $this->change( + $controller, + $draft, + $this->reloadCart($draft), + $newProductKey, + $cart[$newProductKey]['count'] + $count + ); + } + } + } + + $newProductKey = $this->itemManager->updateItemOptions($draft, $productKey, $options); + + if (!$newProductKey) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_change_error'); + } + + $this->draftManager->recalculate($draft); + $cart = $this->reloadCart($draft); + + $this->invokeEvent($controller, 'msOnChangeOptionInCart', [ + 'old_product_key' => $productKey, + 'product_key' => $newProductKey, + 'options' => $options, + ]); + + return $this->successWithCart($controller, $draft, $cart, 'ms3_cart_change_success', [ + 'last_key' => $newProductKey, + ], ['count' => $count]); + } + + /** + * Remove product from cart + * + * @param object $controller Cart facade (event payload BC) + */ + public function remove( + object $controller, + msOrder $draft, + array $cart, + string $productKey + ): array { + if (!isset($cart[$productKey])) { + return $this->errorWithStatus($controller, $cart, 'ms3_cart_change_error'); + } + + $response = $this->invokeEvent($controller, 'msOnBeforeRemoveFromCart', [ + 'product_key' => $productKey, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + + $itemData = $this->itemManager->getItemDataForLog($draft, $productKey); + $orderId = $draft->get('id'); + + $this->itemManager->removeItem($draft, $productKey); + + $this->orderLog->addEntry( + $orderId, + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'remove', + 'product_id' => $itemData['product_id'] ?? 0, + 'product_name' => $itemData['product_name'] ?? '', + 'count' => $itemData['count'] ?? 0, + 'price' => $itemData['price'] ?? 0, + ] + ); + + if ($this->draftManager->isEmpty($draft)) { + $this->draftManager->deleteDraft($draft); + $draft = null; + $cart = []; + } else { + $this->draftManager->recalculate($draft); + $cart = $this->reloadCart($draft); + } + + $this->invokeEvent($controller, 'msOnRemoveFromCart', [ + 'product_key' => $productKey, + ]); + + return $this->successWithCart($controller, $draft, $cart, 'ms3_cart_remove_success', [ + 'last_key' => $productKey, + ]); + } + + /** + * @param object $controller Cart facade (event payload BC) + */ + protected function successWithCart( + object $controller, + ?msOrder $draft, + array $cart, + string $message, + array $data = [], + array $placeholders = [] + ): array { + return $this->ms3->utils->success($message, array_merge($data, [ + 'cart' => $cart, + 'draft' => $draft, + 'status' => $this->buildStatus($controller, $cart), + ]), $placeholders); + } + + /** + * @param object $controller Cart facade (event payload BC) + */ + protected function errorWithStatus( + object $controller, + array $cart, + string $message, + array $placeholders = [] + ): array { + return $this->ms3->utils->error( + $message, + $this->buildStatus($controller, $cart), + $placeholders + ); + } + + /** + * @param object $controller Cart facade (event payload BC) + */ + protected function buildStatus(object $controller, array $cart): array + { + $status = $this->itemManager->calculateStatus($cart); + + $response = $this->invokeEvent($controller, 'msOnGetStatusCart', [ + 'status' => $status, + ]); + + if ($response['success'] && isset($response['data']['status'])) { + return $response['data']['status']; + } + + return $status; + } + + protected function reloadCart(?msOrder $draft): array + { + if (!$draft) { + return []; + } + + return $this->itemManager->loadItems($draft); + } + + /** + * @param object $controller Cart facade (event payload BC) + */ + protected function invokeEvent(object $controller, string $eventName, array $params = []): array + { + $params['controller'] = $controller; + + return $this->ms3->utils->invokeEvent($eventName, $params); + } +} diff --git a/core/components/minishop3/src/Services/Customer/CustomerFieldManager.php b/core/components/minishop3/src/Services/Customer/CustomerFieldManager.php new file mode 100644 index 00000000..781e22f7 --- /dev/null +++ b/core/components/minishop3/src/Services/Customer/CustomerFieldManager.php @@ -0,0 +1,206 @@ +modx = $modx; + $this->ms3 = $ms3; + } + + public function setValidationRules(array $rules): void + { + $this->validationRules = $rules; + } + + public function setValidationMessages(array $messages): void + { + $this->validationMessages = $messages; + } + + /** + * Add or update customer field + */ + /** + * @param object $customer Customer facade (event payload BC) + */ + public function add(object $customer, string $token, string $key, mixed $value): array + { + if (empty($key)) { + return $this->ms3->utils->error('ms3_customer_key_empty'); + } + + $response = $this->ms3->utils->invokeEvent('msOnBeforeAddToCustomer', [ + 'key' => $key, + 'value' => $value, + 'customer' => $customer, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + $value = $response['data']['value']; + + $response = $this->validate($customer, $key, $value); + if (is_array($response)) { + return $this->ms3->utils->error($response[$key]); + } + + $validated = $response; + + $isNew = false; + $msCustomer = $this->modx->getObject(msCustomer::class, [ + 'token' => $token, + ]); + if ($msCustomer) { + $msCustomer->set($key, $validated); + } else { + $isNew = true; + $userId = 0; + + if ($this->modx->user->hasSessionContext($this->ms3->config['ctx'])) { + $userId = $this->modx->user->get('id'); + } + $msCustomer = $this->modx->newObject(msCustomer::class, [ + 'token' => $token, + $key => $validated, + 'user_id' => $userId, + ]); + } + $msCustomer->save(); + + $response = $this->ms3->utils->invokeEvent('msOnAddToCustomer', [ + 'key' => $key, + 'value' => $validated, + 'customer' => $customer, + 'msCustomer' => $msCustomer, + 'isNew' => $isNew, + ]); + if (!$response['success']) { + return $this->ms3->utils->error($response['message']); + } + + return ($validated === false) + ? $this->ms3->utils->error('', [$key => $value]) + : $this->ms3->utils->success('', [$key => $validated]); + } + + /** + * Validate customer field value + * + * @param object $customer Customer facade (event payload BC) + * @return mixed Validated value or error array keyed by field + */ + public function validate(object $customer, string $key, mixed $value): mixed + { + $response = $this->ms3->utils->invokeEvent('msOnBeforeValidateCustomerValue', [ + 'key' => $key, + 'value' => $value, + 'customer' => $customer, + ]); + if (!$response['success']) { + return [$key => $response['message']]; + } + $value = $response['data']['value']; + + if (!empty($this->validationRules[$key])) { + $validator = new Validator(); + + $validation = $validator->validate( + [$key => $value], + [$key => $this->validationRules[$key]], + $this->validationMessages + ); + + $validation->validate(); + + if ($validation->fails()) { + $errors = $validation->errors(); + + $response = $this->ms3->utils->invokeEvent('msOnErrorValidateCustomerValue', [ + 'key' => $key, + 'value' => $value, + 'errors' => $errors->firstOfAll(), + 'customer' => $customer, + ]); + + if (!$response['success']) { + return [$key => $response['message']]; + } + + $data = $response['data'] ?? []; + if (array_key_exists('errors', $data) && is_array($data['errors'])) { + return $data['errors']; + } + + return $errors->firstOfAll(); + } + } + + $response = $this->ms3->utils->invokeEvent('msOnValidateCustomerValue', [ + 'key' => $key, + 'value' => $value, + 'customer' => $customer, + ]); + if (!$response['success']) { + return [$key => $response['message']]; + } + + return $response['data']['value']; + } + + /** + * Create customer record + */ + /** + * @param object $customer Customer facade (event payload BC) + */ + public function create(object $customer, array $customerData): ?msCustomer + { + $response = $this->ms3->utils->invokeEvent('msOnBeforeCreateCustomer', [ + 'customerData' => $customerData, + 'customer' => $customer, + ]); + if (!$response['success']) { + return null; + } + $customerData = $response['data']['customerData']; + + $msCustomer = $this->modx->newObject(msCustomer::class, $customerData); + $save = $msCustomer->save(); + if (!$save) { + return null; + } + + $response = $this->ms3->utils->invokeEvent('msOnCreateCustomer', [ + 'customerData' => $customerData, + 'msCustomer' => $msCustomer, + 'customer' => $customer, + ]); + if (!$response['success']) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[CustomerFieldManager::create] msOnCreateCustomer event failed: ' . $response['message'] + ); + } + + return $msCustomer; + } +} diff --git a/core/components/minishop3/src/Services/Customer/CustomerOrderResolver.php b/core/components/minishop3/src/Services/Customer/CustomerOrderResolver.php new file mode 100644 index 00000000..2e76e221 --- /dev/null +++ b/core/components/minishop3/src/Services/Customer/CustomerOrderResolver.php @@ -0,0 +1,203 @@ +modx = $modx; + $this->ms3 = $ms3; + $this->fieldManager = $fieldManager; + } + + /** + * Get or create customer for order + */ + /** + * @param object $customer Customer facade (event/create payload BC) + */ + public function getOrCreate(object $customer, string $token, ?array $orderData = null): int + { + $response = $this->ms3->utils->invokeEvent('msOnBeforeGetOrderCustomer', [ + 'controller' => $this->ms3->order, + 'msCustomer' => null, + ]); + if (!$response['success']) { + return 0; + } + + $msCustomer = ($response['data']['msCustomer'] ?? null) instanceof msCustomer + ? $response['data']['msCustomer'] + : $this->getByToken($token); + + if (!$msCustomer) { + if ($orderData === null) { + $orderResponse = $this->ms3->order->get(); + $orderData = $orderResponse['data']['order'] ?? []; + } + + $email = $orderData['address_email'] ?? ''; + if ($email !== '') { + $msCustomer = $this->findByEmail($email); + if ($msCustomer) { + $this->attachToken($msCustomer, $token); + } + } + + if (!$msCustomer) { + $msCustomer = $this->createFromOrderData($customer, $token, $orderData); + } + } + + $response = $this->ms3->utils->invokeEvent('msOnGetOrderCustomer', [ + 'controller' => $this->ms3->order, + 'msCustomer' => $msCustomer, + ]); + if (!$response['success']) { + return 0; + } + + return $msCustomer ? (int)$msCustomer->get('id') : 0; + } + + /** + * Create customer from order data + */ + /** + * @param object $customer Customer facade (create payload BC) + */ + protected function createFromOrderData(object $customer, string $token, array $orderData): ?msCustomer + { + $email = $orderData['address_email'] ?? ''; + + if ($email === '') { + return null; + } + + $autoRegister = (bool)$this->modx->getOption('ms3_customer_auto_register_on_order', null, true); + $autoLogin = (bool)$this->modx->getOption('ms3_customer_auto_login_on_order', null, true); + $msCustomer = null; + + if ($autoRegister) { + /** @var RegisterService $registerService */ + $registerService = $this->modx->services->get('ms3_register_service'); + + if ($registerService) { + $registerResult = $registerService->register([ + 'first_name' => $orderData['address_first_name'] ?? '', + 'last_name' => $orderData['address_last_name'] ?? '', + 'phone' => $orderData['address_phone'] ?? '', + 'email' => $email, + 'token' => $token, + 'privacy_accepted' => true, + 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', + ]); + + if ($registerResult['success']) { + $msCustomer = $registerResult['customer']; + if ($msCustomer && $autoLogin) { + $this->autoLoginCustomer($msCustomer, $token); + } + } else { + $msCustomer = $this->findByEmail($email); + if ($msCustomer) { + $this->attachToken($msCustomer, $token, $autoLogin); + } + } + } + } + + if (!$msCustomer) { + $msCustomer = $this->fieldManager->create($customer, [ + 'first_name' => $orderData['address_first_name'] ?? '', + 'last_name' => $orderData['address_last_name'] ?? '', + 'phone' => $orderData['address_phone'] ?? '', + 'email' => $email, + 'token' => $token, + ]); + + if ($msCustomer && $autoLogin) { + $this->autoLoginCustomer($msCustomer, $token); + } + } + + return $msCustomer; + } + + protected function attachToken(msCustomer $msCustomer, string $token, bool $autoLogin = false): void + { + $msCustomer->set('token', $token); + $msCustomer->save(); + + if ($autoLogin) { + $this->autoLoginCustomer($msCustomer, $token); + } + } + + /** + * Auto-login customer after order creation + */ + protected function autoLoginCustomer(msCustomer $msCustomer, string $token): void + { + if (!isset($_SESSION['ms3'])) { + $_SESSION['ms3'] = []; + } + $_SESSION['ms3']['customer_id'] = $msCustomer->id; + + $currentToken = CookieHelper::getTokenFromCookie(); + if ($currentToken === '') { + $currentToken = $_SESSION['ms3']['customer_token'] ?? $token; + } + + if ($currentToken === '') { + return; + } + + $tokenObj = $this->modx->getObject(msCustomerToken::class, [ + 'token' => $currentToken, + 'type' => msCustomerToken::TYPE_API, + ]); + + if ($tokenObj) { + $tokenObj->set('customer_id', $msCustomer->id); + $tokenObj->save(); + } + + $_SESSION['ms3']['customer_token'] = $currentToken; + CookieHelper::setTokenCookie($this->modx, $currentToken); + } + + protected function findByEmail(string $email): ?msCustomer + { + if ($email === '') { + return null; + } + + return $this->modx->getObject(msCustomer::class, ['email' => $email]) ?: null; + } + + protected function getByToken(string $token): ?msCustomer + { + if ($token === '') { + return null; + } + + return $this->modx->getObject(msCustomer::class, ['token' => $token]) ?: null; + } +} diff --git a/core/components/minishop3/tests/CartCustomerFacadeStructureTest.php b/core/components/minishop3/tests/CartCustomerFacadeStructureTest.php new file mode 100644 index 00000000..f3bb190e --- /dev/null +++ b/core/components/minishop3/tests/CartCustomerFacadeStructureTest.php @@ -0,0 +1,107 @@ +getStartLine(); + $end = $method->getEndLine(); + if ($start === false || $end === false) { + return PHP_INT_MAX; + } + + return $end - $start + 1; +}; + +$maxFacadeLines = 55; + +$cartMethods = ['add', 'change', 'changeOption', 'remove']; +$cartReflection = new \ReflectionClass(Cart::class); +foreach ($cartMethods as $name) { + $lines = $methodLineCount($cartReflection->getMethod($name)); + $assertTrue( + $lines <= $maxFacadeLines, + "Cart::{$name} is {$lines} lines (max {$maxFacadeLines})" + ); +} + +$customerMethods = ['add', 'validate', 'create', 'getOrCreate']; +$customerReflection = new \ReflectionClass(Customer::class); +foreach ($customerMethods as $name) { + $lines = $methodLineCount($customerReflection->getMethod($name)); + $assertTrue( + $lines <= $maxFacadeLines, + "Customer::{$name} is {$lines} lines (max {$maxFacadeLines})" + ); +} + +$assertTrue(class_exists(CartMutationHandler::class), 'CartMutationHandler missing'); +$assertTrue(class_exists(CustomerFieldManager::class), 'CustomerFieldManager missing'); +$assertTrue(class_exists(CustomerOrderResolver::class), 'CustomerOrderResolver missing'); + +$registryReflection = new \ReflectionClass(ServiceRegistry::class); +$defaultsProp = $registryReflection->getProperty('defaultServices'); +$defaultsProp->setAccessible(true); +// Property default value without constructing ServiceRegistry (needs modX). +$defaults = $defaultsProp->getDefaultValue(); +$assertTrue(is_array($defaults), 'ServiceRegistry::$defaultServices default missing'); +foreach ( + [ + 'ms3_cart_mutation_handler', + 'ms3_customer_field_manager', + 'ms3_customer_order_resolver', + ] as $key +) { + $assertTrue(isset($defaults[$key]), "ServiceRegistry defaultServices missing {$key}"); +} + +// Logic for mutations must live in Services, not only in Controllers facades. +$cartSource = file_get_contents($cartReflection->getFileName() ?: '') ?: ''; +$assertTrue( + !str_contains($cartSource, 'msOnBeforeAddToCart'), + 'Cart facade still contains msOnBeforeAddToCart (should be in CartMutationHandler)' +); + +$handlerSource = file_get_contents( + (new \ReflectionClass(CartMutationHandler::class))->getFileName() ?: '' +) ?: ''; +$assertTrue( + str_contains($handlerSource, 'msOnBeforeAddToCart'), + 'CartMutationHandler must own msOnBeforeAddToCart' +); +$assertTrue( + str_contains($handlerSource, 'buildStatus') || str_contains($handlerSource, 'msOnGetStatusCart'), + 'CartMutationHandler must own cart status for mutation responses' +); +$assertTrue( + !str_contains($cartSource, 'statusSpreadMessages'), + 'Cart facade must not whitelist status via statusSpreadMessages' +); + +fwrite(STDOUT, "OK: Cart/Customer facade structure checks passed\n"); +exit(0);