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..d930c6005 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -189,6 +189,13 @@ $_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_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.'; $_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..a56d0fcfc 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -189,6 +189,13 @@ $_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_attempt_record'] = 'Не удалось записать попытку оплаты после отправки заказа в платёжный шлюз.'; +$_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..dc35f36fe 100644 --- a/core/components/minishop3/src/Controllers/Payment/Payment.php +++ b/core/components/minishop3/src/Controllers/Payment/Payment.php @@ -6,6 +6,8 @@ use MiniShop3\Model\msOrder; use MiniShop3\Model\msPayment; use MiniShop3\Services\Order\OrderCostEngine; +use MiniShop3\Services\Payment\PaymentService; +use MiniShop3\Services\Payment\PaymentWebhookHmac; use MODX\Revolution\modX; /** @@ -61,15 +63,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'); * } * @@ -150,19 +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. - * - * Note: Method calls send() each time without caching. - * For payment systems with API limits caching is recommended. + * 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 { + $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( @@ -236,6 +243,42 @@ protected function success(string $message = '', array $data = [], array $placeh return $this->ms3->utils->success($message, $data, $placeholders); } + /** + * HMAC-SHA256 over the raw webhook body. Call from verifyWebhook(). + */ + protected function verifyWebhookHmac(string $rawBody, string $signature, ?string $secret = null): bool + { + 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 (string) $this->modx->getOption('ms3_payment_secret', null, ''); + } + + private function configuredPayment(): ?msPayment + { + $payment = $this->config['payment'] ?? null; + + return $payment instanceof msPayment ? $payment : null; + } + /** * 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..ecb6d6c0c 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,21 @@ 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 { + 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, + $prefix . 'ms3_payment_attempts', + $prefix . 'ms3_payment_attempt_events' + ); + /** @var OrderStatusService $orderStatus */ + $orderStatus = $services->get('ms3_order_status'); + + return new $class($store, $modx, $orderStatus); + }, '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/Order/OrderStatusChanger.php b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php new file mode 100644 index 000000000..45144d482 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/OrderStatusChanger.php @@ -0,0 +1,23 @@ +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/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; + + /** + * 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/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..9470fd741 --- /dev/null +++ b/core/components/minishop3/src/Services/Payment/PaymentLifecycleService.php @@ -0,0 +1,670 @@ + [ + 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 OrderStatusChanger $orderStatus + */ + public function __construct( + private readonly PaymentAttemptStoreInterface $store, + private readonly modX $modx, + private readonly OrderStatusChanger $orderStatus, + ) { + } + + /** + * @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); + } + + /** + * @param array $extraFields + * @return PaymentAttemptRow + */ + public function refund( + int $attemptId, + float $amount, + ?string $refundExternalId = null, + ?string $providerEventId = null, + array $extraFields = [], + ): 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); + $fields = array_merge($extraFields, [ + 'status' => $target, + 'refunded_amount' => $refundedAmount, + 'refund_external_id' => $refundExternalId, + 'refundedon' => time(), + ]); + + return $this->commit($attempt, $target, $eventKey, $fields); + } + + 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 + { + $this->assertFinancialExternalId($event); + $attempt = $this->resolveAttempt($event, $paymentMethodId, $provider); + $this->assertWebhookCurrency($attempt, $event); + $payloadFields = []; + if ($event->payload !== []) { + $payloadFields['payload'] = array_merge( + $attempt['payload'], + $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) { + PaymentAttemptStatus::PENDING, + PaymentAttemptStatus::AUTHORIZED, + PaymentAttemptStatus::FAILED, + PaymentAttemptStatus::CANCELLED => $this->apply( + $attempt['id'], + $event->eventType, + $eventId, + null, + $payloadFields + ), + PaymentAttemptStatus::PAID => $this->apply( + $attempt['id'], + $event->eventType, + $eventId, + $this->requirePaidAmount($event), + $payloadFields + ), + PaymentAttemptStatus::REFUNDED => $this->refundWebhook($attempt, $event, $eventId, $payloadFields), + PaymentAttemptStatus::PARTIALLY_REFUNDED => $this->refund( + $attempt['id'], + $event->refundAmount ?? 0.0, + $event->refundExternalId, + $eventId, + $payloadFields + ), + default => throw new PaymentLifecycleException( + 'ms3_err_payment_webhook_invalid', + ['event' => $event->eventType] + ), + }; + } + + /** + * @param array $fields + * @return PaymentAttemptRow + */ + 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) { + $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, $fields); + } + + /** + * @param array $fields + * @return PaymentAttemptRow + */ + 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); + } + + /** + * Persist fields+event atomically, then heal the order status. + * + * @param PaymentAttemptRow $attempt + * @param array $fields + * @return PaymentAttemptRow + */ + private function commit(array $attempt, string $target, string $eventKey, array $fields = []): array + { + if ($attempt['status'] !== $target) { + $this->assertTransition($attempt['status'], $target); + $fields['status'] = $target; + } + $updated = $this->store->writeWithEvent($attempt['id'], $target, $eventKey, $fields); + $this->syncOrderStatus($updated['order_id'], $target); + + return $updated; + } + + /** + * @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 $existing; + } + } + + 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']); + $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; + } + if (abs($paidAmount - $attempt['amount']) > 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->orderStatus->ensure($orderId, $statusId); + if ($result === true) { + return; + } + $message = is_string($result) ? $result : 'ms3_err_unknown'; + 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 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 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) { + 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..49405798c 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,65 @@ public function sendToPaymentGateway( } } - return $controller->send($order); + $response = $controller->send($order); + if (!empty($response['success'])) { + 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); } /** @@ -194,4 +252,46 @@ 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')) { + throw new \RuntimeException('ms3_payment_lifecycle is not registered'); + } + $lifecycle = $this->modx->services->get('ms3_payment_lifecycle'); + if (!$lifecycle instanceof PaymentLifecycleService) { + throw new \RuntimeException('ms3_payment_lifecycle is not a PaymentLifecycleService'); + } + $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; + } + $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 @@ +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; + } + + 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 + */ + 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 + { + return (string) $this->db->lastInsertId(); + } + + private function prepare(string $sql): PDOStatement + { + $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..b34b009a0 --- /dev/null +++ b/core/components/minishop3/tests/PaymentLifecycleWiringTest.php @@ -0,0 +1,75 @@ +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, 'resolvePaymentLink')) { + $fail('PaymentService must resolve payment links through send()+initiate'); +} +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..ffd58cef3 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Controllers/Api/Web/PaymentWebhookControllerTest.php @@ -0,0 +1,316 @@ +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, new CallbackOrderStatusChanger(static fn (int $orderId, int $statusId): 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..44f31542a --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentLifecycleServiceTest.php @@ -0,0 +1,527 @@ + */ + 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->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); + + $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->refund($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->refund($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); + } + + 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, + ?OrderStatusChanger $changer = 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, + $changer ?? new CallbackOrderStatusChanger( + $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..fbbb3e7f9 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Payment/PaymentServiceSendAttemptTest.php @@ -0,0 +1,193 @@ + 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(), 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']); + + $service->sendToPaymentGateway($payment, $this->sender([ + 'success' => true, + 'data' => [ + 'payment_link' => 'https://shop.example/thanks', + 'order_id' => 32, + ], + ]), $order); + + 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 + */ + public function __construct(private array $response) + { + } + + public function send(msOrder $order): array + { + $this->sends++; + + 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/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 @@ +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 new file mode 100644 index 000000000..ed8878a94 --- /dev/null +++ b/core/components/minishop3/tests/support/CallbackOrderStatusChanger.php @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..9252d9118 --- /dev/null +++ b/core/components/minishop3/tests/support/InMemoryPaymentAttemptStore.php @@ -0,0 +1,150 @@ + */ + 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]); + } + + 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; + } +}