diff --git a/_build/elements/settings.php b/_build/elements/settings.php
index c8c371cd..fa5958b2 100644
--- a/_build/elements/settings.php
+++ b/_build/elements/settings.php
@@ -296,6 +296,16 @@
'xtype' => 'numberfield',
'area' => 'ms3_statuses',
],
+ 'ms3_status_sent' => [
+ 'value' => 4,
+ 'xtype' => 'numberfield',
+ 'area' => 'ms3_statuses',
+ ],
+ 'ms3_order_status_transitions' => [
+ 'value' => '',
+ 'xtype' => 'textfield',
+ 'area' => 'ms3_statuses',
+ ],
'ms3_customer_cancel_allowed_statuses' => [
'value' => '2,3',
'xtype' => 'textfield',
diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php
index 756f3837..5621a333 100644
--- a/core/components/minishop3/lexicon/en/default.inc.php
+++ b/core/components/minishop3/lexicon/en/default.inc.php
@@ -193,6 +193,9 @@
$_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.';
$_lang['ms3_err_status_wrong'] = 'Invalid order status.';
$_lang['ms3_err_status_same'] = 'This status is already set.';
+$_lang['ms3_err_status_transition'] = 'This status transition is not allowed.';
+$_lang['ms3_err_status_transitions_invalid'] = 'Order status transition allow-list is invalid.';
+$_lang['ms3_err_status_rollback'] = 'Failed to roll back order status after a rejected transition.';
$_lang['ms3_err_register_globals'] = 'Error: php parameter register_globals must be disabled.';
$_lang['ms3_err_link_equal'] = 'You are trying to add product link to itself';
$_lang['ms3_err_no_link'] = 'Link type not found';
diff --git a/core/components/minishop3/lexicon/en/setting.inc.php b/core/components/minishop3/lexicon/en/setting.inc.php
index a82d6193..892dbc97 100644
--- a/core/components/minishop3/lexicon/en/setting.inc.php
+++ b/core/components/minishop3/lexicon/en/setting.inc.php
@@ -139,6 +139,10 @@
$_lang['setting_ms3_status_paid_desc'] = 'What status to set after order payment';
$_lang['setting_ms3_status_canceled'] = 'Canceled order status ID';
$_lang['setting_ms3_status_canceled_desc'] = 'What status to set when canceling order';
+$_lang['setting_ms3_status_sent'] = 'Shipped / sent order status ID';
+$_lang['setting_ms3_status_sent_desc'] = 'Status ID treated as shipped for order lifecycle ports (default seed: 4).';
+$_lang['setting_ms3_order_status_transitions'] = 'Allowed order status transitions';
+$_lang['setting_ms3_order_status_transitions_desc'] = 'Optional allow-list of status edges in addition to final/fixed rules. Empty = no matrix (default final/fixed only). Format: CSV pairs from:to (e.g. 2:3,3:4,2:5) or JSON [[2,3],[3,4]].';
$_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Statuses from which customer can cancel order';
$_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'Comma-separated status IDs. Default: New and Paid (2,3). Empty = use ms3_status_new and ms3_status_paid.';
$_lang['setting_ms3_status_for_stat'] = 'Status IDs for statistics';
diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php
index 26ab5683..ea117911 100644
--- a/core/components/minishop3/lexicon/ru/default.inc.php
+++ b/core/components/minishop3/lexicon/ru/default.inc.php
@@ -193,6 +193,9 @@
$_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.';
$_lang['ms3_err_status_wrong'] = 'Неверный статус заказа.';
$_lang['ms3_err_status_same'] = 'Этот статус уже установлен.';
+$_lang['ms3_err_status_transition'] = 'Такой переход статуса не разрешён.';
+$_lang['ms3_err_status_transitions_invalid'] = 'Некорректный allow-list переходов статусов заказа.';
+$_lang['ms3_err_status_rollback'] = 'Не удалось откатить статус заказа после отклонённого перехода.';
$_lang['ms3_err_register_globals'] = 'Ошибка: php параметр register_globals должен быть выключен.';
$_lang['ms3_err_link_equal'] = 'Вы пытаетесь добавить товару ссылку на самого себя';
$_lang['ms3_err_no_link'] = 'Тип связи не найден';
diff --git a/core/components/minishop3/lexicon/ru/setting.inc.php b/core/components/minishop3/lexicon/ru/setting.inc.php
index 88678f60..a1aea93f 100644
--- a/core/components/minishop3/lexicon/ru/setting.inc.php
+++ b/core/components/minishop3/lexicon/ru/setting.inc.php
@@ -139,6 +139,10 @@
$_lang['setting_ms3_status_paid_desc'] = 'Какой статус нужно устанавливать после оплаты заказа';
$_lang['setting_ms3_status_canceled'] = 'ID статуса отмены заказа';
$_lang['setting_ms3_status_canceled_desc'] = 'Какой статус нужно устанавливать при отмене заказа';
+$_lang['setting_ms3_status_sent'] = 'ID статуса «Отправлен»';
+$_lang['setting_ms3_status_sent_desc'] = 'Статус, при котором срабатывает порт отгрузки в lifecycle (по умолчанию seed id 4).';
+$_lang['setting_ms3_order_status_transitions'] = 'Разрешённые переходы статусов заказа';
+$_lang['setting_ms3_order_status_transitions_desc'] = 'Опциональный allow-list рёбер поверх правил final/fixed. Пусто — только final/fixed. Формат: CSV пары from:to (например 2:3,3:4,2:5) или JSON [[2,3],[3,4]].';
$_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Статусы, из которых покупатель может отменить заказ';
$_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'ID статусов через запятую. По умолчанию: «Новый» и «Оплачен» (2,3). Пусто — использовать ms3_status_new и ms3_status_paid.';
$_lang['setting_ms3_status_for_stat'] = 'ID статусов для статистики';
diff --git a/core/components/minishop3/src/Controllers/Payment/Payment.php b/core/components/minishop3/src/Controllers/Payment/Payment.php
index a0222b8b..38f21d4c 100644
--- a/core/components/minishop3/src/Controllers/Payment/Payment.php
+++ b/core/components/minishop3/src/Controllers/Payment/Payment.php
@@ -62,15 +62,32 @@
* }
*
* if ($data['status'] === 'succeeded') {
- * $order->set('status_id', $this->getPaidStatusId());
- * $order->save();
- * return $this->success('Payment confirmed');
+ * // Non-draft status changes must go through OrderStatusService (issue #592).
+ * // Prefer PaymentLifecycle (#590) when available; until then:
+ * $status = $this->modx->services->get('ms3_order_status');
+ * $result = $status->change(
+ * (int) $order->get('id'),
+ * $this->getPaidStatusId(),
+ * false,
+ * ['idempotent' => true]
+ * );
+ * return $result === true
+ * ? $this->success('Payment confirmed')
+ * : $this->error((string) $result);
* }
*
* if ($data['status'] === 'canceled') {
- * $order->set('status_id', $this->getCanceledStatusId());
- * $order->save();
- * return $this->error('Payment canceled');
+ * $status = $this->modx->services->get('ms3_order_status');
+ * $result = $status->change(
+ * (int) $order->get('id'),
+ * $this->getCanceledStatusId(),
+ * false,
+ * ['idempotent' => true]
+ * );
+ * // ACK the webhook even when payment was canceled; distinguish transport vs business.
+ * return $result === true
+ * ? $this->success('Payment canceled')
+ * : $this->error((string) $result);
* }
*
* return $this->error('Payment failed');
diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php
index 296706fd..af78ac14 100644
--- a/core/components/minishop3/src/ServiceRegistry.php
+++ b/core/components/minishop3/src/ServiceRegistry.php
@@ -107,7 +107,7 @@ class ServiceRegistry
'ms3_order_number_generator',
],
'ms3_order_finalize' => ['ms3_order_number_generator'],
- 'ms3_order_status' => ['ms3_order_log'],
+ 'ms3_order_status' => ['ms3_order_log', 'ms3_order_lifecycle_ports'],
'ms3_cart_mutation_handler' => [
'ms3_order_draft_manager',
'ms3_cart_item_manager',
@@ -256,6 +256,10 @@ class ServiceRegistry
'class' => \MiniShop3\Services\Order\OrderLogService::class,
'interface' => null,
],
+ 'ms3_order_lifecycle_ports' => [
+ 'class' => \MiniShop3\Services\Order\NullOrderLifecyclePorts::class,
+ 'interface' => \MiniShop3\Services\Order\OrderLifecyclePortsInterface::class,
+ ],
'ms3_order_status' => [
'class' => \MiniShop3\Services\Order\OrderStatusService::class,
'interface' => null,
@@ -688,19 +692,16 @@ protected function validateClass(
return $fallbackClass;
}
- if ($requiredInterface) {
- $interfaces = class_implements($className);
- if (!in_array($requiredInterface, $interfaces ?: [])) {
- $this->modx->log(
- modX::LOG_LEVEL_ERROR,
- "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, "
- . 'using fallback'
- );
- return $fallbackClass;
- }
+ if ($requiredInterface && !is_a($className, $requiredInterface, true)) {
+ $this->modx->log(
+ modX::LOG_LEVEL_ERROR,
+ "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, "
+ . 'using fallback'
+ );
+ return $fallbackClass;
}
- if (!is_subclass_of($className, $fallbackClass)) {
+ if (!$requiredInterface && !is_subclass_of($className, $fallbackClass)) {
$this->modx->log(
modX::LOG_LEVEL_ERROR,
"[MiniShop3 ServiceRegistry] Class '{$className}' must extend {$fallbackClass}, using fallback"
diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php
index 03585364..135e29b6 100644
--- a/core/components/minishop3/src/ServiceRegistryFactories.php
+++ b/core/components/minishop3/src/ServiceRegistryFactories.php
@@ -147,11 +147,16 @@ public static function map(): array
);
},
+ 'ms3_order_lifecycle_ports' => static function (modX $modx, object $services, string $class): object {
+ return new $class();
+ },
+
'ms3_order_status' => static function (modX $modx, object $services, string $class): object {
return new $class(
$modx,
self::ms3($modx),
- $services->get('ms3_order_log')
+ $services->get('ms3_order_log'),
+ $services->get('ms3_order_lifecycle_ports')
);
},
diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php
index 769f417e..f681cfae 100644
--- a/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php
+++ b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php
@@ -201,12 +201,19 @@ public function update(array $params = []): array
// Store old values for logging
$oldStatusId = (int)$order->get('status_id');
+ $pendingStatusId = array_key_exists('status_id', $params)
+ ? (int) $params['status_id']
+ : null;
// Get editable order fields from msModelField configuration
$orderFields = $this->presenter->getModelFieldNames('msOrder');
$changedOrderFields = [];
foreach ($orderFields as $field) {
+ // Non-draft status changes go only through OrderStatusService (#592).
+ if ($field === 'status_id') {
+ continue;
+ }
if (array_key_exists($field, $params)) {
$oldValue = $order->get($field);
$newValue = $params[$field];
@@ -257,8 +264,7 @@ public function update(array $params = []): array
return $this->error('Failed to update order', HttpStatus::INTERNAL_SERVER_ERROR);
}
- // Log order field changes (excluding status_id which is logged separately)
- unset($changedOrderFields['status_id']);
+ // Log order field changes (status_id is logged by OrderStatusService)
if (!empty($changedOrderFields)) {
$this->getOrderLog()->addEntry(
$id,
@@ -325,15 +331,10 @@ public function update(array $params = []): array
}
// Handle status change via OrderStatusService (sends notifications)
- $newStatusId = (int)$order->get('status_id');
- if ($oldStatusId !== $newStatusId) {
- // Revert status to old value - OrderStatusService will change it properly
- $order->set('status_id', $oldStatusId);
- $order->save();
-
+ if ($pendingStatusId !== null && $pendingStatusId !== $oldStatusId) {
/** @var OrderStatusService $orderStatusService */
$orderStatusService = $this->modx->services->get('ms3_order_status');
- $result = $orderStatusService->change((int)$order->get('id'), $newStatusId);
+ $result = $orderStatusService->change((int)$order->get('id'), $pendingStatusId);
if ($result !== true) {
return $this->error(
diff --git a/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php b/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php
new file mode 100644
index 00000000..bc4df83d
--- /dev/null
+++ b/core/components/minishop3/src/Services/Order/NullOrderLifecyclePorts.php
@@ -0,0 +1,28 @@
+modx = $modx;
$this->ms3 = $ms3;
$this->orderLog = $orderLog;
+ $this->lifecyclePorts = $lifecyclePorts ?? new NullOrderLifecyclePorts();
$this->modx->lexicon->load('minishop3:default');
}
@@ -70,15 +84,22 @@ public function getAllowedCancelStatusIds(): array
}
/**
- * Switch order status
+ * Switch order status (single gate for non-draft transitions).
*
* @param int $orderId The id of msOrder
* @param int $statusId The id of msOrderStatus
* @param bool $skipNotifications Skip sending notifications (for admin finalization)
+ * @param array{idempotent?: bool} $options idempotent=true → same status is success no-op
* @return bool|string True on success, error message on failure
*/
- public function change(int $orderId, int $statusId, bool $skipNotifications = false): bool|string
- {
+ public function change(
+ int $orderId,
+ int $statusId,
+ bool $skipNotifications = false,
+ array $options = []
+ ): bool|string {
+ $idempotent = !empty($options['idempotent']);
+
/** @var msOrder|null $msOrder */
$msOrder = $this->modx->getObject(msOrder::class, ['id' => $orderId]);
if (!$msOrder) {
@@ -95,26 +116,26 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa
return $this->modx->lexicon('ms3_err_status_nf');
}
+ $storedStatusId = $msOrder->get('status_id');
+ $previousStatusId = $storedStatusId !== null ? (int) $storedStatusId : null;
+
/** @var msOrderStatusModel|null $oldStatus */
- $oldStatus = $this->modx->getObject(
- msOrderStatusModel::class,
- ['id' => $msOrder->get('status_id'), 'active' => 1]
- );
+ $oldStatus = $previousStatusId !== null
+ ? $this->modx->getObject(msOrderStatusModel::class, ['id' => $previousStatusId])
+ : null;
- if ($oldStatus) {
- $transitionError = $this->validateStatusTransition($oldStatus, $status);
- if ($transitionError !== null) {
- return $transitionError;
- }
+ if ($previousStatusId === $statusId) {
+ return $idempotent ? true : $this->modx->lexicon('ms3_err_status_same');
}
- if ($msOrder->get('status_id') == $statusId) {
- return $this->modx->lexicon('ms3_err_status_same');
+ $transitionError = $this->validateStatusTransition($oldStatus, $status);
+ if ($transitionError !== null) {
+ return $transitionError;
}
$eventParams = [
'msOrder' => $msOrder,
- 'old_status' => $oldStatus?->get('id'),
+ 'old_status' => $previousStatusId,
'status' => $statusId,
];
$response = $this->ms3->utils->invokeEvent('msOnBeforeChangeOrderStatus', $eventParams);
@@ -134,8 +155,8 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa
if (!$status) {
return $this->modx->lexicon('ms3_err_status_nf');
}
- if ($msOrder->get('status_id') == $statusId) {
- return $this->modx->lexicon('ms3_err_status_same');
+ if ($previousStatusId === $statusId) {
+ return $idempotent ? true : $this->modx->lexicon('ms3_err_status_same');
}
$transitionError = $this->validateStatusTransition($oldStatus, $status);
@@ -145,22 +166,26 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa
}
$msOrder->set('status_id', $statusId);
-
if (!$msOrder->save()) {
return $this->modx->lexicon('ms3_err_unknown');
}
- $this->orderLog->add($msOrder->get('id'), $statusId, 'status');
+ $portError = $this->runLifecyclePorts($msOrder, $statusId, $previousStatusId);
+ if ($portError !== null) {
+ return $this->rollbackStatus($msOrder, $previousStatusId) ?? $portError;
+ }
$response = $this->ms3->utils->invokeEvent('msOnChangeOrderStatus', [
'msOrder' => $msOrder,
- 'old_status' => $oldStatus?->get('id'),
+ 'old_status' => $previousStatusId,
'status' => $statusId,
]);
if (!$response['success']) {
- return $response['message'];
+ return $this->rollbackStatus($msOrder, $previousStatusId) ?? $response['message'];
}
+ $this->orderLog->add($msOrder->get('id'), $statusId, 'status');
+
// Send notifications via NotificationManager (unless skipped)
// Use output buffering to prevent any stray output from Fenom/pdoTools
if (!$skipNotifications) {
@@ -173,7 +198,7 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa
}
/**
- * Validate transition from old status to new (final/fixed rules).
+ * Validate transition: final/fixed defaults + optional allow-list (ms3_order_status_transitions).
*/
protected function validateStatusTransition(
?msOrderStatusModel $oldStatus,
@@ -191,9 +216,68 @@ protected function validateStatusTransition(
return $this->modx->lexicon('ms3_err_status_fixed');
}
+ $edges = OrderStatusTransitionPolicy::resolve(
+ $this->modx->getOption('ms3_order_status_transitions', null, '')
+ );
+ if ($edges['mode'] === OrderStatusTransitionPolicy::MODE_INVALID) {
+ return $this->modx->lexicon('ms3_err_status_transitions_invalid');
+ }
+ if (
+ $edges['mode'] === OrderStatusTransitionPolicy::MODE_ON
+ && !isset($edges['edges'][(int) $oldStatus->get('id')][(int) $newStatus->get('id')])
+ ) {
+ return $this->modx->lexicon('ms3_err_status_transition');
+ }
+
+ return null;
+ }
+
+ /**
+ * Invoke semantic lifecycle ports when the target matches configured status ids.
+ */
+ protected function runLifecyclePorts(msOrder $order, int $statusId, ?int $previousStatusId): ?string
+ {
+ $paidId = (int) $this->modx->getOption('ms3_status_paid', null, 3);
+ $canceledId = (int) $this->modx->getOption('ms3_status_canceled', null, 5);
+ $sentId = (int) $this->modx->getOption('ms3_status_sent', null, 4);
+
+ if ($statusId === $paidId) {
+ return $this->lifecyclePorts->onOrderBecamePaid($order, $previousStatusId);
+ }
+ if ($statusId === $canceledId) {
+ return $this->lifecyclePorts->onOrderCancelled($order, $previousStatusId);
+ }
+ if ($statusId === $sentId) {
+ return $this->lifecyclePorts->onOrderShipped($order, $previousStatusId);
+ }
+
return null;
}
+ /**
+ * Restore previous status_id after failed after-event or lifecycle port.
+ *
+ * @return string|null Lexicon/error when rollback persist fails
+ */
+ protected function rollbackStatus(msOrder $order, ?int $previousStatusId): ?string
+ {
+ $order->set('status_id', $previousStatusId);
+ if ($order->save()) {
+ return null;
+ }
+
+ $this->modx->log(
+ modX::LOG_LEVEL_ERROR,
+ sprintf(
+ '[MiniShop3] Failed to rollback order #%s status to %s',
+ (string) $order->get('id'),
+ $previousStatusId === null ? 'null' : (string) $previousStatusId
+ )
+ );
+
+ return $this->modx->lexicon('ms3_err_status_rollback');
+ }
+
/**
* Send notifications for status change
*
diff --git a/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php b/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php
new file mode 100644
index 00000000..8d1eb13c
--- /dev/null
+++ b/core/components/minishop3/src/Services/Order/OrderStatusTransitionPolicy.php
@@ -0,0 +1,89 @@
+>}
+ */
+ public static function resolve(mixed $raw): array
+ {
+ if ($raw === null) {
+ return ['mode' => self::MODE_OFF, 'edges' => []];
+ }
+
+ if (is_array($raw)) {
+ return ['mode' => self::MODE_ON, 'edges' => self::fromPairList($raw)];
+ }
+
+ $value = trim((string) $raw);
+ if ($value === '') {
+ return ['mode' => self::MODE_OFF, 'edges' => []];
+ }
+
+ if (str_starts_with($value, '[')) {
+ $decoded = json_decode($value, true);
+ if (!is_array($decoded)) {
+ return ['mode' => self::MODE_INVALID, 'edges' => []];
+ }
+
+ return ['mode' => self::MODE_ON, 'edges' => self::fromPairList($decoded)];
+ }
+
+ $pairs = [];
+ foreach (array_filter(array_map('trim', explode(',', $value))) as $pair) {
+ $parts = array_map('trim', explode(':', $pair, 2));
+ if (count($parts) !== 2) {
+ return ['mode' => self::MODE_INVALID, 'edges' => []];
+ }
+ $pairs[] = $parts;
+ }
+
+ $edges = self::fromPairList($pairs);
+ if ($edges === []) {
+ return ['mode' => self::MODE_INVALID, 'edges' => []];
+ }
+
+ return ['mode' => self::MODE_ON, 'edges' => $edges];
+ }
+
+ /**
+ * @param array $pairs
+ * @return array>
+ */
+ private static function fromPairList(array $pairs): array
+ {
+ $edges = [];
+ foreach ($pairs as $pair) {
+ if (!is_array($pair) || count($pair) < 2) {
+ continue;
+ }
+ $from = (int) $pair[0];
+ $to = (int) $pair[1];
+ if ($from < 1 || $to < 1) {
+ continue;
+ }
+ $edges[$from][$to] = true;
+ }
+
+ return $edges;
+ }
+}
diff --git a/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php
new file mode 100644
index 00000000..67077110
--- /dev/null
+++ b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusServiceLifecycleTest.php
@@ -0,0 +1,380 @@
+makeHarness(statusId: 2);
+ $log = $this->recordingLog();
+ $events = [];
+
+ $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events);
+ $result = $service->change(10, 2, true, ['idempotent' => true]);
+
+ self::assertTrue($result);
+ self::assertSame([], $log->entries);
+ self::assertSame([], $events);
+ }
+
+ public function testSameStatusWithoutIdempotentReturnsError(): void
+ {
+ $harness = $this->makeHarness(statusId: 2);
+ $log = $this->recordingLog();
+ $events = [];
+
+ $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events);
+ $result = $service->change(10, 2);
+
+ self::assertSame('ms3_err_status_same', $result);
+ self::assertSame([], $log->entries);
+ }
+
+ public function testAllowListRejectsUnknownEdgeWithoutSave(): void
+ {
+ $harness = $this->makeHarness(statusId: 2, options: [
+ 'ms3_order_status_transitions' => '2:3',
+ ]);
+ $log = $this->recordingLog();
+ $events = [];
+
+ $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events);
+ $result = $service->change(10, 4);
+
+ self::assertSame('ms3_err_status_transition', $result);
+ self::assertSame(2, $harness['order']->get('status_id'));
+ self::assertSame([], $log->entries);
+ self::assertSame([], $events);
+ }
+
+ public function testInvalidAllowListConfigRejectedWithoutSave(): void
+ {
+ $harness = $this->makeHarness(statusId: 2, options: [
+ 'ms3_order_status_transitions' => '[broken',
+ ]);
+ $log = $this->recordingLog();
+ $events = [];
+
+ $service = $this->makeService($harness, $log, new NullOrderLifecyclePorts(), $events);
+ $result = $service->change(10, 3);
+
+ self::assertSame('ms3_err_status_transitions_invalid', $result);
+ self::assertSame(2, $harness['order']->get('status_id'));
+ self::assertSame([], $log->entries);
+ }
+
+ public function testCancelAndShippedPortsAreInvoked(): void
+ {
+ $ports = new class implements OrderLifecyclePortsInterface {
+ public int $cancelCalls = 0;
+ public int $shipCalls = 0;
+
+ public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string
+ {
+ return null;
+ }
+
+ public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string
+ {
+ ++$this->cancelCalls;
+
+ return null;
+ }
+
+ public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string
+ {
+ ++$this->shipCalls;
+
+ return null;
+ }
+ };
+
+ $cancelHarness = $this->makeHarness(statusId: 2);
+ $cancelLog = $this->recordingLog();
+ $cancelEvents = [];
+ $cancelService = $this->makeService($cancelHarness, $cancelLog, $ports, $cancelEvents);
+ self::assertTrue($cancelService->change(10, 5, true));
+ self::assertSame(1, $ports->cancelCalls);
+
+ $shipHarness = $this->makeHarness(statusId: 2);
+ $shipLog = $this->recordingLog();
+ $shipEvents = [];
+ $shipService = $this->makeService($shipHarness, $shipLog, $ports, $shipEvents);
+ self::assertTrue($shipService->change(10, 4, true));
+ self::assertSame(1, $ports->shipCalls);
+ }
+
+ public function testAfterEventFailureRollsBackStatusAndSkipsLog(): void
+ {
+ $harness = $this->makeHarness(statusId: 2);
+ $log = $this->recordingLog();
+ $events = [];
+
+ $service = $this->makeService(
+ $harness,
+ $log,
+ new NullOrderLifecyclePorts(),
+ $events,
+ afterFail: true
+ );
+ $result = $service->change(10, 3, true);
+
+ self::assertSame('after failed', $result);
+ self::assertSame(2, $harness['order']->get('status_id'));
+ self::assertSame([], $log->entries);
+ self::assertSame(['msOnBeforeChangeOrderStatus', 'msOnChangeOrderStatus'], $events);
+ }
+
+ public function testPaidPortRunsAndFailureRollsBack(): void
+ {
+ $harness = $this->makeHarness(statusId: 2);
+ $log = $this->recordingLog();
+ $events = [];
+ $ports = new class implements OrderLifecyclePortsInterface {
+ public int $paidCalls = 0;
+
+ public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string
+ {
+ ++$this->paidCalls;
+
+ return 'port failed';
+ }
+
+ public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string
+ {
+ return null;
+ }
+
+ public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string
+ {
+ return null;
+ }
+ };
+
+ $service = $this->makeService($harness, $log, $ports, $events);
+ $result = $service->change(10, 3, true);
+
+ self::assertSame('port failed', $result);
+ self::assertSame(1, $ports->paidCalls);
+ self::assertSame(2, $harness['order']->get('status_id'));
+ self::assertSame([], $log->entries);
+ self::assertSame(['msOnBeforeChangeOrderStatus'], $events);
+ }
+
+ public function testSuccessfulPaidTransitionLogsAndInvokesPort(): void
+ {
+ $harness = $this->makeHarness(statusId: 2);
+ $log = $this->recordingLog();
+ $events = [];
+ $ports = new class implements OrderLifecyclePortsInterface {
+ public int $paidCalls = 0;
+
+ public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string
+ {
+ ++$this->paidCalls;
+
+ return null;
+ }
+
+ public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string
+ {
+ return null;
+ }
+
+ public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string
+ {
+ return null;
+ }
+ };
+
+ $service = $this->makeService($harness, $log, $ports, $events);
+ $result = $service->change(10, 3, true);
+
+ self::assertTrue($result);
+ self::assertSame(1, $ports->paidCalls);
+ self::assertSame(3, $harness['order']->get('status_id'));
+ self::assertSame([[10, 3, 'status']], $log->entries);
+ self::assertSame(['msOnBeforeChangeOrderStatus', 'msOnChangeOrderStatus'], $events);
+ }
+
+ /**
+ * @param array $options
+ * @return array{order: RecordingMsOrder, statuses: array, options: array}
+ */
+ private function makeHarness(int $statusId, array $options = []): array
+ {
+ $order = new RecordingMsOrder([
+ 'id' => 10,
+ 'status_id' => $statusId,
+ 'context' => 'web',
+ ]);
+
+ $statuses = [
+ 2 => $this->makeStatus(2, final: false, fixed: false, position: 2),
+ 3 => $this->makeStatus(3, final: false, fixed: true, position: 3),
+ 4 => $this->makeStatus(4, final: true, fixed: true, position: 4),
+ 5 => $this->makeStatus(5, final: true, fixed: false, position: 5),
+ ];
+
+ return [
+ 'order' => $order,
+ 'statuses' => $statuses,
+ 'options' => array_merge([
+ 'ms3_status_paid' => 3,
+ 'ms3_status_canceled' => 5,
+ 'ms3_status_sent' => 4,
+ 'ms3_order_status_transitions' => '',
+ ], $options),
+ ];
+ }
+
+ private function makeStatus(int $id, bool $final, bool $fixed, int $position): object
+ {
+ return new class($id, $final, $fixed, $position) extends msOrderStatus {
+ public function __construct(
+ private int $statusId,
+ private bool $isFinal,
+ private bool $isFixed,
+ private int $pos
+ ) {
+ }
+
+ public function get($k, $format = null, $formatType = '')
+ {
+ return match ($k) {
+ 'id' => $this->statusId,
+ 'final' => $this->isFinal ? 1 : 0,
+ 'fixed' => $this->isFixed ? 1 : 0,
+ 'position' => $this->pos,
+ default => null,
+ };
+ }
+ };
+ }
+
+ /**
+ * @return OrderLogService&object{entries: list}
+ */
+ private function recordingLog(): OrderLogService
+ {
+ return new class extends OrderLogService {
+ /** @var list */
+ public array $entries = [];
+
+ public function __construct()
+ {
+ }
+
+ public function add(int $order_id, mixed $entry, string $action, bool $visible = true): bool
+ {
+ $this->entries[] = [$order_id, $entry, $action];
+
+ return true;
+ }
+ };
+ }
+
+ /**
+ * @param array{order: RecordingMsOrder, statuses: array, options: array} $harness
+ * @param list $events
+ */
+ private function makeService(
+ array $harness,
+ OrderLogService $log,
+ OrderLifecyclePortsInterface $ports,
+ array &$events,
+ bool $afterFail = false
+ ): OrderStatusService {
+ $modx = new class($harness, $afterFail) extends modX {
+ /** @var array{order: RecordingMsOrder, statuses: array, options: array} */
+ private array $harness;
+ private bool $afterFail;
+
+ public function __construct(array $harness, bool $afterFail)
+ {
+ parent::__construct();
+ $this->harness = $harness;
+ $this->afterFail = $afterFail;
+ }
+
+ public function getOption(string $key, $options = null, $default = null)
+ {
+ return $this->harness['options'][$key] ?? $default;
+ }
+
+ public function switchContext($contextKey, $force = false)
+ {
+ return true;
+ }
+
+ public function getObject($className, $criteria = null, $cacheFlag = true)
+ {
+ if ($className === msOrder::class || $className === RecordingMsOrder::class) {
+ $id = is_array($criteria) ? (int) ($criteria['id'] ?? 0) : (int) $criteria;
+ return $id === (int) $this->harness['order']->get('id')
+ ? $this->harness['order']
+ : null;
+ }
+
+ if ($className === msOrderStatus::class || is_a($className, msOrderStatus::class, true)) {
+ $id = is_array($criteria) ? (int) ($criteria['id'] ?? 0) : (int) $criteria;
+ return $this->harness['statuses'][$id] ?? null;
+ }
+
+ return null;
+ }
+ };
+
+ $ms3 = $this->createMock(MiniShop3::class);
+ $ms3->method('initialize')->willReturn(true);
+
+ $utils = new class($events, $afterFail) {
+ /** @var list */
+ private array $events;
+ private bool $afterFail;
+
+ public function __construct(array &$events, bool $afterFail)
+ {
+ $this->events = &$events;
+ $this->afterFail = $afterFail;
+ }
+
+ public function invokeEvent(string $eventName, array $params = [], $glue = '
'): array
+ {
+ $this->events[] = $eventName;
+ if ($eventName === 'msOnChangeOrderStatus' && $this->afterFail) {
+ return ['success' => false, 'message' => 'after failed', 'data' => []];
+ }
+
+ return ['success' => true, 'message' => '', 'data' => $params];
+ }
+ };
+ $ms3->utils = $utils;
+
+ return new OrderStatusService($modx, $ms3, $log, $ports);
+ }
+}
diff --git a/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php
new file mode 100644
index 00000000..4f511ee3
--- /dev/null
+++ b/core/components/minishop3/tests/Unit/Services/Order/OrderStatusTransitionPolicyTest.php
@@ -0,0 +1,57 @@
+