From 117214460ece45aeeab66c3ffe7f51dce29dca66 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 19 Aug 2026 10:06:59 +0600 Subject: [PATCH 1/3] feat(core): add payment attempt lifecycle and webhook callback Async providers need a payment attempt distinct from msPayment and order status, plus an idempotent core webhook that maps events through OrderStatusService. --- _build/elements/settings.php | 10 + .../minishop3/config/ms3.services.example.php | 1 + .../minishop3/config/routes/web.php | 5 + .../elements/snippets/ms3_get_order.php | 6 +- .../minishop3/lexicon/en/default.inc.php | 6 + .../minishop3/lexicon/en/setting.inc.php | 4 + .../minishop3/lexicon/ru/default.inc.php | 6 + .../minishop3/lexicon/ru/setting.inc.php | 6 +- ...20260819140000_create_payment_attempts.php | 62 ++ .../Api/Web/PaymentWebhookController.php | 175 +++++ .../src/Controllers/Payment/Payment.php | 30 +- .../Payment/PaymentWebhookEvent.php | 28 + .../PaymentWebhookHandlerInterface.php | 29 + .../src/Middleware/TokenMiddleware.php | 1 + .../src/Notifications/Notification.php | 3 +- .../minishop3/src/ServiceRegistry.php | 5 + .../src/ServiceRegistryFactories.php | 14 + .../Services/Customer/OrdersPageService.php | 5 +- .../Services/Payment/PaymentAttemptStatus.php | 16 + .../Payment/PaymentAttemptStoreInterface.php | 73 +++ .../Payment/PaymentLifecycleException.php | 47 ++ .../Payment/PaymentLifecycleService.php | 607 ++++++++++++++++++ .../Services/Payment/PaymentPublicFields.php | 43 ++ .../src/Services/Payment/PaymentService.php | 62 +- .../Payment/PdoPaymentAttemptStore.php | 264 ++++++++ .../DeliveryPaymentCatalogRoutesTest.php | 1 + .../tests/PaymentLifecycleWiringTest.php | 72 +++ .../tests/TokenMiddlewarePublicRoutesTest.php | 1 + .../Api/Web/PaymentWebhookControllerTest.php | 315 +++++++++ .../Payment/PaymentLifecycleServiceTest.php | 380 +++++++++++ .../Payment/PaymentPublicFieldsTest.php | 36 ++ .../Payment/PaymentServiceSendAttemptTest.php | 134 ++++ core/components/minishop3/tests/bootstrap.php | 1 + .../support/InMemoryPaymentAttemptStore.php | 131 ++++ 34 files changed, 2564 insertions(+), 15 deletions(-) create mode 100644 core/components/minishop3/migrations/20260819140000_create_payment_attempts.php create mode 100644 core/components/minishop3/src/Controllers/Api/Web/PaymentWebhookController.php create mode 100644 core/components/minishop3/src/Controllers/Payment/PaymentWebhookEvent.php create mode 100644 core/components/minishop3/src/Controllers/Payment/PaymentWebhookHandlerInterface.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentAttemptStatus.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentAttemptStoreInterface.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentLifecycleException.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentPublicFields.php create mode 100644 core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php create mode 100644 core/components/minishop3/tests/PaymentLifecycleWiringTest.php create mode 100644 core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Payment/PaymentPublicFieldsTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php create mode 100644 core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php diff --git a/_build/elements/settings.php b/_build/elements/settings.php index c8c371cd6..ad4974d9a 100644 --- a/_build/elements/settings.php +++ b/_build/elements/settings.php @@ -434,6 +434,16 @@ 'xtype' => 'textfield', 'area' => 'ms3_security', ], + 'ms3_payment_on_failed_status' => [ + 'value' => 5, + 'xtype' => 'numberfield', + 'area' => 'ms3_order', + ], + 'ms3_payment_on_refunded_status' => [ + 'value' => 5, + 'xtype' => 'numberfield', + 'area' => 'ms3_order', + ], 'ms3_snippet_token_secret' => [ 'value' => '', // Генерируется автоматически при первом запуске 'xtype' => 'textfield', diff --git a/core/components/minishop3/config/ms3.services.example.php b/core/components/minishop3/config/ms3.services.example.php index b797046ec..889e75127 100644 --- a/core/components/minishop3/config/ms3.services.example.php +++ b/core/components/minishop3/config/ms3.services.example.php @@ -183,6 +183,7 @@ * ------------------ * 'ms3_delivery_service' - Delivery service * 'ms3_payment_service' - Payment service + * 'ms3_payment_lifecycle' - Payment attempt lifecycle (async providers) * * Orders: * ------- diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index 39dfd9ed1..b824a0390 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -327,6 +327,11 @@ $controller = new \MiniShop3\Controllers\Api\Web\PaymentController($modx); return $controller->getList($params); }); + + $router->post('/webhook/{payment_method_id}', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\PaymentWebhookController($modx); + return $controller->handle($params); + }); }); $router->get('/health', function () use ($modx) { diff --git a/core/components/minishop3/elements/snippets/ms3_get_order.php b/core/components/minishop3/elements/snippets/ms3_get_order.php index 53abf4882..b374561e9 100644 --- a/core/components/minishop3/elements/snippets/ms3_get_order.php +++ b/core/components/minishop3/elements/snippets/ms3_get_order.php @@ -3,6 +3,7 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msOrder; use MiniShop3\Services\Payment\PaymentLinkResolver; +use MiniShop3\Services\Payment\PaymentPublicFields; use MiniShop3\Model\msOrderProduct; use MiniShop3\Model\msProduct; use MiniShop3\Model\msProductData; @@ -248,6 +249,7 @@ } try { + $payment = $msOrder->getOne('Payment'); $pls = array_merge($scriptProperties, [ 'order' => $msOrder->toArray(), 'products' => $products, @@ -260,9 +262,7 @@ 'delivery' => ($tmp = $msOrder->getOne('Delivery')) ? $tmp->toArray() : [], - 'payment' => ($payment = $msOrder->getOne('Payment')) - ? $payment->toArray() - : [], + 'payment' => PaymentPublicFields::fromEntityOrEmpty($payment), 'total' => [ 'cost' => (float)$msOrder->get('cost'), 'cost_formatted' => $ms3->format->price($msOrder->get('cost'), true), diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 163378ff0..ea3288559 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -189,6 +189,12 @@ $_lang['ms3_err_delivery_id_required'] = 'Delivery ID is required'; $_lang['ms3_err_payment_nf'] = 'Payment method with this identifier not found.'; $_lang['ms3_err_payment_id_required'] = 'Payment ID is required'; +$_lang['ms3_err_payment_webhook_unsupported'] = 'This payment method does not support the core webhook.'; +$_lang['ms3_err_payment_webhook_invalid'] = 'Payment callback payload is invalid.'; +$_lang['ms3_err_payment_webhook_unauthorized'] = 'Payment callback signature is invalid.'; +$_lang['ms3_err_payment_webhook_conflict'] = 'Payment callback conflicts with the current attempt state.'; +$_lang['ms3_err_payment_attempt_nf'] = 'Payment attempt not found.'; +$_lang['ms3_err_payment_event_conflict'] = 'This payment event cannot be applied to the current attempt.'; $_lang['ms3_err_status_final'] = 'Final status is set. It cannot be changed.'; $_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.'; $_lang['ms3_err_status_wrong'] = 'Invalid order status.'; diff --git a/core/components/minishop3/lexicon/en/setting.inc.php b/core/components/minishop3/lexicon/en/setting.inc.php index a82d61935..e9c3f4a0e 100644 --- a/core/components/minishop3/lexicon/en/setting.inc.php +++ b/core/components/minishop3/lexicon/en/setting.inc.php @@ -179,6 +179,10 @@ $_lang['setting_ms3_email_verification_success_url_desc'] = 'Used when the user opens the verification link from email (html=1). If empty, site_url is used; the query parameter ms3_email_verified=1 is appended.'; $_lang['setting_ms3_payment_secret'] = 'Payment secret key'; $_lang['setting_ms3_payment_secret_desc'] = 'Secret key for generating payment notification signatures. Recommended to set a unique value for improved security.'; +$_lang['setting_ms3_payment_on_failed_status'] = 'Order status after failed/cancelled payment'; +$_lang['setting_ms3_payment_on_failed_status_desc'] = 'Order status ID applied when a payment attempt fails or is cancelled before paid. 0 leaves the order unchanged. Default is the canceled status (5).'; +$_lang['setting_ms3_payment_on_refunded_status'] = 'Order status after full refund'; +$_lang['setting_ms3_payment_on_refunded_status_desc'] = 'Order status ID applied after a full refund. 0 leaves the order unchanged. Partial refunds never change order status. Default is the canceled status (5).'; // Currency and Formatting Settings $_lang['setting_ms3_currency_symbol'] = 'Currency symbol'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 266ae656c..4e1bca3a5 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -189,6 +189,12 @@ $_lang['ms3_err_delivery_id_required'] = 'Не указан ID доставки'; $_lang['ms3_err_payment_nf'] = 'Способ оплаты с таким идентификатором не найден.'; $_lang['ms3_err_payment_id_required'] = 'Не указан ID оплаты'; +$_lang['ms3_err_payment_webhook_unsupported'] = 'Этот способ оплаты не поддерживает системный webhook.'; +$_lang['ms3_err_payment_webhook_invalid'] = 'Некорректное тело платёжного callback.'; +$_lang['ms3_err_payment_webhook_unauthorized'] = 'Подпись платёжного callback недействительна.'; +$_lang['ms3_err_payment_webhook_conflict'] = 'Callback конфликтует с текущим состоянием попытки оплаты.'; +$_lang['ms3_err_payment_attempt_nf'] = 'Попытка оплаты не найдена.'; +$_lang['ms3_err_payment_event_conflict'] = 'Это платёжное событие нельзя применить к текущей попытке.'; $_lang['ms3_err_status_final'] = 'Установлен финальный статус. Его нельзя менять.'; $_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.'; $_lang['ms3_err_status_wrong'] = 'Неверный статус заказа.'; diff --git a/core/components/minishop3/lexicon/ru/setting.inc.php b/core/components/minishop3/lexicon/ru/setting.inc.php index 88678f604..b4fb3eb0e 100644 --- a/core/components/minishop3/lexicon/ru/setting.inc.php +++ b/core/components/minishop3/lexicon/ru/setting.inc.php @@ -178,7 +178,11 @@ $_lang['setting_ms3_email_verification_success_url'] = 'URL редиректа после успешной верификации email (необязательно)'; $_lang['setting_ms3_email_verification_success_url_desc'] = 'Используется при переходе по ссылке из письма (параметр html=1). Если пусто — берётся site_url; к URL добавляется параметр ms3_email_verified=1.'; $_lang['setting_ms3_payment_secret'] = 'Секретный ключ для платежей'; -$_lang['setting_ms3_payment_secret_desc'] = 'Секретный ключ для генерации подписей платежных уведомлений. Рекомендуется установить уникальное значение для повышения безопасности.'; +$_lang['setting_ms3_payment_secret_desc'] = 'Секретный ключ для генерации подписей платёжных уведомлений. Рекомендуется установить уникальное значение для повышения безопасности.'; +$_lang['setting_ms3_payment_on_failed_status'] = 'Статус заказа после неуспешной оплаты'; +$_lang['setting_ms3_payment_on_failed_status_desc'] = 'ID статуса заказа, который ставится при failed/cancelled попытки до оплаты. 0 — не менять заказ. По умолчанию статус отмены (5).'; +$_lang['setting_ms3_payment_on_refunded_status'] = 'Статус заказа после полного возврата'; +$_lang['setting_ms3_payment_on_refunded_status_desc'] = 'ID статуса заказа после полного refund. 0 — не менять заказ. Частичный возврат статус заказа не меняет. По умолчанию статус отмены (5).'; // Currency and Formatting Settings $_lang['setting_ms3_currency_symbol'] = 'Символ валюты'; diff --git a/core/components/minishop3/migrations/20260819140000_create_payment_attempts.php b/core/components/minishop3/migrations/20260819140000_create_payment_attempts.php new file mode 100644 index 000000000..7e7efc1f4 --- /dev/null +++ b/core/components/minishop3/migrations/20260819140000_create_payment_attempts.php @@ -0,0 +1,62 @@ +hasTable('ms3_payment_attempts')) { + $this->table('ms3_payment_attempts', [ + 'id' => true, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('order_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('payment_method_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('provider', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('external_id', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('status', 'string', ['limit' => 32, 'null' => false, 'default' => 'pending']) + ->addColumn('amount', 'decimal', ['precision' => 13, 'scale' => 3, 'null' => false, 'default' => '0.000']) + ->addColumn('currency', 'string', ['limit' => 8, 'null' => false, 'default' => 'RUB']) + ->addColumn('payload', 'text', ['null' => true]) + ->addColumn('refunded_amount', 'decimal', ['precision' => 13, 'scale' => 3, 'null' => false, 'default' => '0.000']) + ->addColumn('refund_external_id', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('refundedon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addColumn('updatedon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addIndex(['order_id'], ['name' => 'idx_payment_attempt_order']) + ->addIndex(['payment_method_id', 'provider', 'external_id'], [ + 'unique' => true, + 'name' => 'uniq_payment_attempt_external', + ]) + ->create(); + } + + if (!$this->hasTable('ms3_payment_attempt_events')) { + $this->table('ms3_payment_attempt_events', [ + 'id' => true, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('attempt_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('event_type', 'string', ['limit' => 32, 'null' => false]) + ->addColumn('provider_event_id', 'string', ['limit' => 191, 'null' => false, 'default' => '']) + ->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addIndex( + ['attempt_id', 'event_type', 'provider_event_id'], + ['unique' => true, 'name' => 'uniq_payment_attempt_event'] + ) + ->create(); + } + } +} diff --git a/core/components/minishop3/src/Controllers/Api/Web/PaymentWebhookController.php b/core/components/minishop3/src/Controllers/Api/Web/PaymentWebhookController.php new file mode 100644 index 000000000..889cc1dce --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Web/PaymentWebhookController.php @@ -0,0 +1,175 @@ +modx->lexicon->load('minishop3:default'); + } + + /** + * POST /api/v1/payment/webhook/{payment_method_id} + * + * @param array $params + */ + public function handle(array $params = []): Response + { + $methodId = (int) ($params['payment_method_id'] ?? 0); + if ($methodId <= 0) { + return $this->fail('ms3_err_payment_id_required', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + + $method = $this->modx->getObject(msPayment::class, ['id' => $methodId, 'active' => 1]); + if (!$method instanceof msPayment) { + return $this->fail('ms3_err_payment_nf', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND); + } + + /** @var PaymentService $paymentService */ + $paymentService = $this->modx->services->get('ms3_payment_service'); + $handler = $paymentService->loadPaymentHandler($method); + if (!$handler instanceof PaymentWebhookHandlerInterface) { + return $this->fail( + 'ms3_err_payment_webhook_unsupported', + HttpStatus::BAD_REQUEST, + ApiErrorCode::BAD_REQUEST + ); + } + + $rawBody = $this->readRawRequestBody(); + $payload = $this->decodeJsonObject($rawBody); + $headers = $this->requestHeaders(); + if ($payload === null) { + return $this->fail('ms3_err_payment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + if (!$handler->verifyWebhook($rawBody, $payload, $headers, $method)) { + return $this->fail( + 'ms3_err_payment_webhook_unauthorized', + HttpStatus::UNAUTHORIZED, + ApiErrorCode::UNAUTHORIZED + ); + } + + $event = $handler->parseWebhook($payload, $headers); + if ($event === null) { + return $this->fail('ms3_err_payment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + + /** @var PaymentLifecycleService $lifecycle */ + $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); + $class = $method->get('class'); + $provider = is_string($class) && $class !== '' ? $class : $handler::class; + + try { + $attempt = $lifecycle->applyWebhook($event, $methodId, $provider); + } catch (PaymentLifecycleException $exception) { + return $this->fromLifecycle($exception); + } catch (\Throwable $exception) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + 'Payment webhook failed: ' . $exception->getMessage() + ); + return $this->fail( + 'ms3_err_unknown', + HttpStatus::INTERNAL_SERVER_ERROR, + ApiErrorCode::INTERNAL_ERROR + ); + } + + return Response::success([ + 'attempt_id' => $attempt['id'], + 'status' => $attempt['status'], + 'order_id' => $attempt['order_id'], + ]); + } + + private function fromLifecycle(PaymentLifecycleException $exception): Response + { + [$status, $errorCode] = match ($exception->getKind()) { + PaymentLifecycleException::KIND_CONFLICT => [HttpStatus::CONFLICT, ApiErrorCode::CONFLICT], + PaymentLifecycleException::KIND_NOT_FOUND => [HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND], + default => [HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST], + }; + $message = $this->modx->lexicon($exception->getLexiconKey(), $exception->getPlaceholders()); + if (!is_string($message) || $message === '') { + $message = $exception->getMessage(); + } + + return Response::error($message, $status, null, $errorCode); + } + + private function fail(string $message, int $status, string $errorCode): Response + { + $translated = $this->modx->lexicon($message); + if (is_string($translated) && $translated !== '') { + $message = $translated; + } + + return Response::error($message, $status, null, $errorCode); + } + + /** + * @return array|null + */ + private function decodeJsonObject(string $raw): ?array + { + if ($raw === '') { + return null; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || $decoded === [] || array_is_list($decoded)) { + return null; + } + + return $decoded; + } + + protected function readRawRequestBody(): string + { + $raw = file_get_contents('php://input'); + + return is_string($raw) ? $raw : ''; + } + + /** + * @return array + */ + private function requestHeaders(): array + { + if (function_exists('getallheaders')) { + $headers = getallheaders(); + $normalized = []; + foreach ($headers as $name => $value) { + $normalized[strtolower((string) $name)] = (string) $value; + } + + return $normalized; + } + $headers = []; + foreach ($_SERVER as $key => $value) { + if (!is_string($key) || !str_starts_with($key, 'HTTP_')) { + continue; + } + $name = strtolower(str_replace('_', '-', substr($key, 5))); + $headers[$name] = is_scalar($value) ? (string) $value : ''; + } + + return $headers; + } +} diff --git a/core/components/minishop3/src/Controllers/Payment/Payment.php b/core/components/minishop3/src/Controllers/Payment/Payment.php index a0222b8ba..a3387b0c7 100644 --- a/core/components/minishop3/src/Controllers/Payment/Payment.php +++ b/core/components/minishop3/src/Controllers/Payment/Payment.php @@ -6,6 +6,7 @@ use MiniShop3\Model\msOrder; use MiniShop3\Model\msPayment; use MiniShop3\Services\Order\OrderCostEngine; +use MiniShop3\Services\Payment\PaymentLifecycleService; use MODX\Revolution\modX; /** @@ -61,15 +62,14 @@ * return $this->error('Invalid order hash'); * } * + * $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); * if ($data['status'] === 'succeeded') { - * $order->set('status_id', $this->getPaidStatusId()); - * $order->save(); + * $lifecycle->markPaid($attemptId, $data['event_id'] ?? null); * return $this->success('Payment confirmed'); * } * * if ($data['status'] === 'canceled') { - * $order->set('status_id', $this->getCanceledStatusId()); - * $order->save(); + * $lifecycle->markCancelled($attemptId, $data['event_id'] ?? null); * return $this->error('Payment canceled'); * } * @@ -153,14 +153,19 @@ public function getCost(msOrder $order, msPayment $payment, float $cost): float * Calls send() method and extracts payment_link from response. * Used to display "Pay" button on order page. * - * Note: Method calls send() each time without caching. - * For payment systems with API limits caching is recommended. + * Reuses a stored attempt link when present so send() is not called again + * (async providers must not create a second payment). * * @param msOrder $order Order for payment * @return string|null Payment link or null if failed */ public function getPaymentLink(msOrder $order): ?string { + $paymentMethodId = (int) $order->get('payment_id') ?: null; + $stored = $this->storedPaymentLink((int) $order->get('id'), $paymentMethodId); + if ($stored !== null) { + return $stored; + } try { $response = $this->send($order); return $response['data']['payment_link'] ?? null; @@ -236,6 +241,19 @@ protected function success(string $message = '', array $data = [], array $placeh return $this->ms3->utils->success($message, $data, $placeholders); } + private function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ?string + { + if ($orderId <= 0 || !$this->modx->services->has('ms3_payment_lifecycle')) { + return null; + } + $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); + if (!$lifecycle instanceof PaymentLifecycleService) { + return null; + } + + return $lifecycle->storedPaymentLink($orderId, $paymentMethodId); + } + /** * Get "Paid" status ID from system settings * diff --git a/core/components/minishop3/src/Controllers/Payment/PaymentWebhookEvent.php b/core/components/minishop3/src/Controllers/Payment/PaymentWebhookEvent.php new file mode 100644 index 000000000..0582af051 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Payment/PaymentWebhookEvent.php @@ -0,0 +1,28 @@ + $payload Safe meta only (no credentials) + */ + public function __construct( + public readonly string $eventType, + public readonly ?string $externalId = null, + public readonly ?int $orderId = null, + public readonly ?string $orderUuid = null, + public readonly ?float $amount = null, + public readonly ?string $currency = null, + public readonly ?string $providerEventId = null, + public readonly ?float $refundAmount = null, + public readonly ?string $refundExternalId = null, + public readonly array $payload = [], + ) { + } +} diff --git a/core/components/minishop3/src/Controllers/Payment/PaymentWebhookHandlerInterface.php b/core/components/minishop3/src/Controllers/Payment/PaymentWebhookHandlerInterface.php new file mode 100644 index 000000000..08e8d5a00 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Payment/PaymentWebhookHandlerInterface.php @@ -0,0 +1,29 @@ + $payload + * @param array $headers + */ + public function verifyWebhook(string $rawBody, array $payload, array $headers, msPayment $method): bool; + + /** + * @param array $payload + * @param array $headers + */ + public function parseWebhook(array $payload, array $headers): ?PaymentWebhookEvent; +} diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 5c035a923..a6bdd4b84 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -43,6 +43,7 @@ class TokenMiddleware implements MiddlewareInterface '/api/v1/delivery/list', '/api/v1/payment/get/', '/api/v1/payment/list', + '/api/v1/payment/webhook/', '/api/v1/customer/token/get', '/api/v1/customer/logout', '/api/v1/health', diff --git a/core/components/minishop3/src/Notifications/Notification.php b/core/components/minishop3/src/Notifications/Notification.php index ce8456763..bb81e907b 100644 --- a/core/components/minishop3/src/Notifications/Notification.php +++ b/core/components/minishop3/src/Notifications/Notification.php @@ -6,6 +6,7 @@ use MiniShop3\MiniShop3; use MiniShop3\Model\msOrder; use MiniShop3\Model\msProductData; +use MiniShop3\Services\Payment\PaymentPublicFields; use MiniShop3\Notifications\Messages\EmailMessage; use MiniShop3\Notifications\Messages\TelegramMessage; use MiniShop3\Notifications\Messages\SmsMessage; @@ -172,7 +173,7 @@ public function getPlaceholders(): array // Add payment data if ($payment = $this->order->getOne('Payment')) { - $pls['payment'] = $payment->toArray(); + $pls['payment'] = PaymentPublicFields::fromEntityOrEmpty($payment); } // Add products from order diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 2e1e25643..425d9c5c1 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -108,6 +108,7 @@ class ServiceRegistry ], 'ms3_order_finalize' => ['ms3_order_number_generator'], 'ms3_order_status' => ['ms3_order_log'], + 'ms3_payment_lifecycle' => ['ms3_order_status'], 'ms3_cart_mutation_handler' => [ 'ms3_order_draft_manager', 'ms3_cart_item_manager', @@ -214,6 +215,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Payment\PaymentLinkResolver::class, 'interface' => null, ], + 'ms3_payment_lifecycle' => [ + 'class' => \MiniShop3\Services\Payment\PaymentLifecycleService::class, + 'interface' => null, + ], 'ms3_order_service' => [ 'class' => \MiniShop3\Services\Order\OrderService::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 522b9ac88..3a49c6d76 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -2,6 +2,8 @@ namespace MiniShop3; +use MiniShop3\Services\Order\OrderStatusService; +use MiniShop3\Services\Payment\PdoPaymentAttemptStore; use MODX\Revolution\modX; /** @@ -52,6 +54,18 @@ public static function map(): array 'ms3_delivery_service' => $modxOnly(), 'ms3_payment_service' => $modxOnly(), 'ms3_payment_link_resolver' => $modxOnly(), + 'ms3_payment_lifecycle' => static function (modX $modx, object $services, string $class): object { + $prefix = (string) $modx->getOption('table_prefix', null, ''); + $store = new PdoPaymentAttemptStore( + $modx->pdo, + $prefix . 'ms3_payment_attempts', + $prefix . 'ms3_payment_attempt_events' + ); + /** @var OrderStatusService $orderStatus */ + $orderStatus = $services->get('ms3_order_status'); + + return new $class($store, $modx, $orderStatus->change(...)); + }, 'ms3_order_service' => $modxOnly(), 'ms3_customer_order' => $modxOnly(), 'ms3_order_number_generator' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Customer/OrdersPageService.php b/core/components/minishop3/src/Services/Customer/OrdersPageService.php index a0ffff702..f9abbd662 100644 --- a/core/components/minishop3/src/Services/Customer/OrdersPageService.php +++ b/core/components/minishop3/src/Services/Customer/OrdersPageService.php @@ -6,6 +6,7 @@ use MiniShop3\Model\msOrderProduct; use MiniShop3\Model\msOrderStatus; use MiniShop3\Model\msProductData; +use MiniShop3\Services\Payment\PaymentPublicFields; use MODX\Revolution\modResource; /** @@ -210,7 +211,7 @@ protected function renderOrderDetails(string $orderUuid): string 'order' => $orderArray, 'products' => $products, 'delivery' => $delivery ? $delivery->toArray() : [], - 'payment' => $payment ? $payment->toArray() : [], + 'payment' => PaymentPublicFields::fromEntityOrEmpty($payment), 'address' => $address ? $address->toArray() : [], 'total' => [ 'cost' => $this->ms3->format->price($order->get('cost')), @@ -440,7 +441,7 @@ protected function getOrderDetailsData(string $orderUuid): array ]), 'products' => $products, 'delivery' => $delivery ? $delivery->toArray() : [], - 'payment' => $payment ? $payment->toArray() : [], + 'payment' => PaymentPublicFields::fromEntityOrEmpty($payment), 'address' => $address ? $address->toArray() : [], 'total' => [ 'cost' => $this->ms3->format->price($order->get('cost')), diff --git a/core/components/minishop3/src/Services/Payment/PaymentAttemptStatus.php b/core/components/minishop3/src/Services/Payment/PaymentAttemptStatus.php new file mode 100644 index 000000000..c17bb0937 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentAttemptStatus.php @@ -0,0 +1,16 @@ +, + * refunded_amount: float, + * refund_external_id: ?string, + * refundedon: ?int, + * createdon: int, + * updatedon: int + * } + */ +interface PaymentAttemptStoreInterface +{ + /** + * @param array $payload + * @return PaymentAttemptRow + */ + public function create( + int $orderId, + int $paymentMethodId, + string $provider, + ?string $externalId, + string $status, + float $amount, + string $currency, + array $payload, + ): array; + + /** + * @param array $fields + * @return PaymentAttemptRow + */ + public function update(int $id, array $fields): array; + + /** + * @return PaymentAttemptRow|null + */ + public function findById(int $id): ?array; + + /** + * @return PaymentAttemptRow|null + */ + public function findByExternalId(string $provider, string $externalId, ?int $paymentMethodId = null): ?array; + + /** + * Latest attempt for the order, optionally filtered by method. + * + * @return PaymentAttemptRow|null + */ + public function findLatestForOrder(int $orderId, ?int $paymentMethodId = null): ?array; + + /** + * @return bool true if this event was recorded, false if it already existed + */ + public function recordEvent(int $attemptId, string $eventType, string $providerEventId): bool; + + public function hasEvent(int $attemptId, string $eventType, string $providerEventId): bool; +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentLifecycleException.php b/core/components/minishop3/src/Services/Payment/PaymentLifecycleException.php new file mode 100644 index 000000000..c342bda52 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentLifecycleException.php @@ -0,0 +1,47 @@ + $placeholders + */ + public function __construct( + private readonly string $lexiconKey, + private readonly array $placeholders = [], + private readonly string $kind = self::KIND_INVALID, + string $message = '', + int $code = 0, + ?Throwable $previous = null, + ) { + parent::__construct($message !== '' ? $message : $lexiconKey, $code, $previous); + } + + public function getLexiconKey(): string + { + return $this->lexiconKey; + } + + /** + * @return array + */ + public function getPlaceholders(): array + { + return $this->placeholders; + } + + public function getKind(): string + { + return $this->kind; + } +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php b/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php new file mode 100644 index 000000000..ae62638cb --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php @@ -0,0 +1,607 @@ + [ + PaymentAttemptStatus::PENDING, + PaymentAttemptStatus::AUTHORIZED, + PaymentAttemptStatus::PAID, + PaymentAttemptStatus::FAILED, + PaymentAttemptStatus::CANCELLED, + ], + PaymentAttemptStatus::AUTHORIZED => [ + PaymentAttemptStatus::AUTHORIZED, + PaymentAttemptStatus::PAID, + PaymentAttemptStatus::FAILED, + PaymentAttemptStatus::CANCELLED, + ], + PaymentAttemptStatus::PAID => [ + PaymentAttemptStatus::PAID, + PaymentAttemptStatus::REFUNDED, + PaymentAttemptStatus::PARTIALLY_REFUNDED, + ], + PaymentAttemptStatus::PARTIALLY_REFUNDED => [ + PaymentAttemptStatus::PARTIALLY_REFUNDED, + PaymentAttemptStatus::REFUNDED, + ], + PaymentAttemptStatus::FAILED => [PaymentAttemptStatus::FAILED], + PaymentAttemptStatus::CANCELLED => [PaymentAttemptStatus::CANCELLED], + PaymentAttemptStatus::REFUNDED => [PaymentAttemptStatus::REFUNDED], + ]; + + private const BLOCKED_PAYLOAD_KEYS = [ + 'password', + 'secret', + 'token', + 'api_key', + 'secret_key', + 'properties', + 'class', + 'authorization', + ]; + + /** + * @param \Closure(int, int): (bool|string) $changeStatus + */ + public function __construct( + private readonly PaymentAttemptStoreInterface $store, + private readonly modX $modx, + private readonly \Closure $changeStatus, + ) { + } + + /** + * @param array $payload + * @return PaymentAttemptRow + */ + public function initiate( + int $orderId, + int $paymentMethodId, + string $provider, + float $amount, + string $currency = 'RUB', + ?string $externalId = null, + array $payload = [], + ): array { + $payload = $this->sanitizePayload($payload); + if ($externalId !== null && $externalId !== '') { + $existing = $this->store->findByExternalId($provider, $externalId, $paymentMethodId); + if ($existing !== null) { + return $this->store->update($existing['id'], [ + 'payload' => array_merge($existing['payload'], $payload), + ]); + } + } + $open = $this->store->findLatestForOrder($orderId, $paymentMethodId); + if ($open !== null && $this->isOpen($open['status']) && $this->canRebindOpenAttempt($open, $externalId)) { + return $this->store->update($open['id'], [ + 'external_id' => $externalId ?? $open['external_id'], + 'amount' => $amount, + 'currency' => $currency, + 'payload' => array_merge($open['payload'], $payload), + ]); + } + + return $this->store->create( + $orderId, + $paymentMethodId, + $provider, + $externalId, + PaymentAttemptStatus::PENDING, + $amount, + $currency, + $payload + ); + } + + /** + * @return PaymentAttemptRow + */ + public function markPending(int $attemptId, ?string $providerEventId = null): array + { + return $this->apply($attemptId, PaymentAttemptStatus::PENDING, $providerEventId); + } + + /** + * @return PaymentAttemptRow + */ + public function markAuthorized(int $attemptId, ?string $providerEventId = null): array + { + return $this->apply($attemptId, PaymentAttemptStatus::AUTHORIZED, $providerEventId); + } + + /** + * @return PaymentAttemptRow + */ + public function markPaid(int $attemptId, ?string $providerEventId = null, ?float $paidAmount = null): array + { + return $this->apply($attemptId, PaymentAttemptStatus::PAID, $providerEventId, $paidAmount); + } + + /** + * @return PaymentAttemptRow + */ + public function markFailed(int $attemptId, ?string $providerEventId = null): array + { + return $this->apply($attemptId, PaymentAttemptStatus::FAILED, $providerEventId); + } + + /** + * @return PaymentAttemptRow + */ + public function markCancelled(int $attemptId, ?string $providerEventId = null): array + { + return $this->apply($attemptId, PaymentAttemptStatus::CANCELLED, $providerEventId); + } + + /** + * @return PaymentAttemptRow + */ + public function refund( + int $attemptId, + float $amount, + ?string $refundExternalId = null, + ?string $providerEventId = null, + ): array { + $attempt = $this->requireAttempt($attemptId); + $amount = round($amount, 3); + if ($amount <= 0) { + throw new PaymentLifecycleException('ms3_err_payment_webhook_invalid', ['qty' => $amount]); + } + $eventKey = $this->refundEventKey($providerEventId, $refundExternalId); + foreach ([PaymentAttemptStatus::PARTIALLY_REFUNDED, PaymentAttemptStatus::REFUNDED] as $recordedType) { + if ($this->store->hasEvent($attemptId, $recordedType, $eventKey)) { + $this->syncOrderStatus($attempt['order_id'], $attempt['status']); + + return $attempt; + } + } + $refundedAmount = round($attempt['refunded_amount'] + $amount, 3); + if ($refundedAmount > $attempt['amount'] + 0.0005) { + throw new PaymentLifecycleException( + 'ms3_err_payment_webhook_invalid', + ['qty' => $refundedAmount, 'amount' => $attempt['amount']] + ); + } + $target = $refundedAmount < $attempt['amount'] + ? PaymentAttemptStatus::PARTIALLY_REFUNDED + : PaymentAttemptStatus::REFUNDED; + $this->assertTransition($attempt['status'], $target); + if (!$this->store->recordEvent($attemptId, $target, $eventKey)) { + $this->syncOrderStatus($attempt['order_id'], $attempt['status']); + + return $attempt; + } + $updated = $this->store->update($attemptId, [ + 'status' => $target, + 'refunded_amount' => $refundedAmount, + 'refund_external_id' => $refundExternalId, + 'refundedon' => time(), + ]); + $this->syncOrderStatus($updated['order_id'], $target); + + return $updated; + } + + /** + * @return PaymentAttemptRow + */ + public function partialRefund( + int $attemptId, + float $amount, + ?string $refundExternalId = null, + ?string $providerEventId = null, + ): array { + return $this->refund($attemptId, $amount, $refundExternalId, $providerEventId); + } + + public function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ?string + { + $attempt = $this->store->findLatestForOrder($orderId, $paymentMethodId); + if ($attempt === null || !$this->isOpen($attempt['status'])) { + return null; + } + $link = $attempt['payload']['payment_link'] ?? null; + + return PaymentLinkResolver::normalizePaymentLink(is_string($link) ? $link : null); + } + + /** + * @return PaymentAttemptRow + */ + public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, string $provider): array + { + $attempt = $this->resolveAttempt($event, $paymentMethodId, $provider); + if ($event->payload !== []) { + $attempt = $this->store->update($attempt['id'], [ + 'payload' => array_merge($attempt['payload'], $this->sanitizePayload($event->payload)), + ]); + } + $eventId = $event->providerEventId; + + return match ($event->eventType) { + PaymentAttemptStatus::PENDING, + PaymentAttemptStatus::AUTHORIZED, + PaymentAttemptStatus::FAILED, + PaymentAttemptStatus::CANCELLED => $this->apply($attempt['id'], $event->eventType, $eventId), + PaymentAttemptStatus::PAID => $this->apply( + $attempt['id'], + $event->eventType, + $eventId, + $this->requirePaidAmount($event) + ), + PaymentAttemptStatus::REFUNDED => $this->refundWebhook($attempt, $event, $eventId), + PaymentAttemptStatus::PARTIALLY_REFUNDED => $this->refund( + $attempt['id'], + $event->refundAmount ?? 0.0, + $event->refundExternalId, + $eventId + ), + default => throw new PaymentLifecycleException( + 'ms3_err_payment_webhook_invalid', + ['event' => $event->eventType] + ), + }; + } + + /** + * @param PaymentAttemptRow $attempt + * @return PaymentAttemptRow + */ + private function refundWebhook(array $attempt, PaymentWebhookEvent $event, ?string $eventId): array + { + $qty = $event->refundAmount ?? $this->remainingRefundable($attempt); + if ($qty <= 0) { + if ($attempt['status'] === PaymentAttemptStatus::REFUNDED) { + $this->syncOrderStatus($attempt['order_id'], $attempt['status']); + + return $attempt; + } + throw new PaymentLifecycleException('ms3_err_payment_webhook_invalid', ['qty' => $qty]); + } + + return $this->refund($attempt['id'], $qty, $event->refundExternalId, $eventId); + } + + /** + * @return PaymentAttemptRow + */ + private function apply(int $attemptId, string $target, ?string $providerEventId, ?float $paidAmount = null): array + { + $attempt = $this->requireAttempt($attemptId); + $eventKey = $this->applyEventKey($target, $providerEventId); + if ($target === PaymentAttemptStatus::PAID) { + $this->assertPaidPreconditions($attempt, $paidAmount); + } + if ($attempt['status'] !== $target) { + $this->assertTransition($attempt['status'], $target); + $attempt = $this->store->update($attemptId, ['status' => $target]); + } + $this->syncOrderStatus($attempt['order_id'], $target); + $this->store->recordEvent($attemptId, $target, $eventKey); + + return $this->store->findById($attemptId) ?? $attempt; + } + + /** + * @return PaymentAttemptRow + */ + private function resolveAttempt( + PaymentWebhookEvent $event, + int $paymentMethodId, + string $provider, + ): array { + if ($event->externalId !== null && $event->externalId !== '') { + $byExternal = $this->store->findByExternalId($provider, $event->externalId, $paymentMethodId); + if ($byExternal !== null) { + $this->assertAttemptMatchesWebhook($byExternal, $paymentMethodId); + + return $byExternal; + } + $otherMethod = $this->store->findByExternalId($provider, $event->externalId); + if ($otherMethod !== null) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => 'payment_method', 'to' => (string) $paymentMethodId], + PaymentLifecycleException::KIND_CONFLICT + ); + } + $order = $this->resolveOrder($event); + if ($order !== null) { + $this->assertOrderPaymentMethod($order, $paymentMethodId); + $existing = $this->store->findLatestForOrder((int) $order->get('id'), $paymentMethodId); + if ( + $existing !== null + && ($existing['external_id'] === null || $existing['external_id'] === '') + ) { + return $this->store->update($existing['id'], ['external_id' => $event->externalId]); + } + } + + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + ['external_id' => $event->externalId], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + $order = $this->resolveOrder($event); + if ($order === null) { + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + [], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + $this->assertOrderPaymentMethod($order, $paymentMethodId); + $existing = $this->store->findLatestForOrder((int) $order->get('id'), $paymentMethodId); + if ($existing === null) { + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + ['order_id' => (int) $order->get('id')], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + + return $existing; + } + + private function resolveOrder(PaymentWebhookEvent $event): ?msOrder + { + if ($event->orderId !== null && $event->orderId > 0) { + $order = $this->modx->getObject(msOrder::class, ['id' => $event->orderId]); + if ($order instanceof msOrder) { + return $order; + } + } + if ($event->orderUuid !== null && $event->orderUuid !== '') { + $order = $this->modx->getObject(msOrder::class, ['uuid' => $event->orderUuid]); + if ($order instanceof msOrder) { + return $order; + } + } + + return null; + } + + /** + * @return PaymentAttemptRow + */ + private function requireAttempt(int $attemptId): array + { + $attempt = $this->store->findById($attemptId); + if ($attempt === null) { + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + ['id' => $attemptId], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + + return $attempt; + } + + private function assertTransition(string $from, string $to): void + { + $allowed = self::ALLOWED_TRANSITIONS[$from] ?? []; + if (!in_array($to, $allowed, true)) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => $from, 'to' => $to], + PaymentLifecycleException::KIND_CONFLICT + ); + } + } + + /** + * @param PaymentAttemptRow $attempt + */ + private function assertPaidPreconditions(array $attempt, ?float $paidAmount): void + { + $order = $this->modx->getObject(msOrder::class, ['id' => $attempt['order_id']]); + if (!$order instanceof msOrder) { + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + ['order_id' => $attempt['order_id']], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + $canceledId = (int) $this->modx->getOption('ms3_status_canceled', null, 5) ?: 5; + if ((int) $order->get('status_id') === $canceledId) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => 'canceled', 'to' => PaymentAttemptStatus::PAID], + PaymentLifecycleException::KIND_CONFLICT + ); + } + $this->assertOrderPaymentMethod($order, $attempt['payment_method_id']); + if ($paidAmount === null) { + return; + } + $deltaAttempt = abs($paidAmount - $attempt['amount']); + $deltaCost = abs($paidAmount - (float) $order->get('cost')); + if ($deltaAttempt > 0.001 && $deltaCost > 0.001) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['amount' => $paidAmount], + PaymentLifecycleException::KIND_CONFLICT + ); + } + } + + /** + * @param PaymentAttemptRow $attempt + */ + private function assertAttemptMatchesWebhook(array $attempt, int $paymentMethodId): void + { + if ($attempt['payment_method_id'] !== $paymentMethodId) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => 'payment_method', 'to' => (string) $paymentMethodId], + PaymentLifecycleException::KIND_CONFLICT + ); + } + $order = $this->modx->getObject(msOrder::class, ['id' => $attempt['order_id']]); + if (!$order instanceof msOrder) { + throw new PaymentLifecycleException( + 'ms3_err_payment_attempt_nf', + ['order_id' => $attempt['order_id']], + PaymentLifecycleException::KIND_NOT_FOUND + ); + } + $this->assertOrderPaymentMethod($order, $paymentMethodId); + } + + private function assertOrderPaymentMethod(msOrder $order, int $paymentMethodId): void + { + $orderPaymentId = (int) $order->get('payment_id'); + if ($orderPaymentId > 0 && $orderPaymentId !== $paymentMethodId) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => (string) $orderPaymentId, 'to' => (string) $paymentMethodId], + PaymentLifecycleException::KIND_CONFLICT + ); + } + } + + private function applyEventKey(string $target, ?string $providerEventId): string + { + if ($providerEventId !== null && $providerEventId !== '') { + return $providerEventId; + } + + return $target; + } + + private function refundEventKey(?string $providerEventId, ?string $refundExternalId): string + { + $key = $providerEventId ?? $refundExternalId ?? ''; + if ($key === '') { + throw new PaymentLifecycleException('ms3_err_payment_webhook_invalid', ['event' => 'refund']); + } + + return $key; + } + + private function syncOrderStatus(int $orderId, string $attemptStatus): void + { + $statusId = $this->orderStatusFor($attemptStatus); + if ($statusId <= 0) { + return; + } + $result = ($this->changeStatus)($orderId, $statusId); + if ($result === true) { + return; + } + $message = is_string($result) ? $result : 'ms3_err_unknown'; + if ($this->isAlreadySameStatus($message)) { + return; + } + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['status' => $message], + PaymentLifecycleException::KIND_CONFLICT, + $message + ); + } + + private function orderStatusFor(string $attemptStatus): int + { + return match ($attemptStatus) { + PaymentAttemptStatus::PAID => (int) $this->modx->getOption('ms3_status_paid', null, 3) ?: 3, + PaymentAttemptStatus::FAILED, PaymentAttemptStatus::CANCELLED => (int) $this->modx->getOption( + 'ms3_payment_on_failed_status', + null, + 5 + ), + PaymentAttemptStatus::REFUNDED => (int) $this->modx->getOption('ms3_payment_on_refunded_status', null, 5), + default => 0, + }; + } + + private function isAlreadySameStatus(string $message): bool + { + return str_contains($message, 'ms3_err_status_same') + || $message === $this->modx->lexicon('ms3_err_status_same'); + } + + private function isOpen(string $status): bool + { + return in_array($status, [ + PaymentAttemptStatus::PENDING, + PaymentAttemptStatus::AUTHORIZED, + ], true); + } + + /** + * @param PaymentAttemptRow $open + */ + private function canRebindOpenAttempt(array $open, ?string $externalId): bool + { + $current = $open['external_id'] ?? ''; + if ($current === '') { + return true; + } + if ($externalId === null || $externalId === '') { + return true; + } + + return $current === $externalId; + } + + private function requirePaidAmount(PaymentWebhookEvent $event): float + { + if ($event->amount === null) { + throw new PaymentLifecycleException( + 'ms3_err_payment_webhook_invalid', + ['amount' => 'required'] + ); + } + + return $event->amount; + } + + /** + * @param PaymentAttemptRow $attempt + */ + private function remainingRefundable(array $attempt): float + { + return round(max(0.0, $attempt['amount'] - $attempt['refunded_amount']), 3); + } + + /** + * @param array $payload + * @return array + */ + private function sanitizePayload(array $payload): array + { + $clean = []; + foreach ($payload as $key => $value) { + if (!is_string($key) || in_array(strtolower($key), self::BLOCKED_PAYLOAD_KEYS, true)) { + continue; + } + if (is_array($value)) { + $clean[$key] = $this->sanitizePayload($value); + continue; + } + if (is_scalar($value) || $value === null) { + $clean[$key] = $value; + } + } + + return $clean; + } +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentPublicFields.php b/core/components/minishop3/src/Services/Payment/PaymentPublicFields.php new file mode 100644 index 000000000..8c6d49b20 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentPublicFields.php @@ -0,0 +1,43 @@ + + */ + public const FIELDS = ['id', 'name', 'description', 'price', 'logo']; + + /** + * @return array{id: int, name: string, description: string, price: mixed, logo: string}|null + */ + public static function fromEntity(mixed $entity): ?array + { + if (!is_object($entity) || !method_exists($entity, 'get')) { + return null; + } + + return [ + 'id' => (int) $entity->get('id'), + 'name' => (string) $entity->get('name'), + 'description' => (string) $entity->get('description'), + 'price' => $entity->get('price'), + 'logo' => (string) $entity->get('logo'), + ]; + } + + /** + * @return array{id: int, name: string, description: string, price: mixed, logo: string}|array{} + */ + public static function fromEntityOrEmpty(mixed $entity): array + { + return self::fromEntity($entity) ?? []; + } +} diff --git a/core/components/minishop3/src/Services/Payment/PaymentService.php b/core/components/minishop3/src/Services/Payment/PaymentService.php index fcfc1ad1a..603c3c88d 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentService.php @@ -56,7 +56,7 @@ public function loadPaymentHandler(msPayment $payment): ?PaymentProviderInterfac } try { - $controller = new $class($this->ms3, []); + $controller = new $class($this->ms3, ['payment' => $payment]); if (!$controller instanceof PaymentProviderInterface) { $this->modx->log( @@ -107,7 +107,12 @@ public function sendToPaymentGateway( } } - return $controller->send($order); + $response = $controller->send($order); + if (!empty($response['success'])) { + $this->recordAttemptFromSend($payment, $order, $response); + } + + return $response; } /** @@ -194,4 +199,57 @@ public function removePayment(msPayment $payment, array $ancestors = []): bool return true; } + + /** + * Persist a payment attempt after a successful send() when the provider + * returned an external id. DefaultPayment has neither payment_id nor + * external_id and is left unchanged. + * + * @param array $response + */ + private function recordAttemptFromSend(msPayment $payment, msOrder $order, array $response): void + { + $data = is_array($response['data'] ?? null) ? $response['data'] : []; + $externalId = $data['external_id'] ?? $data['payment_id'] ?? null; + if (is_int($externalId) || is_float($externalId)) { + $externalId = (string) $externalId; + } + if (!is_string($externalId) || $externalId === '') { + return; + } + if (!$this->modx->services->has('ms3_payment_lifecycle')) { + return; + } + $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); + if (!$lifecycle instanceof PaymentLifecycleService) { + return; + } + $class = $payment->get('class'); + $provider = is_string($class) && $class !== '' ? $class : $this->defaultControllerClass; + $payload = []; + $link = $data['payment_link'] ?? null; + if (is_string($link) && $link !== '') { + $payload['payment_link'] = $link; + } + try { + $lifecycle->initiate( + (int) $order->get('id'), + (int) $payment->get('id'), + $provider, + (float) $order->get('cost'), + is_string($data['currency'] ?? null) ? $data['currency'] : 'RUB', + $externalId, + $payload + ); + } catch (\Throwable $exception) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + sprintf( + 'PaymentService: failed to record payment attempt for order #%s: %s', + (string) $order->get('id'), + $exception->getMessage() + ) + ); + } + } } diff --git a/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php b/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php new file mode 100644 index 000000000..2b4fc45e0 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php @@ -0,0 +1,264 @@ +attemptsTable = $this->quoteTable($attemptsTable); + $this->eventsTable = $this->quoteTable($eventsTable); + } + + public function create( + int $orderId, + int $paymentMethodId, + string $provider, + ?string $externalId, + string $status, + float $amount, + string $currency, + array $payload, + ): array { + $now = time(); + $sql = "INSERT INTO {$this->attemptsTable} + (order_id, payment_method_id, provider, external_id, status, amount, currency, payload, + refunded_amount, createdon, updatedon) + VALUES (:order_id, :payment_method_id, :provider, :external_id, :status, :amount, :currency, :payload, + 0, :createdon, :updatedon)"; + $stmt = $this->prepare($sql); + $stmt->execute([ + 'order_id' => $orderId, + 'payment_method_id' => $paymentMethodId, + 'provider' => $provider, + 'external_id' => $externalId, + 'status' => $status, + 'amount' => $amount, + 'currency' => $currency, + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'createdon' => $now, + 'updatedon' => $now, + ]); + $id = (int) $this->lastInsertId(); + $row = $this->findById($id); + if ($row === null) { + throw new RuntimeException('Failed to load created payment attempt'); + } + + return $row; + } + + public function update(int $id, array $fields): array + { + $allowed = [ + 'external_id', + 'status', + 'amount', + 'currency', + 'payload', + 'refunded_amount', + 'refund_external_id', + 'refundedon', + ]; + $set = ['updatedon = :updatedon']; + $params = ['id' => $id, 'updatedon' => time()]; + foreach ($allowed as $column) { + if (!array_key_exists($column, $fields)) { + continue; + } + $set[] = "{$column} = :{$column}"; + $value = $fields[$column]; + if ($column === 'payload' && is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + $params[$column] = $value; + } + $sql = 'UPDATE ' . $this->attemptsTable . ' SET ' . implode(', ', $set) . ' WHERE id = :id'; + $stmt = $this->prepare($sql); + $stmt->execute($params); + $row = $this->findById($id); + if ($row === null) { + throw new RuntimeException('Payment attempt not found after update'); + } + + return $row; + } + + public function findById(int $id): ?array + { + return $this->fetchOne("SELECT * FROM {$this->attemptsTable} WHERE id = :id", ['id' => $id]); + } + + public function findByExternalId(string $provider, string $externalId, ?int $paymentMethodId = null): ?array + { + $sql = "SELECT * FROM {$this->attemptsTable} WHERE provider = :provider AND external_id = :external_id"; + $params = [ + 'provider' => $provider, + 'external_id' => $externalId, + ]; + if ($paymentMethodId !== null) { + $sql .= ' AND payment_method_id = :payment_method_id'; + $params['payment_method_id'] = $paymentMethodId; + } + + return $this->fetchOne($sql, $params); + } + + public function findLatestForOrder(int $orderId, ?int $paymentMethodId = null): ?array + { + $sql = "SELECT * FROM {$this->attemptsTable} WHERE order_id = :order_id"; + $params = ['order_id' => $orderId]; + if ($paymentMethodId !== null) { + $sql .= ' AND payment_method_id = :payment_method_id'; + $params['payment_method_id'] = $paymentMethodId; + } + + return $this->fetchOne($sql . ' ORDER BY id DESC LIMIT 1', $params); + } + + public function recordEvent(int $attemptId, string $eventType, string $providerEventId): bool + { + $sql = "INSERT INTO {$this->eventsTable} + (attempt_id, event_type, provider_event_id, createdon) + VALUES (:attempt_id, :event_type, :provider_event_id, :createdon)"; + $stmt = $this->prepare($sql); + try { + $stmt->execute([ + 'attempt_id' => $attemptId, + 'event_type' => $eventType, + 'provider_event_id' => $providerEventId, + 'createdon' => time(), + ]); + } catch (PDOException $exception) { + if ($this->isDuplicate($exception)) { + return false; + } + throw $exception; + } + + return true; + } + + public function hasEvent(int $attemptId, string $eventType, string $providerEventId): bool + { + $sql = "SELECT 1 FROM {$this->eventsTable} + WHERE attempt_id = :attempt_id AND event_type = :event_type AND provider_event_id = :provider_event_id + LIMIT 1"; + $stmt = $this->prepare($sql); + $stmt->execute([ + 'attempt_id' => $attemptId, + 'event_type' => $eventType, + 'provider_event_id' => $providerEventId, + ]); + + return $stmt->fetchColumn() !== false; + } + + /** + * @param array $params + * @return PaymentAttemptRow|null + */ + private function fetchOne(string $sql, array $params): ?array + { + $stmt = $this->prepare($sql); + $stmt->execute($params); + + return $this->hydrate($stmt->fetch(PDO::FETCH_ASSOC)); + } + + /** + * @param array|false $row + * @return PaymentAttemptRow|null + */ + private function hydrate(array|false $row): ?array + { + if ($row === false) { + return null; + } + $payload = []; + if (isset($row['payload']) && is_string($row['payload']) && $row['payload'] !== '') { + $decoded = json_decode($row['payload'], true); + $payload = is_array($decoded) ? $decoded : []; + } + + return [ + 'id' => (int) $row['id'], + 'order_id' => (int) $row['order_id'], + 'payment_method_id' => (int) $row['payment_method_id'], + 'provider' => (string) $row['provider'], + 'external_id' => $row['external_id'] !== null ? (string) $row['external_id'] : null, + 'status' => (string) $row['status'], + 'amount' => round((float) $row['amount'], 3), + 'currency' => (string) $row['currency'], + 'payload' => $payload, + 'refunded_amount' => round((float) $row['refunded_amount'], 3), + 'refund_external_id' => $row['refund_external_id'] !== null + ? (string) $row['refund_external_id'] + : null, + 'refundedon' => $row['refundedon'] !== null ? (int) $row['refundedon'] : null, + 'createdon' => (int) ($row['createdon'] ?? 0), + 'updatedon' => (int) ($row['updatedon'] ?? 0), + ]; + } + + private function isDuplicate(PDOException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? $exception->getCode(); + + return (string) $sqlState === '23000'; + } + + private function lastInsertId(): string + { + if (method_exists($this->db, 'lastInsertId')) { + return (string) $this->db->lastInsertId(); + } + + throw new RuntimeException('Payment attempt store requires lastInsertId()'); + } + + private function prepare(string $sql): PDOStatement + { + if (!method_exists($this->db, 'prepare')) { + throw new RuntimeException('Payment attempt store requires prepare() on the DB connection'); + } + $stmt = $this->db->prepare($sql); + if (!$stmt instanceof PDOStatement) { + throw new RuntimeException('Payment attempt store failed to prepare SQL'); + } + + return $stmt; + } + + private function quoteTable(string $table): string + { + $bare = str_replace('`', '', $table); + if ($bare === '' || !preg_match('/^[A-Za-z0-9_]+$/', $bare)) { + throw new InvalidArgumentException('Invalid payment attempt table name'); + } + + return '`' . $bare . '`'; + } +} diff --git a/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php index 23e0e9e58..9f04e10be 100644 --- a/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php +++ b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php @@ -35,6 +35,7 @@ "group('/payment'", 'DeliveryController', 'PaymentController', + 'PaymentWebhookController', ] as $needle ) { if (!str_contains($webRoutes, $needle)) { diff --git a/core/components/minishop3/tests/PaymentLifecycleWiringTest.php b/core/components/minishop3/tests/PaymentLifecycleWiringTest.php new file mode 100644 index 000000000..e492dd638 --- /dev/null +++ b/core/components/minishop3/tests/PaymentLifecycleWiringTest.php @@ -0,0 +1,72 @@ +toArray()')) { + $fail('OrdersPageService must not expose payment toArray()'); +} +if (str_contains($snippet, '$payment->toArray()')) { + $fail('ms3_get_order must not expose payment toArray()'); +} +$paymentService = file_get_contents($srcRoot . '/Services/Payment/PaymentService.php'); +$notification = file_get_contents($srcRoot . '/Notifications/Notification.php'); +if ($paymentService === false || $notification === false) { + $fail('cannot read payment service / notification'); +} +if (!str_contains($paymentService, 'recordAttemptFromSend')) { + $fail('PaymentService must record an attempt after a successful send()'); +} +if (str_contains($notification, '$payment->toArray()')) { + $fail('Notification must not expose payment toArray()'); +} + +fwrite(STDOUT, "OK PaymentLifecycleWiringTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php index cd04e6389..95121b8ad 100644 --- a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php +++ b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php @@ -47,6 +47,7 @@ '/api/v1/delivery/list', '/api/v1/payment/get/', '/api/v1/payment/list', + '/api/v1/payment/webhook/', '/api/v1/customer/token/get', '/api/v1/health', ] as $prefix diff --git a/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php b/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php new file mode 100644 index 000000000..62a64a02b --- /dev/null +++ b/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php @@ -0,0 +1,315 @@ +handle([]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testInvalidJsonIsBadRequest(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(true, null)), + '{not-json' + ); + $response = $controller->handle(['payment_method_id' => 4]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testInvalidPayloadIsBadRequest(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(true, null)), + '{"event":"unknown"}' + ); + $response = $controller->handle(['payment_method_id' => 4]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testBadSignatureIsUnauthorized(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(false, null)), + '{"event":"paid"}' + ); + $response = $controller->handle(['payment_method_id' => 4]); + $data = $response->getData(); + + self::assertSame(HttpStatus::UNAUTHORIZED, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::UNAUTHORIZED, $data['error_code'] ?? null); + } + + public function testVerifyWebhookReceivesRawBody(): void + { + $seenRaw = new stdClass(); + $seenRaw->value = null; + $body = '{"event":"paid","id":"ext-4"}'; + $handler = $this->handler(false, null, $seenRaw); + $controller = $this->controllerWithBody($this->modxWithHandler($handler), $body); + $controller->handle(['payment_method_id' => 4]); + + self::assertSame($body, $seenRaw->value); + } + + public function testPaidWebhookReturnsSuccess(): void + { + $seenRaw = new stdClass(); + $seenRaw->value = null; + $event = new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-4', + amount: 25.0, + providerEventId: 'cb-1', + ); + $handler = $this->handler(true, $event, $seenRaw); + $store = new InMemoryPaymentAttemptStore(); + $order = new StubMsOrder(['id' => 40, 'status_id' => 2, 'cost' => 25, 'payment_id' => 4]); + $lifecycle = $this->lifecycle($store, $order); + $lifecycle->initiate(40, 4, $handler::class, 25.0, 'RUB', 'ext-4'); + $body = '{"event":"paid","id":"ext-4"}'; + $controller = $this->controllerWithBody( + $this->modxWithHandler($handler, $lifecycle), + $body + ); + $response = $controller->handle(['payment_method_id' => 4]); + $data = $response->getData(); + + self::assertSame(HttpStatus::OK, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(PaymentAttemptStatus::PAID, $data['data']['status'] ?? null); + self::assertSame($body, $seenRaw->value); + } + + public function testUnexpectedLifecycleErrorIsInternal(): void + { + $handler = $this->handler( + true, + new PaymentWebhookEvent(eventType: PaymentAttemptStatus::PAID, externalId: 'x', amount: 1.0) + ); + $fakeLifecycle = new class { + public function applyWebhook(PaymentWebhookEvent $event, int $methodId, string $provider): array + { + throw new \RuntimeException('boom'); + } + }; + $controller = $this->controllerWithBody( + $this->modxWithHandler($handler, $fakeLifecycle), + '{"event":"paid"}' + ); + $response = $controller->handle(['payment_method_id' => 4]); + $data = $response->getData(); + + self::assertSame(HttpStatus::INTERNAL_SERVER_ERROR, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::INTERNAL_ERROR, $data['error_code'] ?? null); + } + + public function testDefaultPaymentDoesNotImplementWebhookContract(): void + { + self::assertFalse( + is_subclass_of(DefaultPayment::class, PaymentWebhookHandlerInterface::class) + ); + } + + private function controllerWithBody(modX $modx, string $body): PaymentWebhookController + { + return new class ($modx, $body) extends PaymentWebhookController { + public function __construct(modX $modx, private readonly string $raw) + { + parent::__construct($modx); + } + + protected function readRawRequestBody(): string + { + return $this->raw; + } + }; + } + + private function lifecycle(InMemoryPaymentAttemptStore $store, msOrder $order): PaymentLifecycleService + { + $modx = new class ($order) extends modX { + public function __construct(private msOrder $order) + { + parent::__construct(); + } + + public function getOption(string $key, $options = null, $default = null) + { + return match ($key) { + 'ms3_status_paid' => 3, + 'ms3_status_canceled' => 5, + 'ms3_payment_on_failed_status' => 5, + 'ms3_payment_on_refunded_status' => 5, + default => $default, + }; + } + + public function getObject($className, $criteria = null) + { + if ($className === msOrder::class) { + return $this->order; + } + + return null; + } + + public function lexicon(string $key, array $params = []): string + { + return $key; + } + }; + + return new PaymentLifecycleService($store, $modx, static fn (): bool => true); + } + + private function modxWithHandler(object $handler, ?object $lifecycle = null): modX + { + $payment = new StubMsPayment(['id' => 4, 'active' => 1, 'class' => $handler::class]); + + return new class ($payment, $handler, $lifecycle) extends modX { + public function __construct( + private msPayment $payment, + private object $handler, + private ?object $lifecycle, + ) { + parent::__construct(); + $this->services = new class ($handler, $lifecycle) { + public function __construct(private object $handler, private ?object $lifecycle) + { + } + + public function has(string $key): bool + { + return $key === 'ms3_payment_service' + || ($key === 'ms3_payment_lifecycle' && $this->lifecycle !== null); + } + + public function get(string $key): mixed + { + if ($key === 'ms3_payment_lifecycle') { + return $this->lifecycle; + } + if ($key !== 'ms3_payment_service') { + return null; + } + + return new class ($this->handler) { + public function __construct(private object $handler) + { + } + + public function loadPaymentHandler(msPayment $payment): object + { + return $this->handler; + } + }; + } + }; + } + + public function getObject($className, $criteria = null) + { + return $className === msPayment::class ? $this->payment : null; + } + }; + } + + private function handler(bool $verified, ?PaymentWebhookEvent $event, ?stdClass $seenRaw = null): object + { + return new class ($verified, $event, $seenRaw) implements PaymentProviderInterface, PaymentWebhookHandlerInterface { + public function __construct( + private bool $verified, + private ?PaymentWebhookEvent $event, + private ?stdClass $seenRaw, + ) { + } + + public function verifyWebhook(string $rawBody, array $payload, array $headers, msPayment $method): bool + { + if ($this->seenRaw !== null) { + $this->seenRaw->value = $rawBody; + } + + return $this->verified; + } + + public function parseWebhook(array $payload, array $headers): ?PaymentWebhookEvent + { + return $this->event; + } + + public function send(msOrder $order): array + { + return []; + } + + public function receive(msOrder $order): array + { + return []; + } + + public function getPaymentLink(msOrder $order): ?string + { + return null; + } + + public function getCost(msOrder $order, msPayment $payment, float $cost): float + { + return $cost; + } + + public function getOrderHash(msOrder $order): string + { + return ''; + } + }; + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php new file mode 100644 index 000000000..11f8b7367 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php @@ -0,0 +1,380 @@ + */ + private array $statusChanges = []; + + protected function setUp(): void + { + if (!class_exists(modX::class, false)) { + require_once dirname(__DIR__, 3) . '/stubs/ModxStub.php'; + } + require_once dirname(__DIR__, 3) . '/stubs/StubMsOrder.php'; + $this->statusChanges = []; + } + + public function testPaidCallbackSetsOrderPaidAndIsIdempotent(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-1', ['payment_link' => 'https://pay.example/1']); + self::assertSame('https://pay.example/1', $service->storedPaymentLink(10, 2)); + self::assertNull($service->storedPaymentLink(10, 99)); + + $paid = $service->markPaid($attempt['id'], 'evt-paid'); + self::assertSame(PaymentAttemptStatus::PAID, $paid['status']); + self::assertSame([[10, 3]], $this->statusChanges); + + $again = $service->markPaid($attempt['id'], 'evt-paid'); + self::assertSame(PaymentAttemptStatus::PAID, $again['status']); + self::assertCount(2, $this->statusChanges); + self::assertNull($service->storedPaymentLink(10, 2)); + } + + public function testFailedPaymentCancelsOrder(): void + { + $service = $this->service(); + $attempt = $service->initiate(11, 2, 'TestPay', 50.0); + $failed = $service->markFailed($attempt['id'], 'evt-fail'); + self::assertSame(PaymentAttemptStatus::FAILED, $failed['status']); + self::assertSame([[11, 5]], $this->statusChanges); + } + + public function testPaidAfterCanceledOrderConflicts(): void + { + $order = new StubMsOrder(['id' => 12, 'status_id' => 5, 'cost' => 10]); + $service = $this->service($order); + $attempt = $service->initiate(12, 2, 'TestPay', 10.0); + $this->expectException(PaymentLifecycleException::class); + $service->markPaid($attempt['id'], 'evt-late'); + } + + public function testRefundAndPartialRefundPersist(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0); + $service->markPaid($attempt['id'], 'paid'); + $this->statusChanges = []; + + $partial = $service->partialRefund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); + self::assertSame(PaymentAttemptStatus::PARTIALLY_REFUNDED, $partial['status']); + self::assertSame(30.0, $partial['refunded_amount']); + self::assertSame([], $this->statusChanges); + + $full = $service->refund($attempt['id'], 70.0, 'ref-2', 'evt-r2'); + self::assertSame(PaymentAttemptStatus::REFUNDED, $full['status']); + self::assertSame(100.0, $full['refunded_amount']); + self::assertSame([[10, 5]], $this->statusChanges); + + $again = $service->refund($attempt['id'], 70.0, 'ref-2', 'evt-r2'); + self::assertSame(PaymentAttemptStatus::REFUNDED, $again['status']); + self::assertCount(2, $this->statusChanges); + } + + public function testRefundWithoutEventIdIsInvalid(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0); + $service->markPaid($attempt['id'], 'paid'); + try { + $service->refund($attempt['id'], 10.0); + self::fail('expected invalid refund without event id'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_INVALID, $exception->getKind()); + } + try { + $service->refund($attempt['id'], 10.0); + self::fail('expected second refund without event id to stay invalid'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_INVALID, $exception->getKind()); + } + } + + public function testOverRefundIsInvalid(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0); + $service->markPaid($attempt['id'], 'paid'); + $service->partialRefund($attempt['id'], 80.0, 'ref-1', 'evt-r1'); + $this->expectException(PaymentLifecycleException::class); + $service->refund($attempt['id'], 30.0, 'ref-2', 'evt-r2'); + } + + public function testRetryAfterFailedOrderSyncReplaysStatusChange(): void + { + $calls = 0; + $service = $this->service(changeStatus: function (int $orderId, int $statusId) use (&$calls): bool|string { + $this->statusChanges[] = [$orderId, $statusId]; + $calls++; + + return $calls === 1 ? 'ms3_err_unknown' : true; + }); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0); + try { + $service->markPaid($attempt['id'], 'evt-paid'); + self::fail('expected first sync to fail'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + $paid = $service->markPaid($attempt['id'], 'evt-paid'); + self::assertSame(PaymentAttemptStatus::PAID, $paid['status']); + self::assertSame(2, $calls); + } + + public function testWebhookDoesNotCreateAttempt(): void + { + $service = $this->service(); + try { + $service->applyWebhook( + new PaymentWebhookEvent(eventType: PaymentAttemptStatus::PAID, externalId: 'missing'), + 2, + 'TestPay' + ); + self::fail('expected missing attempt'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_NOT_FOUND, $exception->getKind()); + } + } + + public function testWebhookWrongPaymentMethodConflicts(): void + { + $order = new StubMsOrder(['id' => 14, 'status_id' => 2, 'cost' => 20, 'payment_id' => 7, 'uuid' => 'u-14']); + $service = $this->service($order); + $service->initiate(14, 7, 'TestPay', 20.0, 'RUB', 'gw-14'); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'gw-14', + providerEventId: 'cb-1', + ), + 9, + 'TestPay' + ); + self::fail('expected method conflict'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + } + + public function testPaidAmountMismatchConflicts(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0); + $this->expectException(PaymentLifecycleException::class); + $service->markPaid($attempt['id'], 'evt-paid', 40.0); + } + + public function testPaidOnMissingOrderIsNotFound(): void + { + $service = $this->service(); + $attempt = $service->initiate(99, 2, 'TestPay', 1.0); + try { + $service->markPaid($attempt['id'], 'evt-paid'); + self::fail('expected missing order'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_NOT_FOUND, $exception->getKind()); + } + } + + public function testWebhookResolvesByExternalId(): void + { + $service = $this->service(new StubMsOrder(['id' => 14, 'status_id' => 2, 'cost' => 20, 'uuid' => 'u-14'])); + $service->initiate(14, 7, 'TestPay', 20.0, 'RUB', 'gw-14'); + $event = new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'gw-14', + amount: 20.0, + providerEventId: 'cb-1', + ); + $paid = $service->applyWebhook($event, 7, 'TestPay'); + self::assertSame(PaymentAttemptStatus::PAID, $paid['status']); + self::assertSame(14, $paid['order_id']); + } + + public function testUnknownWebhookEventIsInvalid(): void + { + $service = $this->service(); + $service->initiate(16, 2, 'TestPay', 1.0, 'RUB', 'gw-16'); + $this->expectException(PaymentLifecycleException::class); + $service->applyWebhook( + new PaymentWebhookEvent(eventType: 'chargeback', externalId: 'gw-16'), + 2, + 'TestPay' + ); + } + + public function testSecretsAreStrippedFromPayload(): void + { + $service = $this->service(); + $attempt = $service->initiate(15, 2, 'TestPay', 1.0, 'RUB', null, [ + 'payment_link' => 'https://pay.example/x', + 'secret' => 'nope', + 'properties' => ['token' => 'x'], + 'meta' => ['secret' => 'nested', 'ok' => 'yes'], + ]); + self::assertSame('https://pay.example/x', $attempt['payload']['payment_link']); + self::assertArrayNotHasKey('secret', $attempt['payload']); + self::assertArrayNotHasKey('properties', $attempt['payload']); + self::assertSame(['ok' => 'yes'], $attempt['payload']['meta']); + } + + public function testUnknownExternalIdDoesNotPayLatestAttempt(): void + { + $order = new StubMsOrder(['id' => 10, 'status_id' => 2, 'cost' => 100, 'payment_id' => 2]); + $service = $this->service($order); + $current = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'BBB', [ + 'payment_link' => 'https://pay.example/b', + ]); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'AAA', + orderId: 10, + amount: 100.0, + providerEventId: 'old-cb', + ), + 2, + 'TestPay' + ); + self::fail('stale AAA must not pay BBB'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_NOT_FOUND, $exception->getKind()); + } + self::assertSame('https://pay.example/b', $service->storedPaymentLink(10, 2)); + self::assertSame('BBB', $current['external_id']); + } + + public function testInitiateDoesNotOverwriteDifferentExternalId(): void + { + $service = $this->service(); + $first = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'AAA'); + $second = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'BBB'); + self::assertNotSame($first['id'], $second['id']); + self::assertSame('AAA', $first['external_id']); + self::assertSame('BBB', $second['external_id']); + } + + public function testStoredLinkIgnoresFailedAttempt(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-fail', [ + 'payment_link' => 'https://pay.example/stale', + ]); + $service->markFailed($attempt['id'], 'evt-fail'); + self::assertNull($service->storedPaymentLink(10, 2)); + } + + public function testWebhookPaidWithoutAmountIsInvalid(): void + { + $service = $this->service(); + $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-amt'); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-amt', + providerEventId: 'cb-amt', + ), + 2, + 'TestPay' + ); + self::fail('paid webhook must include amount'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_INVALID, $exception->getKind()); + } + } + + public function testFullRefundWebhookWithoutAmountUsesRemainder(): void + { + $service = $this->service(); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-ref'); + $service->markPaid($attempt['id'], 'paid'); + $service->partialRefund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); + $this->statusChanges = []; + $full = $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::REFUNDED, + externalId: 'ext-ref', + providerEventId: 'evt-r2', + refundExternalId: 'ref-2', + ), + 2, + 'TestPay' + ); + self::assertSame(PaymentAttemptStatus::REFUNDED, $full['status']); + self::assertSame(100.0, $full['refunded_amount']); + self::assertSame([[10, 5]], $this->statusChanges); + } + + /** + * @param \Closure(int, int): (bool|string)|null $changeStatus + */ + private function service(?msOrder $order = null, ?\Closure $changeStatus = null): PaymentLifecycleService + { + $order ??= new StubMsOrder(['id' => 10, 'status_id' => 2, 'cost' => 100]); + $store = new InMemoryPaymentAttemptStore(); + $modx = new class ($order) extends modX { + public function __construct(private msOrder $order) + { + parent::__construct(); + } + + public function getOption(string $key, $options = null, $default = null) + { + return match ($key) { + 'ms3_status_paid' => 3, + 'ms3_status_canceled' => 5, + 'ms3_payment_on_failed_status' => 5, + 'ms3_payment_on_refunded_status' => 5, + default => $default, + }; + } + + public function getObject($className, $criteria = null) + { + if ($className !== msOrder::class) { + return null; + } + if (is_array($criteria) && isset($criteria['id']) && (int) $criteria['id'] === (int) $this->order->get('id')) { + return $this->order; + } + if (is_array($criteria) && isset($criteria['uuid']) && $criteria['uuid'] === $this->order->get('uuid')) { + return $this->order; + } + + return null; + } + + public function lexicon(string $key, array $params = []): string + { + return $key; + } + }; + + return new PaymentLifecycleService( + $store, + $modx, + $changeStatus ?? function (int $orderId, int $statusId): bool|string { + $this->statusChanges[] = [$orderId, $statusId]; + + return true; + } + ); + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentPublicFieldsTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentPublicFieldsTest.php new file mode 100644 index 000000000..d9e3a8ce1 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentPublicFieldsTest.php @@ -0,0 +1,36 @@ + 4, + 'name' => 'Card', + 'description' => 'Visa', + 'price' => '3%', + 'logo' => '/logo.png', + 'class' => 'Secret\\Gateway', + 'properties' => ['secret' => 'abc'], + ]); + $public = PaymentPublicFields::fromEntity($payment); + self::assertNotNull($public); + self::assertSame(4, $public['id']); + self::assertSame('Card', $public['name']); + self::assertArrayNotHasKey('properties', $public); + self::assertArrayNotHasKey('class', $public); + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php new file mode 100644 index 000000000..af68542a6 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php @@ -0,0 +1,134 @@ + true); + $service = new PaymentService($this->modxWithLifecycle($lifecycle)); + $order = new StubMsOrder(['id' => 31, 'cost' => 12.5, 'payment_id' => 9]); + $payment = new StubMsPayment(['id' => 9, 'class' => 'AsyncPay']); + + $service->sendToPaymentGateway($payment, $this->sender([ + 'success' => true, + 'data' => [ + 'external_id' => 'gw-31', + 'payment_link' => 'https://pay.example/31', + ], + ]), $order); + + $row = $store->findByExternalId('AsyncPay', 'gw-31', 9); + self::assertNotNull($row); + self::assertSame('https://pay.example/31', $row['payload']['payment_link']); + self::assertSame(31, $row['order_id']); + } + + public function testSendWithoutExternalIdDoesNotInitiate(): void + { + $store = new InMemoryPaymentAttemptStore(); + $lifecycle = new PaymentLifecycleService($store, new modX(), static fn (): bool => true); + $service = new PaymentService($this->modxWithLifecycle($lifecycle)); + $order = new StubMsOrder(['id' => 32, 'cost' => 10, 'payment_id' => 1]); + $payment = new StubMsPayment(['id' => 1, 'class' => 'MiniShop3\\Controllers\\Payment\\DefaultPayment']); + + $service->sendToPaymentGateway($payment, $this->sender([ + 'success' => true, + 'data' => [ + 'payment_link' => 'https://shop.example/thanks', + 'order_id' => 32, + ], + ]), $order); + + self::assertNull($store->findLatestForOrder(32, 1)); + } + + /** + * @param array $response + */ + private function sender(array $response): PaymentProviderInterface + { + return new class ($response) implements PaymentProviderInterface { + /** + * @param array $response + */ + public function __construct(private array $response) + { + } + + public function send(msOrder $order): array + { + return $this->response; + } + + public function receive(msOrder $order): array + { + return []; + } + + public function getPaymentLink(msOrder $order): ?string + { + return null; + } + + public function getCost(msOrder $order, msPayment $payment, float $cost): float + { + return $cost; + } + + public function getOrderHash(msOrder $order): string + { + return ''; + } + }; + } + + private function modxWithLifecycle(PaymentLifecycleService $lifecycle): modX + { + return new class ($lifecycle) extends modX { + public function __construct(private PaymentLifecycleService $lifecycle) + { + parent::__construct(); + $this->services = new class ($lifecycle) { + public function __construct(private PaymentLifecycleService $lifecycle) + { + } + + public function has(string $key): bool + { + return $key === 'ms3_payment_lifecycle'; + } + + public function get(string $key): mixed + { + return $key === 'ms3_payment_lifecycle' ? $this->lifecycle : null; + } + }; + } + }; + } +} diff --git a/core/components/minishop3/tests/bootstrap.php b/core/components/minishop3/tests/bootstrap.php index 41b1bbd9a..dd1e1ebb3 100644 --- a/core/components/minishop3/tests/bootstrap.php +++ b/core/components/minishop3/tests/bootstrap.php @@ -14,3 +14,4 @@ require __DIR__ . '/support/SqliteDraftCartProduct.php'; require __DIR__ . '/support/SqliteHarnessCart.php'; require __DIR__ . '/support/SqliteDraftCartHarnessTrait.php'; +require __DIR__ . '/support/InMemoryPaymentAttemptStore.php'; diff --git a/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php b/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php new file mode 100644 index 000000000..23e982f46 --- /dev/null +++ b/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php @@ -0,0 +1,131 @@ + */ + private array $attempts = []; + + /** @var array */ + private array $events = []; + + private int $nextId = 1; + + public function create( + int $orderId, + int $paymentMethodId, + string $provider, + ?string $externalId, + string $status, + float $amount, + string $currency, + array $payload, + ): array { + if ($externalId !== null && $this->findByExternalId($provider, $externalId, $paymentMethodId) !== null) { + throw new \RuntimeException('duplicate external_id'); + } + $now = time(); + $row = [ + 'id' => $this->nextId++, + 'order_id' => $orderId, + 'payment_method_id' => $paymentMethodId, + 'provider' => $provider, + 'external_id' => $externalId, + 'status' => $status, + 'amount' => round($amount, 3), + 'currency' => $currency, + 'payload' => $payload, + 'refunded_amount' => 0.0, + 'refund_external_id' => null, + 'refundedon' => null, + 'createdon' => $now, + 'updatedon' => $now, + ]; + $this->attempts[$row['id']] = $row; + + return $row; + } + + public function update(int $id, array $fields): array + { + $row = $this->attempts[$id] ?? null; + if ($row === null) { + throw new \RuntimeException('attempt not found'); + } + foreach ($fields as $key => $value) { + if ($key === 'id' || $key === 'createdon') { + continue; + } + $row[$key] = $value; + } + $row['updatedon'] = time(); + $this->attempts[$id] = $row; + + return $row; + } + + public function findById(int $id): ?array + { + return $this->attempts[$id] ?? null; + } + + public function findByExternalId(string $provider, string $externalId, ?int $paymentMethodId = null): ?array + { + foreach ($this->attempts as $row) { + if ($row['provider'] !== $provider || $row['external_id'] !== $externalId) { + continue; + } + if ($paymentMethodId !== null && $row['payment_method_id'] !== $paymentMethodId) { + continue; + } + + return $row; + } + + return null; + } + + public function findLatestForOrder(int $orderId, ?int $paymentMethodId = null): ?array + { + $latest = null; + foreach ($this->attempts as $row) { + if ($row['order_id'] !== $orderId) { + continue; + } + if ($paymentMethodId !== null && $row['payment_method_id'] !== $paymentMethodId) { + continue; + } + if ($latest === null || $row['id'] > $latest['id']) { + $latest = $row; + } + } + + return $latest; + } + + public function recordEvent(int $attemptId, string $eventType, string $providerEventId): bool + { + $key = $attemptId . ':' . $eventType . ':' . $providerEventId; + if (isset($this->events[$key])) { + return false; + } + $this->events[$key] = true; + + return true; + } + + public function hasEvent(int $attemptId, string $eventType, string $providerEventId): bool + { + return isset($this->events[$attemptId . ':' . $eventType . ':' . $providerEventId]); + } +} From e7dcb7b0b14753a53145b38a708a3802ae546e9e Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 19 Aug 2026 10:21:17 +0600 Subject: [PATCH 2/3] fix(core): route payment links and unify attempt commit Checkout now fails when initiate() cannot record an attempt after send(), and getPaymentLink goes through PaymentService instead of a second gateway call. --- .../minishop3/lexicon/en/default.inc.php | 1 + .../minishop3/lexicon/ru/default.inc.php | 1 + .../src/Controllers/Payment/Payment.php | 61 ++++++++---- .../src/ServiceRegistryFactories.php | 5 +- .../src/Services/Order/OrderStatusChanger.php | 16 ++++ .../src/Services/Order/OrderStatusService.php | 2 +- .../Payment/PaymentLifecycleService.php | 96 +++++++++++++------ .../src/Services/Payment/PaymentService.php | 88 ++++++++++++----- .../Services/Payment/PaymentWebhookHmac.php | 22 +++++ .../Payment/PdoPaymentAttemptStore.php | 13 +-- .../tests/PaymentLifecycleWiringTest.php | 11 ++- .../Api/Web/PaymentWebhookControllerTest.php | 3 +- .../Payment/PaymentLifecycleServiceTest.php | 11 ++- .../Payment/PaymentServiceSendAttemptTest.php | 63 +++++++++++- .../Payment/PaymentWebhookHmacTest.php | 33 +++++++ core/components/minishop3/tests/bootstrap.php | 1 + .../support/CallbackOrderStatusChanger.php | 22 +++++ 17 files changed, 357 insertions(+), 92 deletions(-) create mode 100644 core/components/minishop3/src/Services/Order/OrderStatusChanger.php create mode 100644 core/components/minishop3/src/Services/Payment/PaymentWebhookHmac.php create mode 100644 core/components/minishop3/tests/Unit/Services/Payment/PaymentWebhookHmacTest.php create mode 100644 core/components/minishop3/tests/support/CallbackOrderStatusChanger.php diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index ea3288559..d930c6005 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -194,6 +194,7 @@ $_lang['ms3_err_payment_webhook_unauthorized'] = 'Payment callback signature is invalid.'; $_lang['ms3_err_payment_webhook_conflict'] = 'Payment callback conflicts with the current attempt state.'; $_lang['ms3_err_payment_attempt_nf'] = 'Payment attempt not found.'; +$_lang['ms3_err_payment_attempt_record'] = 'Could not record the payment attempt after sending the order to the gateway.'; $_lang['ms3_err_payment_event_conflict'] = 'This payment event cannot be applied to the current attempt.'; $_lang['ms3_err_status_final'] = 'Final status is set. It cannot be changed.'; $_lang['ms3_err_status_fixed'] = 'Fixed status is set. You cannot change it to earlier one.'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 4e1bca3a5..a56d0fcfc 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -194,6 +194,7 @@ $_lang['ms3_err_payment_webhook_unauthorized'] = 'Подпись платёжного callback недействительна.'; $_lang['ms3_err_payment_webhook_conflict'] = 'Callback конфликтует с текущим состоянием попытки оплаты.'; $_lang['ms3_err_payment_attempt_nf'] = 'Попытка оплаты не найдена.'; +$_lang['ms3_err_payment_attempt_record'] = 'Не удалось записать попытку оплаты после отправки заказа в платёжный шлюз.'; $_lang['ms3_err_payment_event_conflict'] = 'Это платёжное событие нельзя применить к текущей попытке.'; $_lang['ms3_err_status_final'] = 'Установлен финальный статус. Его нельзя менять.'; $_lang['ms3_err_status_fixed'] = 'Установлен фиксирующий статус. Вы не можете сменить его на более ранний.'; diff --git a/core/components/minishop3/src/Controllers/Payment/Payment.php b/core/components/minishop3/src/Controllers/Payment/Payment.php index a3387b0c7..dc35f36fe 100644 --- a/core/components/minishop3/src/Controllers/Payment/Payment.php +++ b/core/components/minishop3/src/Controllers/Payment/Payment.php @@ -6,7 +6,8 @@ use MiniShop3\Model\msOrder; use MiniShop3\Model\msPayment; use MiniShop3\Services\Order\OrderCostEngine; -use MiniShop3\Services\Payment\PaymentLifecycleService; +use MiniShop3\Services\Payment\PaymentService; +use MiniShop3\Services\Payment\PaymentWebhookHmac; use MODX\Revolution\modX; /** @@ -150,24 +151,25 @@ public function getCost(msOrder $order, msPayment $payment, float $cost): float /** * Get payment link for order * - * Calls send() method and extracts payment_link from response. - * Used to display "Pay" button on order page. - * - * Reuses a stored attempt link when present so send() is not called again - * (async providers must not create a second payment). + * Goes through PaymentService when the handler is bound to an msPayment + * (stored open attempt, otherwise send() + initiate). Direct send() remains + * only for handlers constructed without that binding. * * @param msOrder $order Order for payment * @return string|null Payment link or null if failed */ public function getPaymentLink(msOrder $order): ?string { - $paymentMethodId = (int) $order->get('payment_id') ?: null; - $stored = $this->storedPaymentLink((int) $order->get('id'), $paymentMethodId); - if ($stored !== null) { - return $stored; + $payment = $this->configuredPayment(); + if ($payment instanceof msPayment && $this->modx->services->has('ms3_payment_service')) { + $service = $this->modx->services->get('ms3_payment_service'); + if ($service instanceof PaymentService) { + return $service->resolvePaymentLink($payment, $this, $order); + } } try { $response = $this->send($order); + return $response['data']['payment_link'] ?? null; } catch (\Exception $e) { $this->modx->log( @@ -241,17 +243,40 @@ protected function success(string $message = '', array $data = [], array $placeh return $this->ms3->utils->success($message, $data, $placeholders); } - private function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ?string + /** + * HMAC-SHA256 over the raw webhook body. Call from verifyWebhook(). + */ + protected function verifyWebhookHmac(string $rawBody, string $signature, ?string $secret = null): bool { - if ($orderId <= 0 || !$this->modx->services->has('ms3_payment_lifecycle')) { - return null; - } - $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); - if (!$lifecycle instanceof PaymentLifecycleService) { - return null; + return PaymentWebhookHmac::verify($rawBody, $signature, $secret ?? $this->webhookSecret()); + } + + /** + * Secret for webhook HMAC: msPayment.properties then ms3_payment_secret. + */ + protected function webhookSecret(): string + { + $payment = $this->configuredPayment(); + if ($payment instanceof msPayment) { + $properties = $payment->get('properties'); + if (is_array($properties)) { + foreach (['secret', 'secret_key', 'webhook_secret'] as $key) { + $value = $properties[$key] ?? null; + if (is_string($value) && $value !== '') { + return $value; + } + } + } } - return $lifecycle->storedPaymentLink($orderId, $paymentMethodId); + return (string) $this->modx->getOption('ms3_payment_secret', null, ''); + } + + private function configuredPayment(): ?msPayment + { + $payment = $this->config['payment'] ?? null; + + return $payment instanceof msPayment ? $payment : null; } /** diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 3a49c6d76..ecb6d6c0c 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -55,6 +55,9 @@ public static function map(): array 'ms3_payment_service' => $modxOnly(), 'ms3_payment_link_resolver' => $modxOnly(), 'ms3_payment_lifecycle' => static function (modX $modx, object $services, string $class): object { + if (!$modx->pdo instanceof \PDO) { + throw new \RuntimeException('ms3_payment_lifecycle requires MODX PDO'); + } $prefix = (string) $modx->getOption('table_prefix', null, ''); $store = new PdoPaymentAttemptStore( $modx->pdo, @@ -64,7 +67,7 @@ public static function map(): array /** @var OrderStatusService $orderStatus */ $orderStatus = $services->get('ms3_order_status'); - return new $class($store, $modx, $orderStatus->change(...)); + return new $class($store, $modx, $orderStatus); }, 'ms3_order_service' => $modxOnly(), 'ms3_customer_order' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Order/OrderStatusChanger.php b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php new file mode 100644 index 000000000..a6720c356 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php @@ -0,0 +1,16 @@ + $extraFields * @return PaymentAttemptRow */ public function refund( @@ -156,6 +158,7 @@ public function refund( float $amount, ?string $refundExternalId = null, ?string $providerEventId = null, + array $extraFields = [], ): array { $attempt = $this->requireAttempt($attemptId); $amount = round($amount, 3); @@ -181,20 +184,14 @@ public function refund( ? PaymentAttemptStatus::PARTIALLY_REFUNDED : PaymentAttemptStatus::REFUNDED; $this->assertTransition($attempt['status'], $target); - if (!$this->store->recordEvent($attemptId, $target, $eventKey)) { - $this->syncOrderStatus($attempt['order_id'], $attempt['status']); - - return $attempt; - } - $updated = $this->store->update($attemptId, [ + $fields = array_merge($extraFields, [ 'status' => $target, 'refunded_amount' => $refundedAmount, 'refund_external_id' => $refundExternalId, 'refundedon' => time(), ]); - $this->syncOrderStatus($updated['order_id'], $target); - return $updated; + return $this->commit($attempt, $target, $eventKey, $fields); } /** @@ -226,10 +223,12 @@ public function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ? public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, string $provider): array { $attempt = $this->resolveAttempt($event, $paymentMethodId, $provider); + $payloadFields = []; if ($event->payload !== []) { - $attempt = $this->store->update($attempt['id'], [ - 'payload' => array_merge($attempt['payload'], $this->sanitizePayload($event->payload)), - ]); + $payloadFields['payload'] = array_merge( + $attempt['payload'], + $this->sanitizePayload($event->payload) + ); } $eventId = $event->providerEventId; @@ -237,19 +236,27 @@ public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, s PaymentAttemptStatus::PENDING, PaymentAttemptStatus::AUTHORIZED, PaymentAttemptStatus::FAILED, - PaymentAttemptStatus::CANCELLED => $this->apply($attempt['id'], $event->eventType, $eventId), + PaymentAttemptStatus::CANCELLED => $this->apply( + $attempt['id'], + $event->eventType, + $eventId, + null, + $payloadFields + ), PaymentAttemptStatus::PAID => $this->apply( $attempt['id'], $event->eventType, $eventId, - $this->requirePaidAmount($event) + $this->requirePaidAmount($event), + $payloadFields ), - PaymentAttemptStatus::REFUNDED => $this->refundWebhook($attempt, $event, $eventId), + PaymentAttemptStatus::REFUNDED => $this->refundWebhook($attempt, $event, $eventId, $payloadFields), PaymentAttemptStatus::PARTIALLY_REFUNDED => $this->refund( $attempt['id'], $event->refundAmount ?? 0.0, $event->refundExternalId, - $eventId + $eventId, + $payloadFields ), default => throw new PaymentLifecycleException( 'ms3_err_payment_webhook_invalid', @@ -259,11 +266,15 @@ public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, s } /** - * @param PaymentAttemptRow $attempt + * @param array $fields * @return PaymentAttemptRow */ - private function refundWebhook(array $attempt, PaymentWebhookEvent $event, ?string $eventId): array - { + private function refundWebhook( + array $attempt, + PaymentWebhookEvent $event, + ?string $eventId, + array $fields = [], + ): array { $qty = $event->refundAmount ?? $this->remainingRefundable($attempt); if ($qty <= 0) { if ($attempt['status'] === PaymentAttemptStatus::REFUNDED) { @@ -274,27 +285,58 @@ private function refundWebhook(array $attempt, PaymentWebhookEvent $event, ?stri throw new PaymentLifecycleException('ms3_err_payment_webhook_invalid', ['qty' => $qty]); } - return $this->refund($attempt['id'], $qty, $event->refundExternalId, $eventId); + return $this->refund($attempt['id'], $qty, $event->refundExternalId, $eventId, $fields); } /** + * @param array $fields * @return PaymentAttemptRow */ - private function apply(int $attemptId, string $target, ?string $providerEventId, ?float $paidAmount = null): array - { + private function apply( + int $attemptId, + string $target, + ?string $providerEventId, + ?float $paidAmount = null, + array $fields = [], + ): array { $attempt = $this->requireAttempt($attemptId); $eventKey = $this->applyEventKey($target, $providerEventId); if ($target === PaymentAttemptStatus::PAID) { $this->assertPaidPreconditions($attempt, $paidAmount); } + + return $this->commit($attempt, $target, $eventKey, $fields); + } + + /** + * Update attempt, sync order, then record the idempotency event. + * + * @param PaymentAttemptRow $attempt + * @param array $fields + * @return PaymentAttemptRow + */ + private function commit(array $attempt, string $target, string $eventKey, array $fields = []): array + { + $attemptId = $attempt['id']; + if ($this->store->hasEvent($attemptId, $target, $eventKey)) { + $this->syncOrderStatus($attempt['order_id'], $target); + + return $this->store->findById($attemptId) ?? $attempt; + } if ($attempt['status'] !== $target) { $this->assertTransition($attempt['status'], $target); - $attempt = $this->store->update($attemptId, ['status' => $target]); + $fields['status'] = $target; + } elseif ($fields === []) { + $this->syncOrderStatus($attempt['order_id'], $target); + $this->store->recordEvent($attemptId, $target, $eventKey); + + return $this->store->findById($attemptId) ?? $attempt; } - $this->syncOrderStatus($attempt['order_id'], $target); + $updated = $this->store->update($attemptId, $fields); + $this->syncOrderStatus($updated['order_id'], $target); $this->store->recordEvent($attemptId, $target, $eventKey); - return $this->store->findById($attemptId) ?? $attempt; + return $this->store->findById($attemptId) ?? $updated; } /** @@ -502,7 +544,7 @@ private function syncOrderStatus(int $orderId, string $attemptStatus): void if ($statusId <= 0) { return; } - $result = ($this->changeStatus)($orderId, $statusId); + $result = $this->orderStatus->change($orderId, $statusId); if ($result === true) { return; } diff --git a/core/components/minishop3/src/Services/Payment/PaymentService.php b/core/components/minishop3/src/Services/Payment/PaymentService.php index 603c3c88d..49405798c 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentService.php @@ -109,12 +109,65 @@ public function sendToPaymentGateway( $response = $controller->send($order); if (!empty($response['success'])) { - $this->recordAttemptFromSend($payment, $order, $response); + try { + $this->recordAttemptFromSend($payment, $order, $response); + } catch (\Throwable $exception) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + sprintf( + 'PaymentService: failed to record payment attempt for order #%s: %s', + (string) $order->get('id'), + $exception->getMessage() + ) + ); + $this->modx->lexicon->load('minishop3:default'); + + return [ + 'success' => false, + 'message' => $this->modx->lexicon('ms3_err_payment_attempt_record'), + 'data' => is_array($response['data'] ?? null) ? $response['data'] : [], + ]; + } } return $response; } + /** + * Stored open-attempt link, otherwise send() + initiate via sendToPaymentGateway. + */ + public function resolvePaymentLink( + msPayment $payment, + ?PaymentProviderInterface $controller, + msOrder $order, + ): ?string { + $methodId = (int) $payment->get('id') ?: null; + $stored = $this->storedOpenPaymentLink((int) $order->get('id'), $methodId); + if ($stored !== null) { + return $stored; + } + $response = $this->sendToPaymentGateway($payment, $controller, $order); + if (!is_array($response) || empty($response['success'])) { + return null; + } + $link = $response['data']['payment_link'] ?? null; + + return is_string($link) && $link !== '' ? $link : null; + } + + public function storedOpenPaymentLink(int $orderId, ?int $paymentMethodId = null): ?string + { + if ($orderId <= 0 || !$this->modx->services->has('ms3_payment_lifecycle')) { + return null; + } + $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); + if (!$lifecycle instanceof PaymentLifecycleService) { + return null; + } + + return $lifecycle->storedPaymentLink($orderId, $paymentMethodId); + } + /** * Receive payment from payment system * @@ -218,11 +271,11 @@ private function recordAttemptFromSend(msPayment $payment, msOrder $order, array return; } if (!$this->modx->services->has('ms3_payment_lifecycle')) { - return; + throw new \RuntimeException('ms3_payment_lifecycle is not registered'); } $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); if (!$lifecycle instanceof PaymentLifecycleService) { - return; + throw new \RuntimeException('ms3_payment_lifecycle is not a PaymentLifecycleService'); } $class = $payment->get('class'); $provider = is_string($class) && $class !== '' ? $class : $this->defaultControllerClass; @@ -231,25 +284,14 @@ private function recordAttemptFromSend(msPayment $payment, msOrder $order, array if (is_string($link) && $link !== '') { $payload['payment_link'] = $link; } - try { - $lifecycle->initiate( - (int) $order->get('id'), - (int) $payment->get('id'), - $provider, - (float) $order->get('cost'), - is_string($data['currency'] ?? null) ? $data['currency'] : 'RUB', - $externalId, - $payload - ); - } catch (\Throwable $exception) { - $this->modx->log( - modX::LOG_LEVEL_ERROR, - sprintf( - 'PaymentService: failed to record payment attempt for order #%s: %s', - (string) $order->get('id'), - $exception->getMessage() - ) - ); - } + $lifecycle->initiate( + (int) $order->get('id'), + (int) $payment->get('id'), + $provider, + (float) $order->get('cost'), + is_string($data['currency'] ?? null) ? $data['currency'] : 'RUB', + $externalId, + $payload + ); } } diff --git a/core/components/minishop3/src/Services/Payment/PaymentWebhookHmac.php b/core/components/minishop3/src/Services/Payment/PaymentWebhookHmac.php new file mode 100644 index 000000000..efcf00811 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentWebhookHmac.php @@ -0,0 +1,22 @@ +db, 'lastInsertId')) { - return (string) $this->db->lastInsertId(); - } - - throw new RuntimeException('Payment attempt store requires lastInsertId()'); + return (string) $this->db->lastInsertId(); } private function prepare(string $sql): PDOStatement { - if (!method_exists($this->db, 'prepare')) { - throw new RuntimeException('Payment attempt store requires prepare() on the DB connection'); - } $stmt = $this->db->prepare($sql); if (!$stmt instanceof PDOStatement) { throw new RuntimeException('Payment attempt store failed to prepare SQL'); diff --git a/core/components/minishop3/tests/PaymentLifecycleWiringTest.php b/core/components/minishop3/tests/PaymentLifecycleWiringTest.php index e492dd638..b34b009a0 100644 --- a/core/components/minishop3/tests/PaymentLifecycleWiringTest.php +++ b/core/components/minishop3/tests/PaymentLifecycleWiringTest.php @@ -47,8 +47,11 @@ if (str_contains($payment, "set('status_id'")) { $fail('Payment docblock must not write status_id directly'); } -if (!str_contains($payment, 'PaymentLifecycleService')) { - $fail('Payment examples must use PaymentLifecycleService'); +if (!str_contains($payment, 'ms3_payment_lifecycle')) { + $fail('Payment examples must use ms3_payment_lifecycle'); +} +if (!str_contains($payment, 'resolvePaymentLink')) { + $fail('Payment::getPaymentLink must go through PaymentService::resolvePaymentLink'); } if (str_contains($ordersPage, '$payment->toArray()')) { $fail('OrdersPageService must not expose payment toArray()'); @@ -61,8 +64,8 @@ if ($paymentService === false || $notification === false) { $fail('cannot read payment service / notification'); } -if (!str_contains($paymentService, 'recordAttemptFromSend')) { - $fail('PaymentService must record an attempt after a successful send()'); +if (!str_contains($paymentService, 'resolvePaymentLink')) { + $fail('PaymentService must resolve payment links through send()+initiate'); } if (str_contains($notification, '$payment->toArray()')) { $fail('Notification must not expose payment toArray()'); diff --git a/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php b/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php index 62a64a02b..ffd58cef3 100644 --- a/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php +++ b/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php @@ -17,6 +17,7 @@ use MiniShop3\Services\Payment\PaymentLifecycleService; use MiniShop3\Tests\Stubs\StubMsOrder; use MiniShop3\Tests\Stubs\StubMsPayment; +use MiniShop3\Tests\Support\CallbackOrderStatusChanger; use MiniShop3\Tests\Support\InMemoryPaymentAttemptStore; use MODX\Revolution\modX; use PHPUnit\Framework\TestCase; @@ -207,7 +208,7 @@ public function lexicon(string $key, array $params = []): string } }; - return new PaymentLifecycleService($store, $modx, static fn (): bool => true); + return new PaymentLifecycleService($store, $modx, new CallbackOrderStatusChanger(static fn (int $orderId, int $statusId): bool => true)); } private function modxWithHandler(object $handler, ?object $lifecycle = null): modX diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php index 11f8b7367..cb451ae78 100644 --- a/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php @@ -9,6 +9,7 @@ use MiniShop3\Services\Payment\PaymentAttemptStatus; use MiniShop3\Services\Payment\PaymentLifecycleException; use MiniShop3\Services\Payment\PaymentLifecycleService; +use MiniShop3\Tests\Support\CallbackOrderStatusChanger; use MiniShop3\Tests\Support\InMemoryPaymentAttemptStore; use MiniShop3\Tests\Stubs\StubMsOrder; use MODX\Revolution\modX; @@ -370,11 +371,13 @@ public function lexicon(string $key, array $params = []): string return new PaymentLifecycleService( $store, $modx, - $changeStatus ?? function (int $orderId, int $statusId): bool|string { - $this->statusChanges[] = [$orderId, $statusId]; + new CallbackOrderStatusChanger( + $changeStatus ?? function (int $orderId, int $statusId): bool|string { + $this->statusChanges[] = [$orderId, $statusId]; - return true; - } + return true; + } + ) ); } } diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php index af68542a6..fbbb3e7f9 100644 --- a/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php @@ -11,6 +11,7 @@ use MiniShop3\Services\Payment\PaymentService; use MiniShop3\Tests\Stubs\StubMsOrder; use MiniShop3\Tests\Stubs\StubMsPayment; +use MiniShop3\Tests\Support\CallbackOrderStatusChanger; use MiniShop3\Tests\Support\InMemoryPaymentAttemptStore; use MODX\Revolution\modX; use PHPUnit\Framework\TestCase; @@ -29,7 +30,7 @@ protected function setUp(): void public function testSuccessfulSendWithExternalIdInitiatesAttempt(): void { $store = new InMemoryPaymentAttemptStore(); - $lifecycle = new PaymentLifecycleService($store, new modX(), static fn (): bool => true); + $lifecycle = new PaymentLifecycleService($store, new modX(), new CallbackOrderStatusChanger(static fn (int $orderId, int $statusId): bool => true)); $service = new PaymentService($this->modxWithLifecycle($lifecycle)); $order = new StubMsOrder(['id' => 31, 'cost' => 12.5, 'payment_id' => 9]); $payment = new StubMsPayment(['id' => 9, 'class' => 'AsyncPay']); @@ -51,7 +52,7 @@ public function testSuccessfulSendWithExternalIdInitiatesAttempt(): void public function testSendWithoutExternalIdDoesNotInitiate(): void { $store = new InMemoryPaymentAttemptStore(); - $lifecycle = new PaymentLifecycleService($store, new modX(), static fn (): bool => true); + $lifecycle = new PaymentLifecycleService($store, new modX(), new CallbackOrderStatusChanger(static fn (int $orderId, int $statusId): bool => true)); $service = new PaymentService($this->modxWithLifecycle($lifecycle)); $order = new StubMsOrder(['id' => 32, 'cost' => 10, 'payment_id' => 1]); $payment = new StubMsPayment(['id' => 1, 'class' => 'MiniShop3\\Controllers\\Payment\\DefaultPayment']); @@ -67,12 +68,68 @@ public function testSendWithoutExternalIdDoesNotInitiate(): void self::assertNull($store->findLatestForOrder(32, 1)); } + public function testSendFailsWhenInitiateThrows(): void + { + $lifecycle = $this->createMock(PaymentLifecycleService::class); + $lifecycle->expects(self::once()) + ->method('initiate') + ->willThrowException(new \RuntimeException('db down')); + $service = new PaymentService($this->modxWithLifecycle($lifecycle)); + $order = new StubMsOrder(['id' => 33, 'cost' => 12.5, 'payment_id' => 9]); + $payment = new StubMsPayment(['id' => 9, 'class' => 'AsyncPay']); + + $response = $service->sendToPaymentGateway($payment, $this->sender([ + 'success' => true, + 'data' => [ + 'external_id' => 'gw-fail', + 'payment_link' => 'https://pay.example/fail', + ], + ]), $order); + + self::assertIsArray($response); + self::assertFalse($response['success']); + self::assertSame('ms3_err_payment_attempt_record', $response['message']); + self::assertSame('gw-fail', $response['data']['external_id']); + self::assertSame('https://pay.example/fail', $response['data']['payment_link']); + } + + public function testResolvePaymentLinkUsesStoredOpenAttemptWithoutSend(): void + { + $store = new InMemoryPaymentAttemptStore(); + $lifecycle = new PaymentLifecycleService( + $store, + new modX(), + new CallbackOrderStatusChanger(static fn (int $orderId, int $statusId): bool => true) + ); + $lifecycle->initiate(40, 7, 'AsyncPay', 10.0, 'RUB', 'gw-40', [ + 'payment_link' => 'https://pay.example/stored', + ]); + $service = new PaymentService($this->modxWithLifecycle($lifecycle)); + $order = new StubMsOrder(['id' => 40, 'cost' => 10, 'payment_id' => 7]); + $payment = new StubMsPayment(['id' => 7, 'class' => 'AsyncPay']); + $sender = $this->sender([ + 'success' => true, + 'data' => [ + 'external_id' => 'should-not-create', + 'payment_link' => 'https://pay.example/new', + ], + ]); + + $link = $service->resolvePaymentLink($payment, $sender, $order); + + self::assertSame('https://pay.example/stored', $link); + self::assertSame(0, $sender->sends); + self::assertNull($store->findByExternalId('AsyncPay', 'should-not-create', 7)); + } + /** * @param array $response */ private function sender(array $response): PaymentProviderInterface { return new class ($response) implements PaymentProviderInterface { + public int $sends = 0; + /** * @param array $response */ @@ -82,6 +139,8 @@ public function __construct(private array $response) public function send(msOrder $order): array { + $this->sends++; + return $this->response; } diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentWebhookHmacTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentWebhookHmacTest.php new file mode 100644 index 000000000..1a2a15fdd --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentWebhookHmacTest.php @@ -0,0 +1,33 @@ +callback)($orderId, $statusId); + } +} From b2ccc7554e16a0164b6e9e76d55be8282215687e Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 19 Aug 2026 11:27:17 +0600 Subject: [PATCH 3/3] fix(core): make paid webhook replay succeed on fixed order status Replay no longer re-runs change() when the order is already paid, and attempt fields plus the idempotency event persist in one write. Financial callbacks now require matching currency, current order cost, and externalId. --- .../src/Services/Order/OrderStatusChanger.php | 7 + .../src/Services/Order/OrderStatusService.php | 20 +++ .../Payment/PaymentAttemptStoreInterface.php | 9 + .../Payment/PaymentLifecycleService.php | 103 +++++++----- .../Payment/PdoPaymentAttemptStore.php | 47 ++++++ .../Payment/PaymentLifecycleServiceTest.php | 154 +++++++++++++++++- core/components/minishop3/tests/bootstrap.php | 1 + .../minishop3/tests/stubs/StubMsOrder.php | 7 + .../support/CallbackOrderStatusChanger.php | 5 + .../support/FixedReplayOrderStatusChanger.php | 47 ++++++ .../support/InMemoryPaymentAttemptStore.php | 19 +++ 11 files changed, 373 insertions(+), 46 deletions(-) create mode 100644 core/components/minishop3/tests/support/FixedReplayOrderStatusChanger.php diff --git a/core/components/minishop3/src/Services/Order/OrderStatusChanger.php b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php index a6720c356..45144d482 100644 --- a/core/components/minishop3/src/Services/Order/OrderStatusChanger.php +++ b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php @@ -13,4 +13,11 @@ interface OrderStatusChanger * @return bool|string True on success, lexicon/error message on failure */ public function change(int $orderId, int $statusId, bool $skipNotifications = false): bool|string; + + /** + * Idempotent status apply: already-at-target is success (does not call change()). + * + * @return bool|string True on success, lexicon/error message on failure + */ + public function ensure(int $orderId, int $statusId, bool $skipNotifications = false): bool|string; } diff --git a/core/components/minishop3/src/Services/Order/OrderStatusService.php b/core/components/minishop3/src/Services/Order/OrderStatusService.php index da2d17f8c..520ba5ccb 100644 --- a/core/components/minishop3/src/Services/Order/OrderStatusService.php +++ b/core/components/minishop3/src/Services/Order/OrderStatusService.php @@ -69,6 +69,26 @@ public function getAllowedCancelStatusIds(): array return array_filter([$newId, $paidId]); } + /** + * Apply status if the order is not already there. Same-status is success, + * including when the current status is fixed/final (change() would fail first). + * + * @return bool|string True on success, lexicon/error message on failure + */ + public function ensure(int $orderId, int $statusId, bool $skipNotifications = false): bool|string + { + /** @var msOrder|null $msOrder */ + $msOrder = $this->modx->getObject(msOrder::class, ['id' => $orderId]); + if (!$msOrder) { + return $this->modx->lexicon('ms3_err_order_nf'); + } + if ((int) $msOrder->get('status_id') === $statusId) { + return true; + } + + return $this->change($orderId, $statusId, $skipNotifications); + } + /** * Switch order status * diff --git a/core/components/minishop3/src/Services/Payment/PaymentAttemptStoreInterface.php b/core/components/minishop3/src/Services/Payment/PaymentAttemptStoreInterface.php index 7c1f1f3df..7281adc4c 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentAttemptStoreInterface.php +++ b/core/components/minishop3/src/Services/Payment/PaymentAttemptStoreInterface.php @@ -70,4 +70,13 @@ public function findLatestForOrder(int $orderId, ?int $paymentMethodId = null): public function recordEvent(int $attemptId, string $eventType, string $providerEventId): bool; public function hasEvent(int $attemptId, string $eventType, string $providerEventId): bool; + + /** + * Persist attempt fields and the idempotency event together. + * If the event already exists, fields are not applied. + * + * @param array $fields + * @return PaymentAttemptRow + */ + public function writeWithEvent(int $id, string $eventType, string $providerEventId, array $fields): array; } diff --git a/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php b/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php index 71e53cd46..9470fd741 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php @@ -194,18 +194,6 @@ public function refund( return $this->commit($attempt, $target, $eventKey, $fields); } - /** - * @return PaymentAttemptRow - */ - public function partialRefund( - int $attemptId, - float $amount, - ?string $refundExternalId = null, - ?string $providerEventId = null, - ): array { - return $this->refund($attemptId, $amount, $refundExternalId, $providerEventId); - } - public function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ?string { $attempt = $this->store->findLatestForOrder($orderId, $paymentMethodId); @@ -222,7 +210,9 @@ public function storedPaymentLink(int $orderId, ?int $paymentMethodId = null): ? */ public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, string $provider): array { + $this->assertFinancialExternalId($event); $attempt = $this->resolveAttempt($event, $paymentMethodId, $provider); + $this->assertWebhookCurrency($attempt, $event); $payloadFields = []; if ($event->payload !== []) { $payloadFields['payload'] = array_merge( @@ -230,6 +220,13 @@ public function applyWebhook(PaymentWebhookEvent $event, int $paymentMethodId, s $this->sanitizePayload($event->payload) ); } + if ( + $event->externalId !== null + && $event->externalId !== '' + && ($attempt['external_id'] === null || $attempt['external_id'] === '') + ) { + $payloadFields['external_id'] = $event->externalId; + } $eventId = $event->providerEventId; return match ($event->eventType) { @@ -309,7 +306,7 @@ private function apply( } /** - * Update attempt, sync order, then record the idempotency event. + * Persist fields+event atomically, then heal the order status. * * @param PaymentAttemptRow $attempt * @param array $fields @@ -317,26 +314,14 @@ private function apply( */ private function commit(array $attempt, string $target, string $eventKey, array $fields = []): array { - $attemptId = $attempt['id']; - if ($this->store->hasEvent($attemptId, $target, $eventKey)) { - $this->syncOrderStatus($attempt['order_id'], $target); - - return $this->store->findById($attemptId) ?? $attempt; - } if ($attempt['status'] !== $target) { $this->assertTransition($attempt['status'], $target); $fields['status'] = $target; - } elseif ($fields === []) { - $this->syncOrderStatus($attempt['order_id'], $target); - $this->store->recordEvent($attemptId, $target, $eventKey); - - return $this->store->findById($attemptId) ?? $attempt; } - $updated = $this->store->update($attemptId, $fields); + $updated = $this->store->writeWithEvent($attempt['id'], $target, $eventKey, $fields); $this->syncOrderStatus($updated['order_id'], $target); - $this->store->recordEvent($attemptId, $target, $eventKey); - return $this->store->findById($attemptId) ?? $updated; + return $updated; } /** @@ -370,7 +355,7 @@ private function resolveAttempt( $existing !== null && ($existing['external_id'] === null || $existing['external_id'] === '') ) { - return $this->store->update($existing['id'], ['external_id' => $event->externalId]); + return $existing; } } @@ -470,12 +455,18 @@ private function assertPaidPreconditions(array $attempt, ?float $paidAmount): vo ); } $this->assertOrderPaymentMethod($order, $attempt['payment_method_id']); + $orderCost = (float) $order->get('cost'); + if (abs($attempt['amount'] - $orderCost) > 0.001) { + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['amount' => $attempt['amount'], 'cost' => $orderCost], + PaymentLifecycleException::KIND_CONFLICT + ); + } if ($paidAmount === null) { return; } - $deltaAttempt = abs($paidAmount - $attempt['amount']); - $deltaCost = abs($paidAmount - (float) $order->get('cost')); - if ($deltaAttempt > 0.001 && $deltaCost > 0.001) { + if (abs($paidAmount - $attempt['amount']) > 0.001) { throw new PaymentLifecycleException( 'ms3_err_payment_event_conflict', ['amount' => $paidAmount], @@ -544,14 +535,11 @@ private function syncOrderStatus(int $orderId, string $attemptStatus): void if ($statusId <= 0) { return; } - $result = $this->orderStatus->change($orderId, $statusId); + $result = $this->orderStatus->ensure($orderId, $statusId); if ($result === true) { return; } $message = is_string($result) ? $result : 'ms3_err_unknown'; - if ($this->isAlreadySameStatus($message)) { - return; - } throw new PaymentLifecycleException( 'ms3_err_payment_event_conflict', ['status' => $message], @@ -574,12 +562,6 @@ private function orderStatusFor(string $attemptStatus): int }; } - private function isAlreadySameStatus(string $message): bool - { - return str_contains($message, 'ms3_err_status_same') - || $message === $this->modx->lexicon('ms3_err_status_same'); - } - private function isOpen(string $status): bool { return in_array($status, [ @@ -604,6 +586,45 @@ private function canRebindOpenAttempt(array $open, ?string $externalId): bool return $current === $externalId; } + private function assertFinancialExternalId(PaymentWebhookEvent $event): void + { + if (!in_array($event->eventType, [ + PaymentAttemptStatus::PAID, + PaymentAttemptStatus::REFUNDED, + PaymentAttemptStatus::PARTIALLY_REFUNDED, + ], true)) { + return; + } + if ($event->externalId !== null && $event->externalId !== '') { + return; + } + + throw new PaymentLifecycleException( + 'ms3_err_payment_webhook_invalid', + ['external_id' => 'required'], + PaymentLifecycleException::KIND_INVALID + ); + } + + /** + * @param PaymentAttemptRow $attempt + */ + private function assertWebhookCurrency(array $attempt, PaymentWebhookEvent $event): void + { + if ($event->currency === null || $event->currency === '') { + return; + } + if (strcasecmp($event->currency, $attempt['currency']) === 0) { + return; + } + + throw new PaymentLifecycleException( + 'ms3_err_payment_event_conflict', + ['from' => $attempt['currency'], 'to' => $event->currency], + PaymentLifecycleException::KIND_CONFLICT + ); + } + private function requirePaidAmount(PaymentWebhookEvent $event): float { if ($event->amount === null) { diff --git a/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php b/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php index d34d641d5..483e49e1f 100644 --- a/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php +++ b/core/components/minishop3/src/Services/Payment/PdoPaymentAttemptStore.php @@ -174,6 +174,53 @@ public function hasEvent(int $attemptId, string $eventType, string $providerEven return $stmt->fetchColumn() !== false; } + public function writeWithEvent(int $id, string $eventType, string $providerEventId, array $fields): array + { + $started = false; + if (!$this->db->inTransaction()) { + $this->db->beginTransaction(); + $started = true; + } + try { + $locked = $this->fetchOne( + "SELECT * FROM {$this->attemptsTable} WHERE id = :id FOR UPDATE", + ['id' => $id] + ); + if ($locked === null) { + throw new RuntimeException('Payment attempt not found'); + } + if ($this->hasEvent($id, $eventType, $providerEventId)) { + if ($started) { + $this->db->commit(); + } + + return $locked; + } + $row = $fields === [] ? $locked : $this->update($id, $fields); + if (!$this->recordEvent($id, $eventType, $providerEventId)) { + if ($started) { + $this->db->rollBack(); + } + $existing = $this->findById($id); + if ($existing === null) { + throw new RuntimeException('Payment attempt not found after event conflict'); + } + + return $existing; + } + if ($started) { + $this->db->commit(); + } + + return $row; + } catch (\Throwable $exception) { + if ($started && $this->db->inTransaction()) { + $this->db->rollBack(); + } + throw $exception; + } + } + /** * @param array $params * @return PaymentAttemptRow|null diff --git a/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php index cb451ae78..44f31542a 100644 --- a/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php @@ -9,7 +9,9 @@ use MiniShop3\Services\Payment\PaymentAttemptStatus; use MiniShop3\Services\Payment\PaymentLifecycleException; use MiniShop3\Services\Payment\PaymentLifecycleService; +use MiniShop3\Services\Order\OrderStatusChanger; use MiniShop3\Tests\Support\CallbackOrderStatusChanger; +use MiniShop3\Tests\Support\FixedReplayOrderStatusChanger; use MiniShop3\Tests\Support\InMemoryPaymentAttemptStore; use MiniShop3\Tests\Stubs\StubMsOrder; use MODX\Revolution\modX; @@ -71,7 +73,7 @@ public function testRefundAndPartialRefundPersist(): void $service->markPaid($attempt['id'], 'paid'); $this->statusChanges = []; - $partial = $service->partialRefund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); + $partial = $service->refund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); self::assertSame(PaymentAttemptStatus::PARTIALLY_REFUNDED, $partial['status']); self::assertSame(30.0, $partial['refunded_amount']); self::assertSame([], $this->statusChanges); @@ -110,7 +112,7 @@ public function testOverRefundIsInvalid(): void $service = $this->service(); $attempt = $service->initiate(10, 2, 'TestPay', 100.0); $service->markPaid($attempt['id'], 'paid'); - $service->partialRefund($attempt['id'], 80.0, 'ref-1', 'evt-r1'); + $service->refund($attempt['id'], 80.0, 'ref-1', 'evt-r1'); $this->expectException(PaymentLifecycleException::class); $service->refund($attempt['id'], 30.0, 'ref-2', 'evt-r2'); } @@ -306,7 +308,7 @@ public function testFullRefundWebhookWithoutAmountUsesRemainder(): void $service = $this->service(); $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-ref'); $service->markPaid($attempt['id'], 'paid'); - $service->partialRefund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); + $service->refund($attempt['id'], 30.0, 'ref-1', 'evt-r1'); $this->statusChanges = []; $full = $service->applyWebhook( new PaymentWebhookEvent( @@ -323,10 +325,152 @@ public function testFullRefundWebhookWithoutAmountUsesRemainder(): void self::assertSame([[10, 5]], $this->statusChanges); } + public function testReplayPaidOnFixedOrderStatusSucceeds(): void + { + $order = new StubMsOrder(['id' => 10, 'status_id' => 2, 'cost' => 100]); + $changer = new FixedReplayOrderStatusChanger($order); + $service = $this->service($order, changer: $changer); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-fixed'); + $paid = $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-fixed', + amount: 100.0, + providerEventId: 'evt-paid', + ), + 2, + 'TestPay' + ); + self::assertSame(PaymentAttemptStatus::PAID, $paid['status']); + self::assertSame(3, (int) $order->get('status_id')); + self::assertSame([[10, 3]], $changer->changes); + + $again = $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-fixed', + amount: 100.0, + providerEventId: 'evt-paid', + ), + 2, + 'TestPay' + ); + self::assertSame(PaymentAttemptStatus::PAID, $again['status']); + self::assertSame([[10, 3]], $changer->changes); + } + + public function testWebhookCurrencyMismatchConflicts(): void + { + $service = $this->service(); + $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-cur'); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-cur', + amount: 100.0, + currency: 'USD', + providerEventId: 'evt-usd', + ), + 2, + 'TestPay' + ); + self::fail('currency mismatch must conflict'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + } + + public function testPaidAfterOrderCostChangeConflicts(): void + { + $order = new StubMsOrder(['id' => 10, 'status_id' => 2, 'cost' => 100]); + $service = $this->service($order); + $attempt = $service->initiate(10, 2, 'TestPay', 100.0, 'RUB', 'ext-cost'); + $order->set('cost', 200); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'ext-cost', + amount: 100.0, + providerEventId: 'evt-stale', + ), + 2, + 'TestPay' + ); + self::fail('stale attempt amount must conflict after cost change'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + self::assertSame(PaymentAttemptStatus::PENDING, $attempt['status']); + } + + public function testPaidWebhookWithoutExternalIdIsInvalid(): void + { + $service = $this->service(); + $service->initiate(10, 2, 'TestPay', 100.0, 'RUB'); + try { + $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + orderId: 10, + amount: 100.0, + providerEventId: 'evt-no-ext', + ), + 2, + 'TestPay' + ); + self::fail('paid webhook must include externalId'); + } catch (PaymentLifecycleException $exception) { + self::assertSame(PaymentLifecycleException::KIND_INVALID, $exception->getKind()); + } + } + + public function testWebhookBindsExternalIdOnCommit(): void + { + $order = new StubMsOrder(['id' => 14, 'status_id' => 2, 'cost' => 20, 'payment_id' => 7]); + $service = $this->service($order); + $open = $service->initiate(14, 7, 'TestPay', 20.0, 'RUB'); + self::assertNull($open['external_id']); + $paid = $service->applyWebhook( + new PaymentWebhookEvent( + eventType: PaymentAttemptStatus::PAID, + externalId: 'gw-14', + orderId: 14, + amount: 20.0, + providerEventId: 'cb-bind', + ), + 7, + 'TestPay' + ); + self::assertSame('gw-14', $paid['external_id']); + self::assertSame(PaymentAttemptStatus::PAID, $paid['status']); + } + + public function testWriteWithEventReplayDoesNotApplyNewFields(): void + { + $store = new InMemoryPaymentAttemptStore(); + $row = $store->create(10, 2, 'TestPay', 'ext-w', PaymentAttemptStatus::PENDING, 100.0, 'RUB', []); + $first = $store->writeWithEvent($row['id'], PaymentAttemptStatus::PAID, 'evt-1', [ + 'status' => PaymentAttemptStatus::PAID, + ]); + self::assertSame(PaymentAttemptStatus::PAID, $first['status']); + $second = $store->writeWithEvent($row['id'], PaymentAttemptStatus::PAID, 'evt-1', [ + 'status' => PaymentAttemptStatus::FAILED, + 'amount' => 1.0, + ]); + self::assertSame(PaymentAttemptStatus::PAID, $second['status']); + self::assertSame(100.0, $second['amount']); + } + /** * @param \Closure(int, int): (bool|string)|null $changeStatus */ - private function service(?msOrder $order = null, ?\Closure $changeStatus = null): PaymentLifecycleService + private function service( + ?msOrder $order = null, + ?\Closure $changeStatus = null, + ?OrderStatusChanger $changer = null, + ): PaymentLifecycleService { $order ??= new StubMsOrder(['id' => 10, 'status_id' => 2, 'cost' => 100]); $store = new InMemoryPaymentAttemptStore(); @@ -371,7 +515,7 @@ public function lexicon(string $key, array $params = []): string return new PaymentLifecycleService( $store, $modx, - new CallbackOrderStatusChanger( + $changer ?? new CallbackOrderStatusChanger( $changeStatus ?? function (int $orderId, int $statusId): bool|string { $this->statusChanges[] = [$orderId, $statusId]; diff --git a/core/components/minishop3/tests/bootstrap.php b/core/components/minishop3/tests/bootstrap.php index ea3dcc6c1..7aa0f01d1 100644 --- a/core/components/minishop3/tests/bootstrap.php +++ b/core/components/minishop3/tests/bootstrap.php @@ -16,3 +16,4 @@ require __DIR__ . '/support/SqliteDraftCartHarnessTrait.php'; require __DIR__ . '/support/InMemoryPaymentAttemptStore.php'; require __DIR__ . '/support/CallbackOrderStatusChanger.php'; +require __DIR__ . '/support/FixedReplayOrderStatusChanger.php'; diff --git a/core/components/minishop3/tests/stubs/StubMsOrder.php b/core/components/minishop3/tests/stubs/StubMsOrder.php index 7c0642c8e..0535563fd 100644 --- a/core/components/minishop3/tests/stubs/StubMsOrder.php +++ b/core/components/minishop3/tests/stubs/StubMsOrder.php @@ -23,4 +23,11 @@ public function get($key) { return $this->fields[$key] ?? null; } + + public function set($k, $v = null, $v2 = null) + { + $this->fields[$k] = $v; + + return true; + } } diff --git a/core/components/minishop3/tests/support/CallbackOrderStatusChanger.php b/core/components/minishop3/tests/support/CallbackOrderStatusChanger.php index 16077f3bf..ed8878a94 100644 --- a/core/components/minishop3/tests/support/CallbackOrderStatusChanger.php +++ b/core/components/minishop3/tests/support/CallbackOrderStatusChanger.php @@ -19,4 +19,9 @@ public function change(int $orderId, int $statusId, bool $skipNotifications = fa { return ($this->callback)($orderId, $statusId); } + + public function ensure(int $orderId, int $statusId, bool $skipNotifications = false): bool|string + { + return $this->change($orderId, $statusId, $skipNotifications); + } } diff --git a/core/components/minishop3/tests/support/FixedReplayOrderStatusChanger.php b/core/components/minishop3/tests/support/FixedReplayOrderStatusChanger.php new file mode 100644 index 000000000..1ce727c20 --- /dev/null +++ b/core/components/minishop3/tests/support/FixedReplayOrderStatusChanger.php @@ -0,0 +1,47 @@ + */ + public array $changes = []; + + public function __construct( + private readonly msOrder $order, + private readonly int $fixedStatusId = 3, + ) { + } + + public function change(int $orderId, int $statusId, bool $skipNotifications = false): bool|string + { + $current = (int) $this->order->get('status_id'); + if ($current === $this->fixedStatusId) { + return 'ms3_err_status_fixed'; + } + if ($current === $statusId) { + return 'ms3_err_status_same'; + } + $this->changes[] = [$orderId, $statusId]; + $this->order->set('status_id', $statusId); + + return true; + } + + public function ensure(int $orderId, int $statusId, bool $skipNotifications = false): bool|string + { + if ((int) $this->order->get('status_id') === $statusId) { + return true; + } + + return $this->change($orderId, $statusId, $skipNotifications); + } +} diff --git a/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php b/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php index 23e982f46..9252d9118 100644 --- a/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php +++ b/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php @@ -128,4 +128,23 @@ public function hasEvent(int $attemptId, string $eventType, string $providerEven { return isset($this->events[$attemptId . ':' . $eventType . ':' . $providerEventId]); } + + public function writeWithEvent(int $id, string $eventType, string $providerEventId, array $fields): array + { + if ($this->hasEvent($id, $eventType, $providerEventId)) { + $row = $this->findById($id); + if ($row === null) { + throw new \RuntimeException('attempt not found'); + } + + return $row; + } + $row = $fields === [] ? $this->findById($id) : $this->update($id, $fields); + if ($row === null) { + throw new \RuntimeException('attempt not found'); + } + $this->recordEvent($id, $eventType, $providerEventId); + + return $row; + } }