Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions _build/elements/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b>register_globals</b> 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';
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/en/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 параметр <b>register_globals</b> должен быть выключен.';
$_lang['ms3_err_link_equal'] = 'Вы пытаетесь добавить товару ссылку на самого себя';
$_lang['ms3_err_no_link'] = 'Тип связи не найден';
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/lexicon/ru/setting.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 статусов для статистики';
Expand Down
29 changes: 23 additions & 6 deletions core/components/minishop3/src/Controllers/Payment/Payment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
25 changes: 13 additions & 12 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion core/components/minishop3/src/ServiceRegistryFactories.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Order;

use MiniShop3\Model\msOrder;

/**
* No-op lifecycle ports until inventory / payment / shipment domains are wired (#589–#591).
*/
final class NullOrderLifecyclePorts implements OrderLifecyclePortsInterface
{
public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string
{
return null;
}

public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string
{
return null;
}

public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string
{
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Services\Order;

use MiniShop3\Model\msOrder;

/**
* Domain ports invoked by order status lifecycle after a successful transition gate.
*
* Implementations land with inventory (#589), payment (#590), and shipment (#591).
* Core ships {@see NullOrderLifecyclePorts} until those domains exist.
*
* Ports must be safe to retry (idempotent) because after-event failure rolls back
* status while a port may already have run.
*/
interface OrderLifecyclePortsInterface
{
/**
* Order reached the configured "paid" status (ms3_status_paid).
*
* @return string|null Lexicon/error message on failure; null on success
*/
public function onOrderBecamePaid(msOrder $order, ?int $previousStatusId): ?string;

/**
* Order reached the configured canceled status (ms3_status_canceled).
*
* @return string|null Lexicon/error message on failure; null on success
*/
public function onOrderCancelled(msOrder $order, ?int $previousStatusId): ?string;

/**
* Order reached the configured shipped/sent status (ms3_status_sent, default 4).
*
* @return string|null Lexicon/error message on failure; null on success
*/
public function onOrderShipped(msOrder $order, ?int $previousStatusId): ?string;
}
Loading