From 7abec1d4d622a2281f779490cadc14320331c104 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Mon, 27 Jul 2026 09:56:02 +0600 Subject: [PATCH 1/6] fix: fill payment_link in status change email notifications StatusChangedNotification now resolves the online payment URL once per notification via PaymentLinkResolver and passes it to email templates. Removes the broken unused OrderStatusService::getPaymentLink helper. Closes #407 --- .../Order/StatusChangedNotification.php | 20 ++++++ .../minishop3/src/ServiceRegistry.php | 4 ++ .../src/Services/Order/OrderStatusService.php | 26 ------- .../Services/Payment/PaymentLinkResolver.php | 68 +++++++++++++++++++ .../src/Services/Payment/PaymentService.php | 4 ++ .../tests/PaymentLinkResolverTest.php | 35 ++++++++++ 6 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php create mode 100644 core/components/minishop3/tests/PaymentLinkResolverTest.php diff --git a/core/components/minishop3/src/Notifications/Order/StatusChangedNotification.php b/core/components/minishop3/src/Notifications/Order/StatusChangedNotification.php index ce30fb2f..da01d28d 100644 --- a/core/components/minishop3/src/Notifications/Order/StatusChangedNotification.php +++ b/core/components/minishop3/src/Notifications/Order/StatusChangedNotification.php @@ -7,6 +7,7 @@ use MiniShop3\Model\msOrderStatus; use MiniShop3\Model\msNotificationConfig; use MiniShop3\Services\Notification\NotificationConfigService; +use MiniShop3\Services\Payment\PaymentLinkResolver; use MiniShop3\Notifications\Notification; use MiniShop3\Notifications\Messages\EmailMessage; use MiniShop3\Notifications\Messages\TelegramMessage; @@ -24,6 +25,8 @@ class StatusChangedNotification extends Notification protected ?msOrderStatus $oldStatus; protected msOrderStatus $newStatus; protected ?NotificationConfigService $configService = null; + private ?string $paymentLink = null; + private bool $paymentLinkResolved = false; public function __construct( modX $modx, @@ -267,6 +270,11 @@ public function getPlaceholders(): array $pls['old_status_name'] = $this->modx->lexicon($oldStatusKey) ?: $oldStatusKey; } + $paymentLink = $this->resolvePaymentLink(); + if ($paymentLink !== null) { + $pls['payment_link'] = $paymentLink; + } + // Site info $pls['site_name'] = $this->modx->getOption('site_name'); $pls['site_url'] = $this->modx->getOption('site_url'); @@ -274,6 +282,18 @@ public function getPlaceholders(): array return $pls; } + private function resolvePaymentLink(): ?string + { + if (!$this->paymentLinkResolved) { + /** @var PaymentLinkResolver $resolver */ + $resolver = $this->modx->services->get('ms3_payment_link_resolver'); + $this->paymentLink = $resolver->resolveForOrder($this->order, $this->newStatus); + $this->paymentLinkResolved = true; + } + + return $this->paymentLink; + } + /** * Process subject line with placeholders * diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 04980615..afa0ddc1 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -79,6 +79,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Payment\PaymentService::class, 'interface' => null, ], + 'ms3_payment_link_resolver' => [ + 'class' => \MiniShop3\Services\Payment\PaymentLinkResolver::class, + 'interface' => null, + ], 'ms3_order_service' => [ 'class' => \MiniShop3\Services\Order\OrderService::class, 'interface' => null, diff --git a/core/components/minishop3/src/Services/Order/OrderStatusService.php b/core/components/minishop3/src/Services/Order/OrderStatusService.php index 5185b1df..0a8a271c 100644 --- a/core/components/minishop3/src/Services/Order/OrderStatusService.php +++ b/core/components/minishop3/src/Services/Order/OrderStatusService.php @@ -4,8 +4,6 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msOrder; -use MiniShop3\Model\msPayment; -use MiniShop3\Services\Payment\PaymentService; use MiniShop3\Model\msOrderAddress; use MiniShop3\Model\msOrderStatus as msOrderStatusModel; use MiniShop3\Model\msCustomer; @@ -397,28 +395,4 @@ protected function getLang(msOrder $msOrder): string return $lang; } - - /** - * Get payment link for order - */ - protected function getPaymentLink(mixed $msPayment, msOrder $msOrder): string - { - if (!$msPayment instanceof msPayment) { - return ''; - } - - $class = (string) $msPayment->get('class'); - if ($class === '') { - return ''; - } - - /** @var PaymentService $paymentService */ - $paymentService = $this->modx->services->get('ms3_payment_service'); - $controller = $paymentService->loadPaymentHandler($msPayment); - if ($controller === null) { - return ''; - } - - return $controller->getPaymentLink($msOrder) ?? ''; - } } diff --git a/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php b/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php new file mode 100644 index 00000000..7f6cde55 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php @@ -0,0 +1,68 @@ +get('id'), + (bool) $status->get('final'), + (int) $this->modx->getOption('ms3_status_paid', null, 3), + (int) $this->modx->getOption('ms3_status_new', null, 2) + ); + } + + public static function shouldResolvePaymentLink( + int $statusId, + bool $isFinal, + int $paidStatusId, + int $newStatusId + ): bool { + if ($isFinal || $statusId === $paidStatusId) { + return false; + } + + return $statusId === $newStatusId; + } + + public function resolveForOrder(msOrder $order, ?msOrderStatus $status = null): ?string + { + if ($status !== null && !$this->shouldResolveForStatus($status)) { + return null; + } + + $payment = $order->getOne('Payment'); + if (!$payment) { + return null; + } + + /** @var PaymentService $paymentService */ + $paymentService = $this->modx->services->get('ms3_payment_service'); + $controller = $paymentService->loadPaymentHandler($payment); + if ($controller === null || !method_exists($controller, 'getPaymentLink')) { + return null; + } + + $link = $controller->getPaymentLink($order); + + return $link !== null && $link !== '' ? (string) $link : null; + } +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentService.php b/core/components/minishop3/src/Services/Payment/PaymentService.php index fcfc1ad1..9a916f44 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentService.php @@ -55,6 +55,10 @@ public function loadPaymentHandler(msPayment $payment): ?PaymentProviderInterfac $class = $this->defaultControllerClass; } + if ($this->ms3) { + $this->ms3->loadCustomClasses('payment'); + } + try { $controller = new $class($this->ms3, []); diff --git a/core/components/minishop3/tests/PaymentLinkResolverTest.php b/core/components/minishop3/tests/PaymentLinkResolverTest.php new file mode 100644 index 00000000..43a3c8ec --- /dev/null +++ b/core/components/minishop3/tests/PaymentLinkResolverTest.php @@ -0,0 +1,35 @@ + Date: Mon, 27 Jul 2026 09:59:10 +0600 Subject: [PATCH 2/6] refactor: unify payment link gating and add resolve smoke test Share payStatus parsing and eligibility rules between StatusChangedNotification and ms3_get_order via PaymentLinkResolver. Add PaymentLinkResolverResolveTest with stub handler (no MODX bootstrap). --- .../elements/snippets/ms3_get_order.php | 27 +-- .../Services/Payment/PaymentLinkResolver.php | 121 ++++++++-- .../tests/PaymentLinkResolverResolveTest.php | 223 ++++++++++++++++++ .../tests/PaymentLinkResolverTest.php | 30 ++- 4 files changed, 358 insertions(+), 43 deletions(-) create mode 100644 core/components/minishop3/tests/PaymentLinkResolverResolveTest.php diff --git a/core/components/minishop3/elements/snippets/ms3_get_order.php b/core/components/minishop3/elements/snippets/ms3_get_order.php index 8b307c10..53abf488 100644 --- a/core/components/minishop3/elements/snippets/ms3_get_order.php +++ b/core/components/minishop3/elements/snippets/ms3_get_order.php @@ -2,6 +2,7 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msOrder; +use MiniShop3\Services\Payment\PaymentLinkResolver; use MiniShop3\Model\msOrderProduct; use MiniShop3\Model\msProduct; use MiniShop3\Model\msProductData; @@ -282,25 +283,17 @@ return $modx->lexicon('ms3_err_order_load'); } -if ($payment && $class = $payment->get('class')) { - $payStatuses = $modx->getOption('payStatus', $scriptProperties, '1'); - $payStatuses = array_map('trim', explode(',', $payStatuses)); +if ($payment) { + $payStatusCsv = (string) $modx->getOption('payStatus', $scriptProperties, '1'); + $eligibleStatusIds = PaymentLinkResolver::parseEligibleStatusIds($payStatusCsv); - if (in_array($msOrder->get('status_id'), $payStatuses)) { - try { - if (class_exists($class)) { - /** @var \MiniShop3\Controllers\Payment\Payment $paymentHandler */ - $paymentHandler = new $class($ms3, []); + /** @var PaymentLinkResolver $paymentLinkResolver */ + $paymentLinkResolver = $modx->services->get('ms3_payment_link_resolver'); + $orderStatus = $msOrder->getOne('Status'); + $link = $paymentLinkResolver->resolveForOrder($msOrder, $orderStatus, $eligibleStatusIds); - $link = $paymentHandler->getPaymentLink($msOrder); - - if ($link) { - $pls['payment_link'] = $link; - } - } - } catch (\Exception $e) { - $modx->log(modX::LOG_LEVEL_WARN, '[msGetOrder] Error getting payment link: ' . $e->getMessage()); - } + if ($link) { + $pls['payment_link'] = $link; } } diff --git a/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php b/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php index 7f6cde55..77377db8 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php +++ b/core/components/minishop3/src/Services/Payment/PaymentLinkResolver.php @@ -4,48 +4,114 @@ use MiniShop3\Model\msOrder; use MiniShop3\Model\msOrderStatus; -use MODX\Revolution\modX; +use MiniShop3\Model\msPayment; /** * Resolves online payment URLs for order emails and storefront views. + * + * Eligible status IDs: + * - notifications: system setting {@see ms3_payment_link_statuses} (CSV), fallback {@see ms3_status_new} + * - ms3_get_order snippet: property `payStatus` (CSV), same parsing via {@see parseEligibleStatusIds()} */ final class PaymentLinkResolver { + /** + * @param modX|object $modx MODX instance (plain object in smoke tests) + */ public function __construct( - private modX $modx, + private object $modx, + private ?object $paymentServiceOverride = null, ) { } - public function shouldResolveForStatus(?msOrderStatus $status): bool + /** + * @return int[] + */ + public static function parseEligibleStatusIds(string $csv): array { - if ($status === null) { - return false; + if ($csv === '') { + return []; } - return self::shouldResolvePaymentLink( - (int) $status->get('id'), - (bool) $status->get('final'), - (int) $this->modx->getOption('ms3_status_paid', null, 3), - (int) $this->modx->getOption('ms3_status_new', null, 2) - ); + $ids = []; + foreach (explode(',', $csv) as $part) { + $part = trim($part); + if ($part === '' || !ctype_digit($part)) { + continue; + } + $ids[] = (int) $part; + } + + return array_values(array_unique($ids)); } - public static function shouldResolvePaymentLink( + public static function isStatusEligibleForPaymentLink( int $statusId, bool $isFinal, int $paidStatusId, - int $newStatusId + array $eligibleStatusIds ): bool { if ($isFinal || $statusId === $paidStatusId) { return false; } - return $statusId === $newStatusId; + return in_array($statusId, $eligibleStatusIds, true); + } + + public static function normalizePaymentLink(?string $link): ?string + { + return $link !== null && $link !== '' ? (string) $link : null; + } + + /** + * @return int[] + */ + public function getEligibleStatusIds(): array + { + $csv = (string) $this->modx->getOption('ms3_payment_link_statuses', null, ''); + $ids = self::parseEligibleStatusIds($csv); + if ($ids !== []) { + return $ids; + } + + return [(int) $this->modx->getOption('ms3_status_new', null, 2)]; } - public function resolveForOrder(msOrder $order, ?msOrderStatus $status = null): ?string + public function shouldResolveForStatus(?msOrderStatus $status): bool { - if ($status !== null && !$this->shouldResolveForStatus($status)) { + if ($status === null) { + return false; + } + + return self::isStatusEligibleForPaymentLink( + (int) $status->get('id'), + (bool) $status->get('final'), + (int) $this->modx->getOption('ms3_status_paid', null, 3), + $this->getEligibleStatusIds() + ); + } + + /** + * @param int[]|null $eligibleStatusIds Override eligible statuses (e.g. snippet payStatus property) + */ + public function resolveForOrder( + msOrder $order, + ?msOrderStatus $status = null, + ?array $eligibleStatusIds = null + ): ?string { + $eligibleStatusIds ??= $this->getEligibleStatusIds(); + $paidStatusId = (int) $this->modx->getOption('ms3_status_paid', null, 3); + + if ($status !== null) { + if (!self::isStatusEligibleForPaymentLink( + (int) $status->get('id'), + (bool) $status->get('final'), + $paidStatusId, + $eligibleStatusIds + )) { + return null; + } + } elseif (!in_array((int) $order->get('status_id'), $eligibleStatusIds, true)) { return null; } @@ -54,15 +120,28 @@ public function resolveForOrder(msOrder $order, ?msOrderStatus $status = null): return null; } - /** @var PaymentService $paymentService */ - $paymentService = $this->modx->services->get('ms3_payment_service'); - $controller = $paymentService->loadPaymentHandler($payment); + $controller = $this->loadPaymentHandler($payment); if ($controller === null || !method_exists($controller, 'getPaymentLink')) { return null; } - $link = $controller->getPaymentLink($order); + return self::normalizePaymentLink($controller->getPaymentLink($order)); + } - return $link !== null && $link !== '' ? (string) $link : null; + private function loadPaymentHandler(object $payment): ?object + { + if ($this->paymentServiceOverride !== null) { + return $this->paymentServiceOverride->loadPaymentHandler($payment); + } + + return $this->paymentService()->loadPaymentHandler($payment); + } + + private function paymentService(): PaymentService + { + /** @var PaymentService $paymentService */ + $paymentService = $this->modx->services->get('ms3_payment_service'); + + return $paymentService; } } diff --git a/core/components/minishop3/tests/PaymentLinkResolverResolveTest.php b/core/components/minishop3/tests/PaymentLinkResolverResolveTest.php new file mode 100644 index 00000000..312e10f4 --- /dev/null +++ b/core/components/minishop3/tests/PaymentLinkResolverResolveTest.php @@ -0,0 +1,223 @@ +payment : null; + } + }; +}; + +$makeStatus = static function (int $id, bool $final): msOrderStatus { + return new class($id, $final) extends msOrderStatus { + public function __construct(private int $statusId, private bool $isFinal) + { + } + + public function get($k, $format = null, $formatType = '') + { + return match ($k) { + 'id' => $this->statusId, + 'final' => $this->isFinal ? 1 : 0, + default => null, + }; + } + }; +}; + +$makePayment = static function (): object { + return new class() { + public function get($k, $format = null, $formatType = '') + { + return $k === 'class' ? StubPaymentHandler::class : null; + } + }; +}; + +$makeHandler = static function (?string $link): PaymentProviderInterface { + return new class($link) implements PaymentProviderInterface { + public function __construct(private ?string $link) + { + } + + public function send(msOrder $order): array + { + return ['success' => true, 'data' => ['payment_link' => $this->link]]; + } + + public function receive(msOrder $order): array + { + return ['success' => true]; + } + + public function getCost(msOrder $order, msPayment $payment, float $cost): float + { + return 0.0; + } + + public function getOrderHash(msOrder $order): string + { + return 'hash'; + } + + public function getPaymentLink(msOrder $order): ?string + { + return $this->link; + } + }; +}; + +$modx = new class() { + public function getOption($key, $options = null, $default = null, $skipEmpty = false) + { + return match ($key) { + 'ms3_status_paid' => 3, + 'ms3_status_new' => 2, + default => $default, + }; + } +}; + +$paymentService = new class($makeHandler('https://pay.example/order-1')) { + public function __construct(private PaymentProviderInterface $handler) + { + } + + public function loadPaymentHandler(object $payment): ?PaymentProviderInterface + { + return $this->handler; + } +}; + +$resolver = new PaymentLinkResolver($modx, $paymentService); +$order = $makeOrder($makePayment()); +$status = $makeStatus(2, false); + +$assertSame( + 'https://pay.example/order-1', + $resolver->resolveForOrder($order, $status, [2]), + 'eligible status returns payment link' +); + +$assertSame( + null, + $resolver->resolveForOrder($order, $makeStatus(3, false), [2]), + 'paid status id blocked even if in payStatus list mistake' +); + +$assertSame( + null, + $resolver->resolveForOrder($order, $makeStatus(2, true), [2]), + 'final status blocked' +); + +$assertSame( + null, + $resolver->resolveForOrder($makeOrder(null), $status, [2]), + 'missing payment returns null' +); + +$emptyHandlerService = new class($makeHandler('')) { + public function __construct(private PaymentProviderInterface $handler) + { + } + + public function loadPaymentHandler(object $payment): ?PaymentProviderInterface + { + return $this->handler; + } +}; + +$emptyResolver = new PaymentLinkResolver($modx, $emptyHandlerService); +$assertSame( + null, + $emptyResolver->resolveForOrder($order, $status, [2]), + 'empty link normalized to null' +); + +fwrite(STDOUT, "OK PaymentLinkResolverResolveTest\n"); +exit(0); + +final class StubPaymentHandler implements PaymentProviderInterface +{ + public function send(msOrder $order): array + { + return ['success' => true]; + } + + public function receive(msOrder $order): array + { + return ['success' => true]; + } + + public function getCost(msOrder $order, msPayment $payment, float $cost): float + { + return 0.0; + } + + public function getOrderHash(msOrder $order): string + { + return 'hash'; + } +} + +} // namespace diff --git a/core/components/minishop3/tests/PaymentLinkResolverTest.php b/core/components/minishop3/tests/PaymentLinkResolverTest.php index 43a3c8ec..c00686ce 100644 --- a/core/components/minishop3/tests/PaymentLinkResolverTest.php +++ b/core/components/minishop3/tests/PaymentLinkResolverTest.php @@ -25,11 +25,31 @@ $paidId = 3; $newId = 2; - -$assertSame(true, PaymentLinkResolver::shouldResolvePaymentLink($newId, false, $paidId, $newId), 'new status allows link'); -$assertSame(false, PaymentLinkResolver::shouldResolvePaymentLink($paidId, false, $paidId, $newId), 'paid status blocks link'); -$assertSame(false, PaymentLinkResolver::shouldResolvePaymentLink(4, true, $paidId, $newId), 'final status blocks link'); -$assertSame(false, PaymentLinkResolver::shouldResolvePaymentLink(5, false, $paidId, $newId), 'other non-final status blocks link'); +$eligible = [$newId]; + +$assertSame(true, PaymentLinkResolver::isStatusEligibleForPaymentLink($newId, false, $paidId, $eligible), 'new status allows link'); +$assertSame(false, PaymentLinkResolver::isStatusEligibleForPaymentLink($paidId, false, $paidId, $eligible), 'paid status blocks link'); +$assertSame(false, PaymentLinkResolver::isStatusEligibleForPaymentLink(4, true, $paidId, $eligible), 'final status blocks link'); +$assertSame(false, PaymentLinkResolver::isStatusEligibleForPaymentLink(5, false, $paidId, $eligible), 'other non-final status blocks link'); + +$assertSame([1, 2], PaymentLinkResolver::parseEligibleStatusIds('1, 2 ,2'), 'parse csv dedupes'); +$assertSame([], PaymentLinkResolver::parseEligibleStatusIds(''), 'empty csv'); +$assertSame([2], PaymentLinkResolver::parseEligibleStatusIds('2, draft'), 'skip non-numeric parts'); + +$assertSame('https://pay.example/1', PaymentLinkResolver::normalizePaymentLink('https://pay.example/1'), 'normalize link'); +$assertSame(null, PaymentLinkResolver::normalizePaymentLink(''), 'empty link is null'); +$assertSame(null, PaymentLinkResolver::normalizePaymentLink(null), 'null link stays null'); + +$snippetSrc = file_get_contents(__DIR__ . '/../elements/snippets/ms3_get_order.php'); +if ($snippetSrc === false) { + $fail('unable to read ms3_get_order.php'); +} +if (!str_contains($snippetSrc, 'ms3_payment_link_resolver')) { + $fail('ms3_get_order.php must use ms3_payment_link_resolver'); +} +if (!str_contains($snippetSrc, 'PaymentLinkResolver::parseEligibleStatusIds')) { + $fail('ms3_get_order.php must parse payStatus via PaymentLinkResolver'); +} fwrite(STDOUT, "OK PaymentLinkResolverTest\n"); exit(0); From 963535cb032e3f472d5b3128b5e0d149cca8cdaf Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 29 Jul 2026 17:57:28 +0600 Subject: [PATCH 3/6] fix(ci): drop loadCustomClasses, obsolete payment-link test, baseline --- .../src/Services/Payment/PaymentService.php | 4 - .../OrderStatusServiceGetPaymentLinkTest.php | 134 ------------------ .../stubs/CategoryProductScopeModxStub.php | 3 - .../minishop3/tests/stubs/ModxStub.php | 3 + phpstan-baseline.neon | 2 +- 5 files changed, 4 insertions(+), 142 deletions(-) delete mode 100644 core/components/minishop3/tests/OrderStatusServiceGetPaymentLinkTest.php diff --git a/core/components/minishop3/src/Services/Payment/PaymentService.php b/core/components/minishop3/src/Services/Payment/PaymentService.php index 9a916f44..fcfc1ad1 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentService.php @@ -55,10 +55,6 @@ public function loadPaymentHandler(msPayment $payment): ?PaymentProviderInterfac $class = $this->defaultControllerClass; } - if ($this->ms3) { - $this->ms3->loadCustomClasses('payment'); - } - try { $controller = new $class($this->ms3, []); diff --git a/core/components/minishop3/tests/OrderStatusServiceGetPaymentLinkTest.php b/core/components/minishop3/tests/OrderStatusServiceGetPaymentLinkTest.php deleted file mode 100644 index f8967c6b..00000000 --- a/core/components/minishop3/tests/OrderStatusServiceGetPaymentLinkTest.php +++ /dev/null @@ -1,134 +0,0 @@ -setAccessible(true); - - return $method->invoke($service, $msPayment, $msOrder); -}; - -$modx = new class extends modX { - public object $services; - public object $lexicon; - - public function __construct() - { - parent::__construct(); - $this->lexicon = new class { - public function load(string ...$topics): void - { - } - }; - $this->services = new class { - /** @var array */ - private array $services = []; - - public function register(string $key, object $service): void - { - $this->services[$key] = $service; - } - - public function has(string $key): bool - { - return isset($this->services[$key]); - } - - public function get(string $key): object - { - return $this->services[$key]; - } - }; - } -}; - -$ms3 = new class($modx) extends MiniShop3 { - public function __construct(modX $modx) - { - $this->modx = $modx; - } -}; - -$paymentService = new PaymentService($modx); -$ms3Property = new ReflectionProperty(PaymentService::class, 'ms3'); -$ms3Property->setAccessible(true); -$ms3Property->setValue($paymentService, $ms3); - -$modx->services->register('ms3', $ms3); -$modx->services->register('ms3_payment_service', $paymentService); - -$orderLog = new OrderLogService($modx, $ms3); -$service = new OrderStatusService($modx, $ms3, $orderLog); - -$msOrder = new StubMsOrder(['id' => 42]); - -$link = $invokeGetPaymentLink( - $service, - new StubMsPayment(['class' => StubPaymentLinkProvider::class, 'id' => 1]), - $msOrder -); -$assertSame(StubPaymentLinkProvider::PAYMENT_LINK, $link, 'payment link via PaymentService'); - -$emptyForScalar = $invokeGetPaymentLink($service, new stdClass(), $msOrder); -$assertSame('', $emptyForScalar, 'non-msPayment returns empty string'); - -$emptyForMissingClass = $invokeGetPaymentLink( - $service, - new StubMsPayment(['class' => 'MiniShop3\\Tests\\Stubs\\MissingPaymentProvider', 'id' => 2]), - $msOrder -); -$assertSame('', $emptyForMissingClass, 'missing handler returns empty string'); - -$emptyForBlankClass = $invokeGetPaymentLink( - $service, - new StubMsPayment(['class' => '', 'id' => 3]), - $msOrder -); -$assertSame('', $emptyForBlankClass, 'empty payment class returns empty string'); - -fwrite(STDOUT, "OK OrderStatusServiceGetPaymentLinkTest\n"); -exit(0); diff --git a/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php b/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php index 98d1d7d6..b40116f2 100644 --- a/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php +++ b/core/components/minishop3/tests/stubs/CategoryProductScopeModxStub.php @@ -13,9 +13,6 @@ */ class CategoryProductScopeModxStub extends modX { - /** @var object */ - public object $services; - /** @var list */ public array $products = []; diff --git a/core/components/minishop3/tests/stubs/ModxStub.php b/core/components/minishop3/tests/stubs/ModxStub.php index 829ff072..d8b02dd9 100644 --- a/core/components/minishop3/tests/stubs/ModxStub.php +++ b/core/components/minishop3/tests/stubs/ModxStub.php @@ -22,6 +22,9 @@ class modX /** @var array */ private array $permissions = []; + /** @var object|null */ + public $services; + public function __construct() { $this->context = new class { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f753fe59..acfcd745 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -123,7 +123,7 @@ parameters: - message: '#^Access to property \$services on an unknown class modX\.$#' identifier: class.notFound - count: 4 + count: 5 path: core/components/minishop3/elements/snippets/ms3_get_order.php - From 1a0b340f4f767bd32ac728379c62d1eae59f3839 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 2 Aug 2026 09:06:09 +0600 Subject: [PATCH 4/6] test(integration): align anonymous lexicon with ModxStub signature Fix PHP 8.2 fatal when modX stub declares typed lexicon(). --- .../tests/Integration/Cart/CartFacadeDraftStoreTest.php | 2 +- .../tests/Integration/Order/OrderFinalizeServiceTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php b/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php index e242d17d..d7c04756 100644 --- a/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php +++ b/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php @@ -149,7 +149,7 @@ public function getOption($key, $options = null, $default = null, $skipEmpty = f }; } - public function lexicon($key, $params = []) + public function lexicon(string $key, array $params = []): string { return (string) $key; } diff --git a/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php b/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php index 2b2d09a6..7d31f83d 100644 --- a/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php +++ b/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php @@ -285,7 +285,7 @@ public function getIterator($className, $criteria = null, $cacheFlag = true): \A return new \ArrayIterator([]); } - public function lexicon($key, $params = []) + public function lexicon(string $key, array $params = []): string { return (string) $key; } From d55eeec9ee2048a4f24b4d8e226d183940949456 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 2 Aug 2026 09:09:19 +0600 Subject: [PATCH 5/6] test(integration): drop typed property redeclare on modX doubles Anonymous subclasses must not redeclare parent properties with types when ModxStub uses untyped $services (PHP 8.2 fatal). --- .../tests/Integration/Cart/CartFacadeDraftStoreTest.php | 2 -- .../tests/Integration/Order/OrderFinalizeServiceTest.php | 1 - 2 files changed, 3 deletions(-) diff --git a/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php b/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php index d7c04756..c29b5ca8 100644 --- a/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php +++ b/core/components/minishop3/tests/Integration/Cart/CartFacadeDraftStoreTest.php @@ -108,8 +108,6 @@ public function invokeEvent(string $eventName, array $params = []): array $orderService = new OrderService(new modX()); $modx = new class ($store, $product, $orderService) extends modX { - public object $services; - public object $lexicon; public function __construct( private DraftProductStore $store, diff --git a/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php b/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php index 7d31f83d..5549a826 100644 --- a/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php +++ b/core/components/minishop3/tests/Integration/Order/OrderFinalizeServiceTest.php @@ -190,7 +190,6 @@ public function change(int $orderId, int $statusId, bool $writeLog = true): true }; $modx = new class ($orders, $productCount, $products, $delivery, $orderService, $statusService) extends modX { - public object $services; /** * @param array $orders From d2854d26abfb792841ed43a3b1ddfd96dd16fda4 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 2 Aug 2026 09:12:01 +0600 Subject: [PATCH 6/6] test(integration): remove modX property redeclares for PHP 8.2 Drop typed $services/$lexicon on anonymous modX subclasses when ModxStub already declares untyped parent properties. --- .../tests/Integration/Order/OrderDraftCostClampTest.php | 1 - .../tests/Integration/Product/ProductDataPayloadTraitTest.php | 3 --- .../Integration/Product/UpdateProductDataPermissionsTest.php | 2 -- 3 files changed, 6 deletions(-) diff --git a/core/components/minishop3/tests/Integration/Order/OrderDraftCostClampTest.php b/core/components/minishop3/tests/Integration/Order/OrderDraftCostClampTest.php index 97005431..ef3e5a03 100644 --- a/core/components/minishop3/tests/Integration/Order/OrderDraftCostClampTest.php +++ b/core/components/minishop3/tests/Integration/Order/OrderDraftCostClampTest.php @@ -28,7 +28,6 @@ protected function setUp(): void $orderService = new OrderService(new modX()); $modx = new class ($orderService) extends modX { - public object $services; public function __construct(OrderService $orderService) { diff --git a/core/components/minishop3/tests/Integration/Product/ProductDataPayloadTraitTest.php b/core/components/minishop3/tests/Integration/Product/ProductDataPayloadTraitTest.php index a6f498b7..418673d4 100644 --- a/core/components/minishop3/tests/Integration/Product/ProductDataPayloadTraitTest.php +++ b/core/components/minishop3/tests/Integration/Product/ProductDataPayloadTraitTest.php @@ -64,7 +64,6 @@ public function testApplyWhitelistsFlatAndNestedFieldsAndKeepsNull(): void private function xpdoWithoutMs3(): xPDO { return new class extends xPDO { - public object $services; public function __construct() { @@ -126,7 +125,6 @@ public function __construct(modX $modx, array $properties, ?object $object = nul $this->properties = $properties; $this->object = $object ?? new LightweightProduct( new RecordingProductData(new class extends xPDO { - public object $services; public function __construct() { @@ -141,7 +139,6 @@ public function has(string $key): bool ); $this->modx = new class extends modX { - public object $services; public function __construct() { diff --git a/core/components/minishop3/tests/Integration/Product/UpdateProductDataPermissionsTest.php b/core/components/minishop3/tests/Integration/Product/UpdateProductDataPermissionsTest.php index fd765f73..cb249bd0 100644 --- a/core/components/minishop3/tests/Integration/Product/UpdateProductDataPermissionsTest.php +++ b/core/components/minishop3/tests/Integration/Product/UpdateProductDataPermissionsTest.php @@ -86,7 +86,6 @@ public function testDropsUnknownKeysAndSavesAllowlistedFields(): void private function makeService(bool $hasSaveDocument = true, ?UpdatableProduct $product = null): TestableProductDataService { $modx = new class ($hasSaveDocument, $product) extends modX { - public object $services; public function __construct( private bool $hasSaveDocument, @@ -130,7 +129,6 @@ public function getObject($className, $criteria = null, $cacheFlag = true) private function xpdo(): xPDO { return new class extends xPDO { - public object $services; public function __construct() {