diff --git a/src/Exceptions/InvalidRequestException.php b/src/Exceptions/InvalidRequestException.php index 6b1e472..dca066f 100644 --- a/src/Exceptions/InvalidRequestException.php +++ b/src/Exceptions/InvalidRequestException.php @@ -6,15 +6,19 @@ class InvalidRequestException extends PaymentException { + /** + * @param array|null $details + */ public function __construct( string $message = 'Invalid request', ?string $errorCode = null, ?string $errorType = null, ?string $declineCode = null, ?string $param = null, + ?array $details = null, int $code = 400, ?\Throwable $previous = null ) { - parent::__construct($message, $errorCode, $errorType, $declineCode, $param, $code, $previous); + parent::__construct($message, $errorCode, $errorType, $declineCode, $param, $details, $code, $previous); } } diff --git a/src/Exceptions/PaymentException.php b/src/Exceptions/PaymentException.php index f5d7b88..4529b21 100644 --- a/src/Exceptions/PaymentException.php +++ b/src/Exceptions/PaymentException.php @@ -6,12 +6,16 @@ class PaymentException extends \RuntimeException { + /** + * @param array|null $details + */ public function __construct( string $message = '', public readonly ?string $errorCode = null, public readonly ?string $errorType = null, public readonly ?string $declineCode = null, public readonly ?string $param = null, + public readonly ?array $details = null, int $code = 0, ?\Throwable $previous = null ) { diff --git a/src/Gateways/AbstractGateway.php b/src/Gateways/AbstractGateway.php index 3e8860c..8a7a0a1 100644 --- a/src/Gateways/AbstractGateway.php +++ b/src/Gateways/AbstractGateway.php @@ -79,33 +79,93 @@ protected function sendRequest($request): array protected function handleErrorResponse(array $response, int $statusCode): void { - $error = $response['error'] ?? []; - $message = $error['message'] ?? 'An error occurred with the payment gateway'; - $code = $error['code'] ?? null; - $type = $error['type'] ?? null; - $declineCode = $error['decline_code'] ?? null; - $param = $error['param'] ?? null; + $error = isset($response['error']) && is_array($response['error']) + ? $response['error'] + : $response; + + $message = $this->extractErrorMessage($error) + ?? $this->extractErrorMessage($response) + ?? 'An error occurred with the payment gateway'; + + $code = $this->extractErrorField($error, ['code', 'Code', 'error_code', 'ErrorCode', 'ResultCode']); + $type = $this->extractErrorField($error, ['type', 'Type', 'error_type', 'ErrorType']); + $declineCode = $this->extractErrorField($error, ['decline_code', 'DeclineCode']); + $param = $this->extractErrorField($error, ['param', 'Param', 'parameter', 'Parameter']); + $details = [ + 'response' => $response, + ]; switch ($statusCode) { case 400: - throw new \Yiisoft\Payments\Exceptions\InvalidRequestException( + throw new InvalidRequestException( $message, $code, $type, $declineCode, $param, + $details, $statusCode ); - // Add more specific exceptions as needed default: - throw new \Yiisoft\Payments\Exceptions\PaymentException( + throw new PaymentException( $message, $code, $type, $declineCode, $param, + $details, $statusCode ); } } + + private function extractErrorMessage(array $payload): ?string + { + $message = $this->extractErrorField( + $payload, + ['message', 'Message', 'description', 'Description', 'detail', 'Detail', 'title', 'Title', 'error_description', 'error_message', 'Error'] + ); + + if ($message !== null && $message !== '') { + return $message; + } + + $errors = $payload['errors'] ?? $payload['Errors'] ?? null; + if (is_array($errors) && $errors !== []) { + $first = reset($errors); + if (is_array($first)) { + return $this->extractErrorMessage($first); + } + + if (is_scalar($first)) { + return (string) $first; + } + } + + $error = $payload['error'] ?? null; + if (is_scalar($error) && $error !== '') { + return (string) $error; + } + + return null; + } + + /** + * @param list $keys + */ + private function extractErrorField(array $payload, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + if (is_scalar($value) && $value !== '') { + return (string) $value; + } + } + + return null; + } } diff --git a/src/Gateways/PayPalGateway.php b/src/Gateways/PayPalGateway.php index a308934..2f947d5 100644 --- a/src/Gateways/PayPalGateway.php +++ b/src/Gateways/PayPalGateway.php @@ -60,6 +60,9 @@ protected function getBaseUri(): string * * This method returns the given model with an assigned ID if it doesn't have one. No API call is made. * The returned ID is stable only within the calling application (store it yourself if needed). + * + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public PayPal API. */ public function createCustomer(Customer $customer): Customer { @@ -83,6 +86,10 @@ public function createCustomer(Customer $customer): Customer * * This method returns a placeholder customer with the provided ID. */ + /** + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public PayPal API. + */ public function retrieveCustomer(string $customerId): Customer { return new Customer(id: $customerId); @@ -93,6 +100,10 @@ public function retrieveCustomer(string $customerId): Customer * * This method returns the input customer unchanged. */ + /** + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a standalone customer resource or customer update endpoint compatible with this interface. This operation cannot be implemented against the public PayPal API. + */ public function updateCustomer(Customer $customer): Customer { return $customer; @@ -102,6 +113,9 @@ public function updateCustomer(Customer $customer): Customer * PayPal does not expose a generic "Customer" resource compatible with this library's interface. * * This method is a no-op. + * + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a standalone customer resource or customer deletion endpoint compatible with this interface. This operation cannot be implemented against the public PayPal API. */ public function deleteCustomer(string $customerId): void { @@ -119,6 +133,8 @@ public function deleteCustomer(string $customerId): void * Optional metadata keys used by this gateway: * - return_url: URL where PayPal will redirect the payer after approval (web flow) * - cancel_url: URL where PayPal will redirect the payer if they cancel (web flow) + * + * @sandbox-support implemented */ public function createPaymentIntent(PaymentIntent $paymentIntent): PaymentIntent { @@ -170,6 +186,8 @@ public function createPaymentIntent(PaymentIntent $paymentIntent): PaymentIntent /** * Retrieves PayPal Order data. + * + * @sandbox-support implemented */ public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent { @@ -185,6 +203,9 @@ public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent * * For PayPal web flows, payer approval happens outside of the API (via approval link). * This method simply re-fetches the current order state. + * + * @sandbox-support partial + * @sandbox-reason PayPal order approval is performed by the payer on PayPal-hosted pages. The public API does not expose a separate generic confirm endpoint compatible with this interface. */ public function confirmPaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { @@ -199,6 +220,8 @@ public function confirmPaymentIntent(string $paymentIntentId, array $params = [] * * If you already have an authorization ID and want to capture it explicitly, pass it as: * $this->capturePaymentIntent($orderId, ['authorization_id' => '...']) + * + * @sandbox-support implemented */ public function capturePaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { @@ -219,6 +242,7 @@ public function capturePaymentIntent(string $paymentIntentId, array $params = [] 'paypal', null, 'payment_source', + null, 400 ); } @@ -288,7 +312,10 @@ public function capturePaymentIntent(string $paymentIntentId, array $params = [] ]); } -public function cancelPaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent + /** + * @sandbox-support implemented + */ + public function cancelPaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { try { $order = $this->sendRequest( @@ -306,6 +333,9 @@ public function cancelPaymentIntent(string $paymentIntentId, array $params = []) * PayPal does not expose a generic "PaymentMethod" resource compatible with this library's interface. * * This method returns the given model with an assigned ID if it doesn't have one. No API call is made. + * + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a standalone generic payment-method resource compatible with this interface. This operation cannot be implemented against the public PayPal API. */ public function createPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod { @@ -357,6 +387,9 @@ public function detachPaymentMethod(string $paymentMethodId): void * PayPal does not expose a generic "PaymentMethod" attachment API compatible with this library's interface. * * This method returns the payment method unchanged. + * + * @sandbox-support not_implemented + * @sandbox-reason PayPal public API does not expose a generic payment-method attachment endpoint compatible with this interface. This operation cannot be implemented against the public PayPal API. */ public function attachPaymentMethod(string $paymentMethodId, string $customerId): PaymentMethod { @@ -370,6 +403,8 @@ public function attachPaymentMethod(string $paymentMethodId, string $customerId) * To use this method you must pass a capture ID either: * - as $paymentIntentId directly; OR * - as ['capture_id' => '...'] in $params + * + * @sandbox-support implemented */ public function createRefund(string $paymentIntentId, array $params = []): array { @@ -440,6 +475,7 @@ protected function handleErrorResponse(array $responseData, int $statusCode): vo 'paypal', null, $param, + null, $statusCode ); } @@ -470,6 +506,7 @@ private function getAccessToken(): string 'paypal', null, null, + null, $response->getStatusCode() ); } diff --git a/src/Gateways/RobokassaGateway.php b/src/Gateways/RobokassaGateway.php index c78be8f..9fc2615 100644 --- a/src/Gateways/RobokassaGateway.php +++ b/src/Gateways/RobokassaGateway.php @@ -78,6 +78,10 @@ protected function getBaseUri(): string // Customer and PaymentMethod operations (no-op placeholders) // --------------------------------------------------------------------- + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function createCustomer(Customer $customer): Customer { if ($customer->id !== null) { @@ -95,21 +99,37 @@ public function createCustomer(Customer $customer): Customer ); } + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a standalone customer resource or customer retrieval endpoint compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function retrieveCustomer(string $customerId): Customer { return new Customer(id: $customerId); } + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a standalone customer resource or customer update endpoint compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function updateCustomer(Customer $customer): Customer { return $customer; } + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a standalone customer resource or customer deletion endpoint compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function deleteCustomer(string $customerId): void { // Intentionally no-op. } + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a standalone generic payment-method resource compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function createPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod { if ($paymentMethod->id !== null) { @@ -141,6 +161,10 @@ public function detachPaymentMethod(string $paymentMethodId): void // Intentionally no-op. } + /** + * @sandbox-support not_implemented + * @sandbox-reason Robokassa public API does not expose a generic payment-method attachment endpoint compatible with this interface. This operation cannot be implemented against the public Robokassa API. + */ public function attachPaymentMethod(string $paymentMethodId, string $customerId): PaymentMethod { return $this->retrievePaymentMethod($paymentMethodId); @@ -162,6 +186,8 @@ public function attachPaymentMethod(string $paymentMethodId, string $customerId) * Additional Robokassa-specific invoice fields can be provided via metadata: * - InvoiceType, ExpirationDate, Culture, Email, SuccessUrl, FailUrl, ResultUrl, Receipt, etc. * All metadata is passed to Robokassa as-is (except reserved keys shown above). + * + * @sandbox-support implemented */ public function createPaymentIntent(PaymentIntent $paymentIntent): PaymentIntent { @@ -224,6 +250,8 @@ public function createPaymentIntent(PaymentIntent $paymentIntent): PaymentIntent * Returns: * - status: Robokassa state code (string) in metadata and mapped high-level status in PaymentIntent::status * - metadata.robokassa_op_key: operation key for refunds (when available) + * + * @sandbox-support implemented */ public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent { @@ -237,7 +265,15 @@ public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent $code = isset($result->Code) ? (int) $result->Code : null; if ($code !== 0) { $desc = isset($result->Description) ? (string) $result->Description : 'Robokassa OpStateExt error'; - throw new PaymentException($desc, (string) $code, 'robokassa', null, null, 400); + throw new PaymentException( + $desc, + (string) $code, + 'robokassa', + null, + null, + null, + 400 + ); } $stateCode = isset($xml->State->Code) ? (int) $xml->State->Code : null; @@ -257,6 +293,9 @@ public function retrievePaymentIntent(string $paymentIntentId): PaymentIntent /** * For Robokassa, payer action happens on a hosted payment page. * This method simply re-fetches invoice state. + * + * @sandbox-support partial + * @sandbox-reason Robokassa payment confirmation is performed by the payer on the hosted payment page. The public API does not expose a separate generic confirm endpoint compatible with this interface. */ public function confirmPaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { @@ -266,6 +305,9 @@ public function confirmPaymentIntent(string $paymentIntentId, array $params = [] /** * Robokassa does not support "capture" in the same way as card processors (it is invoice-based). * This method re-fetches invoice state. + * + * @sandbox-support partial + * @sandbox-reason Robokassa invoice flow does not expose a separate capture endpoint compatible with this interface; payment is completed on the hosted invoice/payment page. */ public function capturePaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { @@ -276,6 +318,9 @@ public function capturePaymentIntent(string $paymentIntentId, array $params = [] * Attempts to deactivate an invoice via Invoice API. * * If the Invoice API call is not available/authorized, returns a best-effort local status. + * + * @sandbox-support partial + * @sandbox-reason Robokassa invoice deactivation is not equivalent to a guaranteed generic cancel operation for this interface, so cancellation can only be provided on a best-effort basis. */ public function cancelPaymentIntent(string $paymentIntentId, array $params = []): PaymentIntent { @@ -344,6 +389,8 @@ public function cancelPaymentIntent(string $paymentIntentId, array $params = []) * If it is not provided, the gateway will call OpStateExt to obtain it. * * @return array + * + * @sandbox-support implemented */ public function createRefund(string $paymentIntentId, array $params = []): array { @@ -354,6 +401,7 @@ public function createRefund(string $paymentIntentId, array $params = []): array 'robokassa', null, null, + null, 500 ); } @@ -371,6 +419,7 @@ public function createRefund(string $paymentIntentId, array $params = []): array 'robokassa', null, null, + null, 400 ); } @@ -406,46 +455,74 @@ public function createRefund(string $paymentIntentId, array $params = []): array /** * Sends a JWT-as-body request to an endpoint that returns JSON. * - * The JWT is sent as a plain string body, without JSON encoding. - * * @return array */ private function sendRawJsonRequest(string $method, string $url, string $jwt): array { + $requestBody = '"' . $jwt . '"'; + $request = $this->requestFactory->createRequest($method, $url) - ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Type', 'application/json') ->withHeader('Accept', 'application/json'); - $request = $request->withBody($this->streamFactory->createStream($jwt)); + $request = $request->withBody($this->streamFactory->createStream($requestBody)); $response = $this->httpClient->sendRequest($request); $body = (string) $response->getBody(); $data = json_decode($body, true); if (!is_array($data)) { + $this->log('error', 'Robokassa API returned a non-JSON response.', [ + 'status' => $response->getStatusCode(), + 'body' => $body, + ]); + throw new PaymentException( 'Robokassa API returned a non-JSON response.', 'robokassa_invalid_response', 'robokassa', null, null, + [ + 'status' => $response->getStatusCode(), + 'response_body' => $body, + ], $response->getStatusCode() ); } if ($response->getStatusCode() >= 400) { + $this->log('error', 'Robokassa JSON API request failed.', [ + 'status' => $response->getStatusCode(), + 'response' => $data, + ]); $this->handleErrorResponse($data, $response->getStatusCode()); } // Some Robokassa endpoints return 200 even on logical failure. - if (isset($data['success']) && $data['success'] === false) { - $message = (string) ($data['message'] ?? 'Robokassa request failed.'); + $isFailure = (isset($data['success']) && $data['success'] === false) + || (isset($data['Success']) && $data['Success'] === false) + || (isset($data['Result']) && $data['Result'] === false); + + if ($isFailure) { + $message = $this->extractRobokassaErrorMessage($data) ?? 'Robokassa request failed.'; + $errorCode = $this->extractRobokassaErrorCode($data) ?? 'robokassa_error'; + + $this->log('error', 'Robokassa logical API failure.', [ + 'status' => $response->getStatusCode(), + 'response' => $data, + ]); + throw new PaymentException( $message, - (string) ($data['code'] ?? 'robokassa_error'), + $errorCode, 'robokassa', null, null, + [ + 'status' => $response->getStatusCode(), + 'response' => $data, + ], $response->getStatusCode() ); } @@ -453,6 +530,50 @@ private function sendRawJsonRequest(string $method, string $url, string $jwt): a return $data; } + private function extractRobokassaErrorMessage(array $data): ?string + { + foreach (['message', 'Message', 'description', 'Description', 'error', 'Error'] as $key) { + if (!array_key_exists($key, $data)) { + continue; + } + + $value = $data[$key]; + if (is_scalar($value) && $value !== '') { + return (string) $value; + } + } + + $errors = $data['errors'] ?? $data['Errors'] ?? null; + if (is_array($errors) && $errors !== []) { + $first = reset($errors); + if (is_array($first)) { + return $this->extractRobokassaErrorMessage($first); + } + + if (is_scalar($first) && $first !== '') { + return (string) $first; + } + } + + return null; + } + + private function extractRobokassaErrorCode(array $data): ?string + { + foreach (['code', 'Code', 'errorCode', 'ErrorCode'] as $key) { + if (!array_key_exists($key, $data)) { + continue; + } + + $value = $data[$key]; + if (is_scalar($value) && $value !== '') { + return (string) $value; + } + } + + return null; + } + /** * Sends form-encoded request to Robokassa XML API endpoint and parses XML into SimpleXMLElement. * @@ -498,6 +619,7 @@ private function sendXmlRequest(string $method, string $url, array $fields): \Si 'robokassa', null, null, + null, $response->getStatusCode() ); } diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 4a0c713..9a2cf02 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -38,13 +38,71 @@ protected function getBaseUri(): string */ protected function createRequest(string $method, string $endpoint, array $data = []) { - $request = parent::createRequest($method, $endpoint, $data); - - return $request + $uri = rtrim($this->getBaseUri(), '/') . '/' . ltrim($endpoint, '/'); + $request = $this->requestFactory->createRequest($method, $uri) + ->withHeader('Accept', 'application/json') + ->withHeader('User-Agent', 'PaymentGateway/' . self::API_VERSION) ->withHeader('Authorization', 'Bearer ' . $this->apiKey) ->withHeader('Stripe-Version', $this->apiVersion); + + if (!empty($data)) { + $stream = $this->streamFactory->createStream($this->buildFormBody($data)); + $request = $request + ->withBody($stream) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + } + + return $request; + } + + /** + * @param array $data + */ + private function buildFormBody(array $data): string + { + $pairs = []; + + foreach ($this->flattenFormFields($data) as $key => $value) { + $pairs[] = rawurlencode($key) . '=' . rawurlencode($value); + } + + return implode('&', $pairs); + } + + /** + * @param array $data + * @return array + */ + private function flattenFormFields(array $data, string $prefix = ''): array + { + $result = []; + + foreach ($data as $key => $value) { + if ($value === null) { + continue; + } + + $field = $prefix === '' ? (string) $key : $prefix . '[' . $key . ']'; + + if (is_array($value)) { + $result += $this->flattenFormFields($value, $field); + continue; + } + + if (is_bool($value)) { + $result[$field] = $value ? 'true' : 'false'; + continue; + } + + $result[$field] = (string) $value; + } + + return $result; } + /** + * @sandbox-support implemented + */ public function createCustomer(Customer $customer): Customer { $data = array_filter([ @@ -75,6 +133,9 @@ public function createCustomer(Customer $customer): Customer ); } + /** + * @sandbox-support implemented + */ public function retrieveCustomer(string $customerId): Customer { $request = $this->createRequest('GET', "/customers/{$customerId}"); @@ -91,6 +152,9 @@ public function retrieveCustomer(string $customerId): Customer ); } + /** + * @sandbox-support implemented + */ public function updateCustomer(Customer $customer): Customer { if ($customer->id === null) { @@ -125,12 +189,18 @@ public function updateCustomer(Customer $customer): Customer ); } + /** + * @sandbox-support implemented + */ public function deleteCustomer(string $customerId): void { $request = $this->createRequest('DELETE', "/customers/{$customerId}"); $this->sendRequest($request); } + /** + * @sandbox-support implemented + */ public function createPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod { $data = [ @@ -146,6 +216,9 @@ public function createPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod return PaymentMethod::fromArray($response); } + /** + * @sandbox-support implemented + */ public function attachPaymentMethod(string $paymentMethodId, string $customerId): PaymentMethod { $request = $this->createRequest( @@ -158,6 +231,9 @@ public function attachPaymentMethod(string $paymentMethodId, string $customerId) return PaymentMethod::fromArray($response); } + /** + * @sandbox-support implemented + */ public function createPaymentIntent(PaymentIntent $intent): PaymentIntent { $data = array_filter([ @@ -167,6 +243,9 @@ public function createPaymentIntent(PaymentIntent $intent): PaymentIntent 'payment_method' => $intent->paymentMethodId, 'description' => $intent->description, 'metadata' => $intent->metadata, + 'capture_method' => $intent->captureMethod === null + ? null + : ($intent->captureMethod ? 'manual' : 'automatic'), 'confirm' => $intent->confirm, 'off_session' => $intent->offSession, 'receipt_email' => $intent->receiptEmail, @@ -179,6 +258,9 @@ public function createPaymentIntent(PaymentIntent $intent): PaymentIntent return PaymentIntent::fromArray($response); } + /** + * @sandbox-support implemented + */ public function confirmPaymentIntent(string $intentId, array $params = []): PaymentIntent { $request = $this->createRequest('POST', "/payment_intents/{$intentId}/confirm", $params); @@ -186,6 +268,9 @@ public function confirmPaymentIntent(string $intentId, array $params = []): Paym return PaymentIntent::fromArray($response); } + /** + * @sandbox-support implemented + */ public function capturePaymentIntent(string $intentId, array $params = []): PaymentIntent { $request = $this->createRequest('POST', "/payment_intents/{$intentId}/capture", $params); @@ -193,6 +278,9 @@ public function capturePaymentIntent(string $intentId, array $params = []): Paym return PaymentIntent::fromArray($response); } + /** + * @sandbox-support implemented + */ public function cancelPaymentIntent(string $intentId, array $params = []): PaymentIntent { $request = $this->createRequest('POST', "/payment_intents/{$intentId}/cancel", $params); @@ -200,6 +288,9 @@ public function cancelPaymentIntent(string $intentId, array $params = []): Payme return PaymentIntent::fromArray($response); } + /** + * @sandbox-support implemented + */ public function createRefund(string $paymentIntentId, array $params = []): array { $params['payment_intent'] = $paymentIntentId; @@ -215,6 +306,9 @@ public function createRefund(string $paymentIntentId, array $params = []): array ]; } + /** + * @sandbox-support implemented + */ public function retrievePaymentIntent(string $intentId): PaymentIntent { $request = $this->createRequest('GET', "/payment_intents/{$intentId}"); diff --git a/src/Gateways/YooKassaGateway.php b/src/Gateways/YooKassaGateway.php index 9336730..d71c061 100644 --- a/src/Gateways/YooKassaGateway.php +++ b/src/Gateways/YooKassaGateway.php @@ -45,36 +45,63 @@ protected function createRequest(string $method, string $endpoint, array $data = return $request; } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function createCustomer(Customer $customer): Customer { return $customer; } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function retrieveCustomer(string $customerId): Customer { - throw new \PaymentException('YooKassa API does not support retrieving customer'); + throw new PaymentException('YooKassa API does not support retrieving customer'); } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function updateCustomer(Customer $customer): Customer { - throw new \PaymentException('YooKassa API does not support updating customer'); + throw new PaymentException('YooKassa API does not support updating customer'); } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a standalone customer resource or customer CRUD endpoints compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function deleteCustomer(string $customerId): void { - throw new \PaymentException('YooKassa API does not support delete customer'); + throw new PaymentException('YooKassa API does not support delete customer'); } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a standalone generic payment-method resource compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function createPaymentMethod(PaymentMethod $paymentMethod): PaymentMethod { return $paymentMethod; } + /** + * @sandbox-support not_implemented + * @sandbox-reason YooKassa public API does not expose a generic payment-method attachment endpoint compatible with this interface. This operation cannot be implemented against the public YooKassa API. + */ public function attachPaymentMethod(string $paymentMethodId, string $customerId): PaymentMethod { return new PaymentMethod($paymentMethodId, 'yookassa', [], $customerId); } + /** + * @sandbox-support implemented + */ public function createPaymentIntent(PaymentIntent $intent): PaymentIntent { $data = [ @@ -103,6 +130,9 @@ public function createPaymentIntent(PaymentIntent $intent): PaymentIntent return $this->parsePaymentIntent($response); } + /** + * @sandbox-support implemented + */ public function retrievePaymentIntent(string $intentId): PaymentIntent { $request = $this->createRequest('GET', "/payments/{$intentId}"); @@ -111,11 +141,18 @@ public function retrievePaymentIntent(string $intentId): PaymentIntent return $this->parsePaymentIntent($response); } + /** + * @sandbox-support partial + * @sandbox-reason YooKassa payment flow does not expose a separate generic confirm endpoint compatible with this interface; confirmation is handled by the provider flow and subsequent capture step. + */ public function confirmPaymentIntent(string $intentId, array $params = []): PaymentIntent { return $this->capturePaymentIntent($intentId, $params); } + /** + * @sandbox-support implemented + */ public function capturePaymentIntent(string $intentId, array $params = []): PaymentIntent { $request = $this->createRequest('POST', "/payments/{$intentId}/capture", []); @@ -124,6 +161,9 @@ public function capturePaymentIntent(string $intentId, array $params = []): Paym return $this->parsePaymentIntent($response); } + /** + * @sandbox-support implemented + */ public function cancelPaymentIntent(string $intentId, array $params = []): PaymentIntent { $request = $this->createRequest('POST', "/payments/{$intentId}/cancel", $params); @@ -132,6 +172,9 @@ public function cancelPaymentIntent(string $intentId, array $params = []): Payme return $this->parsePaymentIntent($response); } + /** + * @sandbox-support implemented + */ public function createRefund(string $paymentId, array $params = []): array { if (!array_key_exists('amount', $params) || !array_key_exists('currency', $params)) { diff --git a/src/Models/PaymentIntent.php b/src/Models/PaymentIntent.php index c966ae5..25ce15b 100644 --- a/src/Models/PaymentIntent.php +++ b/src/Models/PaymentIntent.php @@ -116,6 +116,15 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Currency must be a string or null'); } + $captureMethod = $data['capture_method'] ?? $data['captureMethod'] ?? null; + if (is_string($captureMethod)) { + $captureMethod = match ($captureMethod) { + 'manual' => true, + 'automatic' => false, + default => null, + }; + } + return new self( id: $data['id'] ?? null, status: $data['status'] ?? null, @@ -128,7 +137,7 @@ public static function fromArray(array $data): self metadata: $data['metadata'] ?? null, nextAction: $data['next_action'] ?? $data['nextAction'] ?? null, charges: $data['charges'] ?? null, - captureMethod: $data['capture_method'] ?? $data['captureMethod'] ?? null, + captureMethod: $captureMethod, confirm: $data['confirm'] ?? null, offSession: $data['off_session'] ?? $data['offSession'] ?? null, receiptEmail: $data['receipt_email'] ?? $data['receiptEmail'] ?? null, diff --git a/tests/Gateways/PayPalGatewayTest.php b/tests/Gateways/PayPalGatewayTest.php index f64ca02..8e8fad6 100644 --- a/tests/Gateways/PayPalGatewayTest.php +++ b/tests/Gateways/PayPalGatewayTest.php @@ -125,7 +125,7 @@ public function testCreateRefundUsesCaptureRefundV2(): void $result = $this->gateway->createRefund( paymentIntentId: 'CAPTURE-123', - amount: 1000 + params: ['amount' => 1000] ); $this->assertSame('RFD-123', $result['id']); diff --git a/tests/Gateways/RobokassaGatewayTest.php b/tests/Gateways/RobokassaGatewayTest.php index 5716bd1..bbcc68e 100644 --- a/tests/Gateways/RobokassaGatewayTest.php +++ b/tests/Gateways/RobokassaGatewayTest.php @@ -60,10 +60,70 @@ public function testCreatePaymentIntentCreatesInvoice(): void $this->assertSame('POST', $lastRequest['method']); $this->assertStringContainsString('InvoiceServiceWebApi/api/CreateInvoice', $lastRequest['uri']); $this->assertArrayHasKey('Content-Type', $lastRequest['headers']); - $this->assertSame('text/plain', $lastRequest['headers']['Content-Type'][0]); + $this->assertSame('application/json', $lastRequest['headers']['Content-Type'][0]); - // JWT has 3 dot-separated segments. - $this->assertCount(3, explode('.', $lastRequest['body'])); + $this->assertStringStartsWith('"', $lastRequest['body']); + $this->assertStringEndsWith('"', $lastRequest['body']); + // JWT has 3 dot-separated segments inside quoted request body. + $this->assertCount(3, explode('.', trim($lastRequest['body'], '"'))); + } + + + public function testCreatePaymentIntentIncludesApiErrorDetailsInException(): void + { + $this->httpClient->queueJsonResponse([ + 'Error' => 'Invalid signature.', + 'ErrorCode' => 'INVALID_SIGNATURE', + ], 400); + + $intent = new PaymentIntent( + id: null, + amount: 2500, + currency: 'RUB', + description: 'Test payment' + ); + + $this->expectException(\Yiisoft\Payments\Exceptions\InvalidRequestException::class); + $this->expectExceptionMessage('Invalid signature.'); + + try { + $this->gateway->createPaymentIntent($intent); + } catch (\Yiisoft\Payments\Exceptions\InvalidRequestException $e) { + $this->assertSame('INVALID_SIGNATURE', $e->errorCode); + $this->assertIsArray($e->details); + $this->assertSame('Invalid signature.', $e->details['response']['Error'] ?? null); + throw $e; + } + } + + + public function testCreatePaymentIntentUsesProblemDetailsTitleAsErrorMessage(): void + { + $this->httpClient->queueJsonResponse([ + 'type' => 'https://tools.ietf.org/html/rfc9110#section-15.5.16', + 'title' => 'Unsupported Media Type', + 'status' => 415, + 'traceId' => 'trace-id', + ], 415); + + $intent = new PaymentIntent( + id: null, + amount: 2500, + currency: 'RUB', + description: 'Test payment' + ); + + $this->expectException(\Yiisoft\Payments\Exceptions\PaymentException::class); + $this->expectExceptionMessage('Unsupported Media Type'); + + try { + $this->gateway->createPaymentIntent($intent); + } catch (\Yiisoft\Payments\Exceptions\PaymentException $e) { + $this->assertSame('https://tools.ietf.org/html/rfc9110#section-15.5.16', $e->errorType); + $this->assertIsArray($e->details); + $this->assertSame('Unsupported Media Type', $e->details['response']['title'] ?? null); + throw $e; + } } public function testRetrievePaymentIntentUsesOpStateExt(): void @@ -110,7 +170,10 @@ public function testCreateRefundUsesRefundApiV2(): void $result = $this->gateway->createRefund( paymentIntentId: '123', - amount: 1000 + params: [ + 'amount' => 1000, + 'op_key' => 'OP-123', + ] ); $this->assertTrue($result['success']); diff --git a/tests/Gateways/StripeGatewayCaptureMethodTest.php b/tests/Gateways/StripeGatewayCaptureMethodTest.php index 7f6b20d..2d80dff 100644 --- a/tests/Gateways/StripeGatewayCaptureMethodTest.php +++ b/tests/Gateways/StripeGatewayCaptureMethodTest.php @@ -52,7 +52,7 @@ public function testCreatePaymentIntentAutomaticCapture(): void $this->assertSame('pi_automatic', $result->id); - $body = json_decode($this->httpClient->lastRequest['body'], true); + parse_str($this->httpClient->lastRequest['body'], $body); $this->assertSame('automatic', $body['capture_method']); } } diff --git a/tests/Gateways/StripeGatewayTest.php b/tests/Gateways/StripeGatewayTest.php index 2b0cbca..d8740e9 100644 --- a/tests/Gateways/StripeGatewayTest.php +++ b/tests/Gateways/StripeGatewayTest.php @@ -86,6 +86,11 @@ public function testCreateCustomer(): void $lastRequest = $this->getLastRequest(); $this->assertSame('POST', $lastRequest['method']); $this->assertStringContainsString('/customers', $lastRequest['uri']); + $this->assertSame('application/x-www-form-urlencoded', $lastRequest['headers']['Content-Type'][0]); + $this->assertStringContainsString('email=test%40example.com', $lastRequest['body']); + $this->assertStringContainsString('name=Test%20User', $lastRequest['body']); + $this->assertStringContainsString('address%5Bline1%5D=123%20Test%20St', $lastRequest['body']); + $this->assertStringContainsString('metadata%5Btest_meta%5D=value', $lastRequest['body']); } public function testCreatePaymentIntent(): void @@ -135,6 +140,10 @@ public function testCreatePaymentIntent(): void $lastRequest = $this->getLastRequest(); $this->assertSame('POST', $lastRequest['method']); $this->assertStringContainsString('/payment_intents', $lastRequest['uri']); + $this->assertSame('application/x-www-form-urlencoded', $lastRequest['headers']['Content-Type'][0]); + $this->assertStringNotContainsString('confirm=true', $lastRequest['body']); + $this->assertStringNotContainsString('off_session=false', $lastRequest['body']); + $this->assertStringContainsString('metadata%5Border_id%5D=12345', $lastRequest['body']); } public function testConfirmPaymentIntent(): void @@ -154,7 +163,8 @@ public function testConfirmPaymentIntent(): void $lastRequest = $this->getLastRequest(); $this->assertSame('POST', $lastRequest['method']); $this->assertStringContainsString('/payment_intents/pi_test123/confirm', $lastRequest['uri']); - $this->assertStringContainsString('return_url', $lastRequest['body']); + $this->assertSame('application/x-www-form-urlencoded', $lastRequest['headers']['Content-Type'][0]); + $this->assertStringContainsString('return_url=https%3A%2F%2Fexample.com%2Freturn', $lastRequest['body']); } public function testCreateRefund(): void @@ -177,9 +187,11 @@ public function testCreateRefund(): void $lastRequest = $this->getLastRequest(); $this->assertSame('POST', $lastRequest['method']); $this->assertSame('https://api.stripe.com/v1/refunds', $lastRequest['uri']); - $this->assertJsonStringEqualsJsonString( - '{"payment_intent":"pi_test123","amount":1000}', - $lastRequest['body'] - ); + $this->assertSame('application/x-www-form-urlencoded', $lastRequest['headers']['Content-Type'][0]); + parse_str($lastRequest['body'], $parsedBody); + $this->assertSame([ + 'amount' => '1000', + 'payment_intent' => 'pi_test123', + ], $parsedBody); } } diff --git a/tests/Gateways/YooKassaGatewayTest.php b/tests/Gateways/YooKassaGatewayTest.php index c79443f..78bb52a 100644 --- a/tests/Gateways/YooKassaGatewayTest.php +++ b/tests/Gateways/YooKassaGatewayTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Payments\Tests\Gateways; use PHPUnit\Framework\TestCase; +use Yiisoft\Payments\Models\Customer; use Yiisoft\Payments\Models\PaymentIntent; use Yiisoft\Payments\Gateways\YooKassaGateway; use Yiisoft\Payments\Tests\Support\TestHttpClient; @@ -42,7 +43,19 @@ private function getLastRequest(): array return $this->httpClient->lastRequest; } - public function testCreateCustomer(): void {} + public function testCreateCustomer(): void + { + $customer = new Customer( + email: 'test@example.com', + name: 'Test User', + ); + + $created = $this->gateway->createCustomer($customer); + + $this->assertNull($created->id); + $this->assertSame('test@example.com', $created->email); + $this->assertSame('Test User', $created->name); + } public function testCreatePaymentIntent(): void { @@ -99,7 +112,7 @@ public function testCreatePaymentIntent(): void $this->assertSame('30ae77b9-000f-5001-8000-13e0de458932', $result->id); $this->assertSame(10000, $result->amount); - $this->assertSame('rub', $result->currency); + $this->assertSame('RUB', $result->currency); $this->assertSame('Test payment', $result->description); }