From cb2714249cca85e187cd8bd7f0f1b5f1e9f4cf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 30 Jun 2026 15:06:01 +0200 Subject: [PATCH 1/4] Replace hand-rolled API client with setono/quickpay-php-sdk Adopt the setono/quickpay-php-sdk package as the QuickPay API client and rebuild the gateway around it (2.x): - Api is now a final, immutable value object wrapping the SDK ClientInterface and the gateway options; it performs no HTTP itself (Basic auth, Accept-Version: v10, error mapping and (de)serialization all live in the SDK). - Delete the custom src/Model/* DTOs in favour of the SDK response DTOs/enums; move the surviving state logic into the stateless Operations helper. - Rewrite every action to the SDK's typed endpoints/DTOs. ConvertPaymentAction now persists only scalars (quickpayPaymentId is the source of truth), and NotifyAction verifies the QuickPay-Checksum-Sha256 HMAC (timing-safe) before acting, rejecting invalid/unsigned callbacks with a 400. - Gateway options: drop the unused 'merchant'; require only apikey + privatekey; add 'synchronized' and 'branding_id'; map 'agreement' to the link agreement id; allow injecting a prebuilt SDK client via 'quickpay.client'. - Rebuild the test suite offline on a PSR-18 Http\Mock\Client with request-shape assertions; add OperationsTest, ConvertPaymentActionTest and ConfirmPaymentActionTest. - Keep php-http/message-factory (payum/core gateway wiring needs it) and document the carve-out in composer-dependency-analyser.php. --- composer-dependency-analyser.php | 14 + composer.json | 6 +- src/Action/Api/ConfirmPaymentAction.php | 32 +- src/Action/AuthorizeAction.php | 27 +- src/Action/CancelAction.php | 28 +- src/Action/CaptureAction.php | 9 +- src/Action/ConvertPaymentAction.php | 24 +- src/Action/NotifyAction.php | 47 ++- src/Action/RefundAction.php | 9 +- src/Action/StatusAction.php | 44 ++- src/Api.php | 260 +++----------- src/Model/QuickPayModel.php | 17 - src/Model/QuickPayPayment.php | 120 ------- src/Model/QuickPayPaymentLink.php | 36 -- src/Model/QuickPayPaymentOperation.php | 73 ---- src/Model/QuickpayCard.php | 57 ---- src/Operations.php | 73 ++++ src/QuickPayGatewayFactory.php | 37 +- tests/Action/Api/ConfirmPaymentActionTest.php | 139 ++++++++ tests/Action/AuthorizeActionTest.php | 75 ++-- tests/Action/CancelActionTest.php | 100 +++--- tests/Action/CaptureActionTest.php | 94 ++--- tests/Action/ConvertPaymentActionTest.php | 123 +++++++ tests/Action/NotifyActionTest.php | 132 ++++--- tests/Action/RefundActionTest.php | 72 +--- tests/Action/StatusActionTest.php | 321 +++++------------- tests/ApiTest.php | 52 +-- tests/ApiTestTrait.php | 118 ++++--- tests/Model/QuickpayModelTest.php | 126 ------- tests/OperationsTest.php | 106 ++++++ tests/QuickPayGatewayFactoryTest.php | 49 ++- tests/StubGetHttpRequestAction.php | 51 +++ tests/StubHttpClient.php | 56 --- 33 files changed, 1149 insertions(+), 1378 deletions(-) create mode 100644 composer-dependency-analyser.php delete mode 100644 src/Model/QuickPayModel.php delete mode 100644 src/Model/QuickPayPayment.php delete mode 100644 src/Model/QuickPayPaymentLink.php delete mode 100644 src/Model/QuickPayPaymentOperation.php delete mode 100644 src/Model/QuickpayCard.php create mode 100644 src/Operations.php create mode 100644 tests/Action/Api/ConfirmPaymentActionTest.php create mode 100644 tests/Action/ConvertPaymentActionTest.php delete mode 100644 tests/Model/QuickpayModelTest.php create mode 100644 tests/OperationsTest.php create mode 100644 tests/StubGetHttpRequestAction.php delete mode 100644 tests/StubHttpClient.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php new file mode 100644 index 0000000..5259164 --- /dev/null +++ b/composer-dependency-analyser.php @@ -0,0 +1,14 @@ +ignoreErrorsOnPackage('php-http/message-factory', [ErrorType::UNUSED_DEPENDENCY]); diff --git a/composer.json b/composer.json index f406c39..3943c80 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "ext-json": "*", "payum/core": "^1.6", "php-http/message-factory": "^1.0", - "psr/http-message": "^1.0" + "setono/quickpay-php-sdk": "^1.0@alpha" }, "require-dev": { "ergebnis/composer-normalize": "^2.52", @@ -24,7 +24,7 @@ "infection/infection": "^0.29.8", "php-http/discovery": "^1.19", "php-http/guzzle7-adapter": "^1.0", - "php-http/message": "^1.16", + "php-http/mock-client": "^1.6", "phpspec/prophecy-phpunit": "^2.5", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", @@ -32,6 +32,7 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^10.5", + "psr/http-message": "^1.0 || ^2.0", "rector/rector": "^2.5", "shipmonk/composer-dependency-analyser": "^1.8", "sylius-labs/coding-standard": "^4.5" @@ -68,6 +69,7 @@ "@analyse" ], "fix-style": "ecs check --fix", + "infection": "infection --threads=max", "phpunit": "phpunit", "rector": "rector", "test": [ diff --git a/src/Action/Api/ConfirmPaymentAction.php b/src/Action/Api/ConfirmPaymentAction.php index 32ce6a7..ca0c975 100644 --- a/src/Action/Api/ConfirmPaymentAction.php +++ b/src/Action/Api/ConfirmPaymentAction.php @@ -12,8 +12,10 @@ use Payum\Core\Exception\RequestNotSupportedException; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Payum\QuickPay\Operations; use Setono\Payum\QuickPay\Request\Api\ConfirmPayment; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Request\Payment\CaptureRequest; class ConfirmPaymentAction implements ActionInterface, GatewayAwareInterface, ApiAwareInterface { @@ -32,24 +34,34 @@ public function execute($request): void throw new LogicException('The payment has not been created'); } - $quickpayPayment = $this->api->getPayment($model, false); - - $latestOperation = $quickpayPayment->getLatestOperation(); + $payment = $this->api->payments()->getById((int) $model['quickpayPaymentId']); + $latestOperation = Operations::latest($payment->operations); if (null === $latestOperation) { throw new LogicException('The payment does not have a `latest operation`'); } - if (1 === (int) $this->api->getOption('auto_capture') && QuickPayPaymentOperation::TYPE_AUTHORIZE === $latestOperation->getType()) { - if ($quickpayPayment->getAuthorizedAmount() === (int) $model['amount']) { - $this->api->capturePayment($quickpayPayment, $model); - } else { - throw new LogicException(sprintf('Authorized amount does not match. Authorized %s expected %s', $quickpayPayment->getAuthorizedAmount(), $model['amount'])); + if ($this->api->isAutoCapture() && OperationType::Authorize === $latestOperation->type()) { + $authorizedAmount = Operations::authorizedAmount($payment->operations); + $expectedAmount = (int) $model['amount']; + + if ($authorizedAmount !== $expectedAmount) { + throw new LogicException(sprintf( + 'Authorized amount does not match. Authorized %s expected %s', + $authorizedAmount, + $expectedAmount, + )); } + + $this->api->payments()->capture( + (int) $model['quickpayPaymentId'], + new CaptureRequest(amount: $expectedAmount), + synchronized: $this->api->isSynchronized(), + ); } } - public function supports($request) + public function supports($request): bool { return $request instanceof ConfirmPayment && $request->getModel() instanceof ArrayAccess; } diff --git a/src/Action/AuthorizeAction.php b/src/Action/AuthorizeAction.php index 95bf130..5c38001 100644 --- a/src/Action/AuthorizeAction.php +++ b/src/Action/AuthorizeAction.php @@ -8,6 +8,7 @@ use Payum\Core\Action\ActionInterface; use Payum\Core\ApiAwareInterface; use Payum\Core\Bridge\Spl\ArrayObject; +use Payum\Core\Exception\LogicException; use Payum\Core\Exception\RequestNotSupportedException; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; @@ -16,6 +17,7 @@ use Payum\Core\Security\GenericTokenFactoryAwareInterface; use Payum\Core\Security\GenericTokenFactoryAwareTrait; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Quickpay\Request\Payment\CreateLinkRequest; class AuthorizeAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface, GenericTokenFactoryAwareInterface { @@ -33,19 +35,32 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); if (null !== $token = $request->getToken()) { - // Create callback url + // Build the server-to-server callback (notify) url. $model['callback_url'] = $this->tokenFactory ->createNotifyToken($token->getGatewayName(), $token->getDetails()) ->getTargetUrl(); } - $quickpayPayment = $this->api->getPayment($model); + $model->validateNotEmpty(['continue_url', 'cancel_url', 'callback_url', 'amount']); - // Create payment link - $paymentLink = $this->api->createPaymentLink($quickpayPayment, $model); + $link = $this->api->payments()->createLink((int) $model['quickpayPaymentId'], new CreateLinkRequest( + amount: (int) $model['amount'], + agreementId: $this->api->getAgreementId(), + language: $this->api->getLanguage(), + continueUrl: (string) $model['continue_url'], + cancelUrl: (string) $model['cancel_url'], + callbackUrl: (string) $model['callback_url'], + paymentMethods: $this->api->getPaymentMethods(), + autoCapture: $this->api->isAutoCapture(), + brandingId: $this->api->getBrandingId(), + )); - // Redirect to payment - throw new HttpRedirect($paymentLink->getUrl()); + if (null === $link->url) { + throw new LogicException('QuickPay did not return a payment link url'); + } + + // Redirect the customer to the QuickPay payment window. + throw new HttpRedirect($link->url); } public function supports($request): bool diff --git a/src/Action/CancelAction.php b/src/Action/CancelAction.php index f56af0e..8bdf35d 100644 --- a/src/Action/CancelAction.php +++ b/src/Action/CancelAction.php @@ -8,12 +8,13 @@ use Payum\Core\Action\ActionInterface; use Payum\Core\ApiAwareInterface; use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Exception\Http\HttpException; use Payum\Core\Exception\RequestNotSupportedException; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\Cancel; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Quickpay\Exception\QuickpayException; +use Setono\Quickpay\Exception\ResponseAwareException; class CancelAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface { @@ -29,25 +30,18 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); - $quickpayPayment = $this->api->getPayment($model); - try { - $this->api->cancelPayment($quickpayPayment, $model); - } catch (HttpException $e) { - try { - $data = json_decode((string) $e->getResponse()->getBody(), true, 512, \JSON_THROW_ON_ERROR); - if (!is_array($data) || !isset($data['message']) || !is_string($data['message'])) { - throw $e; - } - - if (stripos($data['message'], 'Transaction in wrong state for this operation') === false) { - throw $e; - } - + $this->api->payments()->cancel((int) $model['quickpayPaymentId'], synchronized: $this->api->isSynchronized()); + } catch (QuickpayException $e) { + // QuickPay rejects cancelling a payment that is already captured/cancelled with a + // "Transaction in wrong state for this operation" error. Treat that as a no-op so a + // cancel is idempotent; rethrow anything else. + if ($e instanceof ResponseAwareException && + false !== stripos((string) $e->getMessageText(), 'Transaction in wrong state for this operation')) { return; - } catch (\Throwable $throwable) { - throw $e; } + + throw $e; } } diff --git a/src/Action/CaptureAction.php b/src/Action/CaptureAction.php index d1b1220..969bb54 100644 --- a/src/Action/CaptureAction.php +++ b/src/Action/CaptureAction.php @@ -15,6 +15,7 @@ use Payum\Core\Security\GenericTokenFactoryAwareInterface; use Payum\Core\Security\GenericTokenFactoryAwareTrait; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Quickpay\Request\Payment\CaptureRequest; class CaptureAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface, GenericTokenFactoryAwareInterface { @@ -31,9 +32,11 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); - $quickpayPayment = $this->api->getPayment($model); - - $this->api->capturePayment($quickpayPayment, $model); + $this->api->payments()->capture( + (int) $model['quickpayPaymentId'], + new CaptureRequest(amount: (int) $model['amount']), + synchronized: $this->api->isSynchronized(), + ); } public function supports($request): bool diff --git a/src/Action/ConvertPaymentAction.php b/src/Action/ConvertPaymentAction.php index f85b550..a26f529 100644 --- a/src/Action/ConvertPaymentAction.php +++ b/src/Action/ConvertPaymentAction.php @@ -13,7 +13,7 @@ use Payum\Core\Model\PaymentInterface; use Payum\Core\Request\Convert; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; -use Setono\Payum\QuickPay\Model\QuickPayPayment; +use Setono\Quickpay\Request\Payment\CreatePaymentRequest; class ConvertPaymentAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface { @@ -30,17 +30,25 @@ public function execute($request): void /** @var PaymentInterface $paymentModel */ $paymentModel = $request->getSource(); - $token = $request->getToken(); - $details = ArrayObject::ensureArrayObject($paymentModel->getDetails()); $details['amount'] = $paymentModel->getTotalAmount(); - $details['payment'] = $paymentModel; + $details['currency'] = $paymentModel->getCurrencyCode(); + + // Only scalars are stored in the details so they survive serialization by the consumer. + // `quickpayPaymentId` is the single source of truth; the payment is re-fetched when needed. + if (!isset($details['quickpayPaymentId'])) { + $payment = $this->api->payments()->create(new CreatePaymentRequest( + orderId: $this->api->getOrderPrefix() . $paymentModel->getNumber(), + currency: $paymentModel->getCurrencyCode(), + )); + + $details['quickpayPaymentId'] = $payment->id; + $details['order_id'] = $payment->orderId; + } - if (!isset($details['quickpayPayment']) || !$details['quickpayPayment'] instanceof QuickPayPayment) { - $details['quickpayPayment'] = $this->api->getPayment($details); - $details['quickpayPaymentId'] = $details['quickpayPayment']->getId(); + if (null !== $token = $request->getToken()) { + $details['continue_url'] = $details['cancel_url'] = $token->getAfterUrl(); } - $details['continue_url'] = $details['cancel_url'] = $token->getAfterUrl(); $request->setResult((array) $details); } diff --git a/src/Action/NotifyAction.php b/src/Action/NotifyAction.php index d829d4a..09a1b6b 100644 --- a/src/Action/NotifyAction.php +++ b/src/Action/NotifyAction.php @@ -11,12 +11,19 @@ use Payum\Core\Exception\RequestNotSupportedException; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; +use Payum\Core\Reply\HttpResponse; +use Payum\Core\Request\GetHttpRequest; use Payum\Core\Request\Notify; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; use Setono\Payum\QuickPay\Request\Api\ConfirmPayment; +use Setono\Quickpay\Callback\CallbackValidator; /** - * Handles callback from QuickPay. + * Handles the server-to-server callback (webhook) from QuickPay. + * + * The raw request body is verified against the `QuickPay-Checksum-Sha256` HMAC signature (computed + * with the account private key) before the callback is acted upon, so forged or tampered callbacks + * are rejected with a 400 response. */ class NotifyAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface { @@ -32,6 +39,15 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); + $httpRequest = new GetHttpRequest(); + $this->gateway->execute($httpRequest); + + $checksum = $this->extractChecksum($httpRequest); + + if ('' === $checksum || !$this->api->createCallbackValidator()->isValid($httpRequest->content, $checksum)) { + throw new HttpResponse('Invalid checksum', 400); + } + $this->gateway->execute(new ConfirmPayment($model)); } @@ -39,4 +55,33 @@ public function supports($request): bool { return $request instanceof Notify && $request->getModel() instanceof ArrayAccess; } + + /** + * Reads the QuickPay checksum header off the (Symfony-bridge populated) `headers` property of the + * http request. The lookup is case-insensitive because Payum's bridges normalize header casing + * inconsistently, and the value may be a list (PSR/Symfony) or a plain string. + */ + private function extractChecksum(GetHttpRequest $httpRequest): string + { + // `headers` is a dynamic property set by Payum's Symfony bridge (Payum\Core\Bridge\Symfony\ + // Action\GetHttpRequestAction), so it is read defensively through get_object_vars(). + $headers = get_object_vars($httpRequest)['headers'] ?? []; + if (!is_array($headers)) { + return ''; + } + + foreach ($headers as $name => $value) { + if (0 !== strcasecmp((string) $name, CallbackValidator::CHECKSUM_HEADER)) { + continue; + } + + if (is_array($value)) { + $value = reset($value); + } + + return is_scalar($value) ? (string) $value : ''; + } + + return ''; + } } diff --git a/src/Action/RefundAction.php b/src/Action/RefundAction.php index 2b5ec2c..4f0cc52 100644 --- a/src/Action/RefundAction.php +++ b/src/Action/RefundAction.php @@ -13,6 +13,7 @@ use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\Refund; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Quickpay\Request\Payment\RefundRequest; class RefundAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface { @@ -28,9 +29,11 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); - $quickpayPayment = $this->api->getPayment($model); - - $this->api->refundPayment($quickpayPayment, $model); + $this->api->payments()->refund( + (int) $model['quickpayPaymentId'], + new RefundRequest(amount: (int) $model['amount']), + synchronized: $this->api->isSynchronized(), + ); } public function supports($request): bool diff --git a/src/Action/StatusAction.php b/src/Action/StatusAction.php index f949b0e..a85508d 100644 --- a/src/Action/StatusAction.php +++ b/src/Action/StatusAction.php @@ -13,8 +13,9 @@ use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\GetStatusInterface; use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Payum\QuickPay\Operations; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; class StatusAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface { @@ -30,43 +31,47 @@ public function execute($request): void $model = ArrayObject::ensureArrayObject($request->getModel()); - if (!$model->offsetExists('quickpayPaymentId') && !$model->offsetExists('quickpayPayment')) { + if (!$model->offsetExists('quickpayPaymentId')) { $request->markNew(); return; } - $quickpayPayment = $this->api->getPayment($model); - $latestOperation = $quickpayPayment->getLatestOperation(); + $payment = $this->api->payments()->getById((int) $model['quickpayPaymentId']); + $operations = $payment->operations; - switch ($quickpayPayment->getState()) { - case QuickPayPayment::STATE_INITIAL: + switch ($payment->state()) { + case PaymentState::Initial: $request->markNew(); break; - case QuickPayPayment::STATE_NEW: - if ($this->isOperationApproved($latestOperation, QuickPayPaymentOperation::TYPE_AUTHORIZE)) { + case PaymentState::New: + if (Operations::isLatestApproved($operations, OperationType::Authorize)) { $request->markAuthorized(); } else { $request->markFailed(); } break; - case QuickPayPayment::STATE_PENDING: + case PaymentState::Pending: $request->markPending(); break; - case QuickPayPayment::STATE_REJECTED: + case PaymentState::Rejected: + case PaymentState::Invalid: $request->markFailed(); break; - case QuickPayPayment::STATE_PROCESSED: - if ($this->isOperationApproved($latestOperation, QuickPayPaymentOperation::TYPE_CAPTURE)) { + case PaymentState::Processed: + $latestOperation = Operations::latest($operations); + if (Operations::isApprovedOfType($latestOperation, OperationType::Capture)) { $request->markCaptured(); - } elseif ($this->isOperationApproved($latestOperation, QuickPayPaymentOperation::TYPE_REFUND)) { + } elseif (Operations::isApprovedOfType($latestOperation, OperationType::Refund)) { $request->markRefunded(); - } elseif ($this->isOperationApproved($latestOperation, QuickPayPaymentOperation::TYPE_CANCEL)) { + } elseif (Operations::isApprovedOfType($latestOperation, OperationType::Cancel)) { $request->markCanceled(); + } else { + $request->markUnknown(); } break; @@ -79,13 +84,4 @@ public function supports($request): bool { return $request instanceof GetStatusInterface && $request->getModel() instanceof ArrayAccess; } - - private function isOperationApproved(?QuickPayPaymentOperation $operation, string $state): bool - { - if (null === $operation) { - return false; - } - - return $operation->getType() === $state && $operation->isApproved(); - } } diff --git a/src/Api.php b/src/Api.php index 9646440..83eaf4c 100644 --- a/src/Api.php +++ b/src/Api.php @@ -4,254 +4,84 @@ namespace Setono\Payum\QuickPay; -use Http\Message\MessageFactory; -use JsonException; -use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Exception\Http\HttpException; -use Payum\Core\Exception\LogicException; -use Payum\Core\HttpClientInterface; -use Payum\Core\Model\Payment; -use Psr\Http\Message\ResponseInterface; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentLink; - -class Api +use Setono\Quickpay\Callback\CallbackValidator; +use Setono\Quickpay\Client\ClientInterface; +use Setono\Quickpay\Client\Endpoint\PaymentsEndpoint; + +/** + * Immutable value object injected as Payum's `payum.api`. It wraps the configured QuickPay SDK + * client together with the gateway behavior options the actions need. + * + * It performs no HTTP itself — the SDK client does (Basic auth, the mandatory `Accept-Version: v10` + * header, status-code → exception mapping and (de)serialization). + */ +final class Api { - public const VERSION = 'v10'; - - protected HttpClientInterface $client; - - protected MessageFactory $messageFactory; - - /** @var ArrayObject|array */ - protected $options = []; - - /** - * @param array $options - */ - public function __construct(array $options, HttpClientInterface $client, MessageFactory $messageFactory) - { - $options = ArrayObject::ensureArrayObject($options); - $options->defaults($this->options); - $options->validateNotEmpty([ - 'apikey', - 'merchant', - 'agreement', - 'privatekey', - 'language', - ]); - - $this->options = $options; - $this->client = $client; - $this->messageFactory = $messageFactory; - } - - public function getPayment(ArrayObject $params, bool $create = true): QuickPayPayment - { - $params = ArrayObject::ensureArrayObject($params); - - if (isset($params['quickpayPayment']) && $params['quickpayPayment'] instanceof QuickPayPayment) { - return $params['quickpayPayment']; - } - - if (\is_int($params['quickpayPaymentId'])) { - $url = 'payments/' . $params['quickpayPaymentId']; - $response = $this->doRequest('GET', $url); - } else { - /** @var Payment $paymentModel */ - $paymentModel = $params['payment']; - if ($create) { -// // You should specify this parameters in order to use Klarna -// ArrayObject::validatedKeysSet([ -// 'shipping_address', -// 'invoice_address', -// 'shipping', -// 'basket', -// ]); - - $url = 'payments'; - $response = $this->doRequest('POST', $url, $params->getArrayCopy() + [ - 'order_id' => $this->getOption('order_prefix') . $paymentModel->getNumber(), - 'currency' => $paymentModel->getCurrencyCode(), - ]); - } else { - throw new LogicException('Payment does not exist'); - } - } - - return QuickPayPayment::createFromResponse($response, $url); - } - - /** - * @return QuickPayPayment[] - */ - public function getPayments(ArrayObject $params): array - { - $params = ArrayObject::ensureArrayObject($params); - - $url = 'payments?' . http_build_query($params->getArrayCopy()); - - $response = $this->doRequest('GET', $url); - $body = (string) $response->getBody(); - - try { - $payments = json_decode($body, false, 512, \JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new JsonException(sprintf( - 'Could not json_decode input. Error was: %s. Request was: %s. Input was: %s', - $e->getMessage(), - $url, - $body === '' ? 'Empty' : $body, - ), $e->getCode(), $e); - } - if (null === $payments) { - throw new HttpException('Invalid response'); - } - - $return = []; - foreach ($payments as $payment) { - $return[] = QuickPayPayment::createFromObject($payment); - } - - return $return; + public function __construct( + private readonly ClientInterface $client, + private readonly string $privateKey, + private readonly string $orderPrefix = '', + private readonly string $paymentMethods = '', + private readonly string $language = 'en', + private readonly bool $autoCapture = false, + private readonly bool $synchronized = false, + private readonly ?int $agreementId = null, + private readonly ?int $brandingId = null, + ) { } - public function createPaymentLink(QuickPayPayment $payment, ArrayObject $params): QuickPayPaymentLink + public function getClient(): ClientInterface { - $params = ArrayObject::ensureArrayObject($params); - $params->validateNotEmpty([ - 'continue_url', 'cancel_url', 'callback_url', 'amount', - ]); - - $response = $this->doRequest('PUT', 'payments/' . $payment->getId() . '/link', $params->getArrayCopy() + [ - 'payment_methods' => $this->options['payment_methods'], - 'language' => $this->options['language'], - 'auto_capture' => $this->options['auto_capture'], - ]); - - return QuickPayPaymentLink::createFromResponse($response); + return $this->client; } - public function authorizePayment(QuickPayPayment $payment, ArrayObject $params): QuickPayPayment + public function payments(): PaymentsEndpoint { - $params = ArrayObject::ensureArrayObject($params); - $params->validateNotEmpty([ - 'card', 'amount', - ]); - - $url = 'payments/' . $payment->getId() . '/authorize'; - $response = $this->doRequest('POST', $url, $params->getArrayCopy()); - - return QuickPayPayment::createFromResponse($response, $url); + return $this->client->payments(); } - public function capturePayment(QuickPayPayment $payment, ArrayObject $params): QuickPayPayment + public function getPrivateKey(): string { - $params = ArrayObject::ensureArrayObject($params); - $params->validateNotEmpty([ - 'amount', - ]); - - $url = 'payments/' . $payment->getId() . '/capture'; - $response = $this->doRequest('POST', $url, $params->getArrayCopy()); - - return QuickPayPayment::createFromResponse($response, $url); + return $this->privateKey; } - public function refundPayment(QuickPayPayment $payment, ArrayObject $params): QuickPayPayment + public function getOrderPrefix(): string { - $params = ArrayObject::ensureArrayObject($params); - $params->validateNotEmpty([ - 'amount', - ]); - - $url = 'payments/' . $payment->getId() . '/refund'; - $response = $this->doRequest('POST', $url, $params->getArrayCopy()); - - return QuickPayPayment::createFromResponse($response, $url); + return $this->orderPrefix; } - public function cancelPayment(QuickPayPayment $payment, ArrayObject $params): QuickPayPayment + public function getPaymentMethods(): ?string { - $params = ArrayObject::ensureArrayObject($params); - $params->validateNotEmpty([ - 'amount', - ]); - - $url = 'payments/' . $payment->getId() . '/cancel'; - $response = $this->doRequest('POST', $url, $params->getArrayCopy()); - - return QuickPayPayment::createFromResponse($response, $url); + return '' !== $this->paymentMethods ? $this->paymentMethods : null; } - public function validateChecksum(string $content, string $checksum): bool + public function getLanguage(): string { - return $checksum === self::checksum($content, (string) $this->getOption('privatekey')); + return $this->language; } - /** - * @param array $params - */ - protected function doRequest(string $method, string $path, array $params = []): ResponseInterface + public function isAutoCapture(): bool { - $headers = [ - 'Authorization' => 'Basic ' . base64_encode(':' . $this->getOption('apikey')), - 'Accept-Version' => self::VERSION, - 'Content-Type' => 'application/json', - ]; - - $encodedParams = json_encode($params, \JSON_THROW_ON_ERROR); - - $request = $this->messageFactory->createRequest( - $method, - $this->getApiEndpoint() . '/' . ltrim($path, '/'), - $headers, - $encodedParams, - ); - - $response = $this->client->send($request); - $statusCode = $response->getStatusCode(); - - if ($statusCode < 200 || $statusCode > 299) { - throw HttpException::factory($request, $response); - } - - self::assertValidResponse($response, (string) $this->getOption('privatekey')); - - return $response; + return $this->autoCapture; } - protected function getApiEndpoint(): string + public function isSynchronized(): bool { - return 'https://api.quickpay.net'; + return $this->synchronized; } - /** - * Generates a checksum based on request/response body. - */ - public static function checksum(string $data, string $privateKey): string + public function getAgreementId(): ?int { - return hash_hmac('sha256', $data, $privateKey); + return $this->agreementId; } - public static function assertValidResponse(ResponseInterface $response, string $privateKey): void + public function getBrandingId(): ?int { - if ($response->hasHeader('QuickPay-Checksum-Sha256')) { - $checksum = self::checksum((string) $response->getBody(), $privateKey); - $quickpayChecksum = $response->getHeaderLine('QuickPay-Checksum-Sha256'); - if ($checksum !== $quickpayChecksum) { - throw new LogicException('Invalid checksum'); - } - } + return $this->brandingId; } - /** - * @param string|mixed $default - * - * @return string|mixed - */ - public function getOption(string $option, $default = '') + public function createCallbackValidator(): CallbackValidator { - return $this->options[$option] ?? $default; + return new CallbackValidator($this->privateKey); } } diff --git a/src/Model/QuickPayModel.php b/src/Model/QuickPayModel.php deleted file mode 100644 index ceed9e9..0000000 --- a/src/Model/QuickPayModel.php +++ /dev/null @@ -1,17 +0,0 @@ - $value) { - if (property_exists($this, $key)) { - (new \ReflectionProperty($this, $key))->setValue($this, $value); - } - } - } -} diff --git a/src/Model/QuickPayPayment.php b/src/Model/QuickPayPayment.php deleted file mode 100644 index 27fd3bc..0000000 --- a/src/Model/QuickPayPayment.php +++ /dev/null @@ -1,120 +0,0 @@ - */ - protected array $operations; - - protected ?int $fee; - - public static function createFromResponse(ResponseInterface $response, ?string $url = null): self - { - $body = (string) $response->getBody(); - - try { - $data = json_decode($body, false, 512, \JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new JsonException(sprintf( - 'Could not json_decode input. Error was: %s. Called in: %s. Request: %s. Input was: %s', - $e->getMessage(), - __METHOD__, - $url ?? 'Not available', - $body === '' ? 'Empty' : $body, - ), $e->getCode(), $e); - } - - return new self($data); - } - - public static function createFromObject(object $data): self - { - return new self($data); - } - - public function getId(): int - { - return $this->id; - } - - public function getOrderId(): string - { - return $this->order_id; - } - - public function getCurrency(): string - { - return $this->currency; - } - - /** - * @return string initial, pending, new, rejected, processed - */ - public function getState(): string - { - return $this->state; - } - - public function getFee(): ?int - { - return $this->fee; - } - - public function getAuthorizedAmount(): int - { - $operations = array_reverse($this->getOperations()); - - foreach ($operations as $operation) { - if (QuickPayPaymentOperation::TYPE_AUTHORIZE === $operation->getType() && $operation->isApproved()) { - return $operation->getAmount(); - } - } - - return 0; - } - - /** - * @return QuickPayPaymentOperation[] - */ - public function getOperations(): array - { - if (\count($this->operations) > 0) { - return QuickPayPaymentOperation::createFromArray($this->operations); - } - - return []; - } - - public function getLatestOperation(): ?QuickPayPaymentOperation - { - if (\count($this->operations) > 0) { - return QuickPayPaymentOperation::createFromObject(end($this->operations)); - } - - return null; - } -} diff --git a/src/Model/QuickPayPaymentLink.php b/src/Model/QuickPayPaymentLink.php deleted file mode 100644 index 0eb0143..0000000 --- a/src/Model/QuickPayPaymentLink.php +++ /dev/null @@ -1,36 +0,0 @@ -getBody(); - - try { - $data = json_decode($body, false, 512, \JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new JsonException(sprintf( - 'Could not json_decode input. Error was: %s. Called in: %s. Input was: %s', - $e->getMessage(), - __METHOD__, - $body === '' ? 'Empty' : $body, - ), $e->getCode(), $e); - } - - return new self($data); - } - - public function getUrl(): string - { - return $this->url; - } -} diff --git a/src/Model/QuickPayPaymentOperation.php b/src/Model/QuickPayPaymentOperation.php deleted file mode 100644 index 5a548f4..0000000 --- a/src/Model/QuickPayPaymentOperation.php +++ /dev/null @@ -1,73 +0,0 @@ - $operations - * - * @return list - */ - public static function createFromArray(array $operations): array - { - $ret = []; - foreach ($operations as $operation) { - $ret[] = self::createFromObject($operation); - } - - return $ret; - } - - public function getId(): int - { - return $this->id; - } - - public function getType(): string - { - return $this->type; - } - - public function getAmount(): int - { - return (int) $this->amount; - } - - public function getStatusCode(): int - { - return (int) $this->qp_status_code; - } - - public function isApproved(): bool - { - return self::STATUS_CODE_APPROVED === $this->getStatusCode(); - } -} diff --git a/src/Model/QuickpayCard.php b/src/Model/QuickpayCard.php deleted file mode 100644 index 11624a5..0000000 --- a/src/Model/QuickpayCard.php +++ /dev/null @@ -1,57 +0,0 @@ - $data - */ - public static function createFromArray(array $data): self - { - return new self((object) $data); - } - - public function getNumber(): int - { - return $this->number; - } - - /** - * Returns the expiration formatted as YYMM. - */ - public function getExpiration(): string - { - return $this->expiration; - } - - public function getCvd(): int - { - return $this->cvd; - } - - /** - * @return array{number: int, expiration: string, cvd: int} - */ - public function toArray(): array - { - return [ - 'number' => $this->getNumber(), - 'expiration' => $this->getExpiration(), - 'cvd' => $this->getCvd(), - ]; - } - - public function setNumber(int $number): void - { - $this->number = $number; - } -} diff --git a/src/Operations.php b/src/Operations.php new file mode 100644 index 0000000..26c4495 --- /dev/null +++ b/src/Operations.php @@ -0,0 +1,73 @@ + $operations + */ + public static function latest(array $operations): ?Operation + { + if ([] === $operations) { + return null; + } + + return $operations[array_key_last($operations)]; + } + + public static function isApproved(Operation $operation): bool + { + return self::APPROVED_STATUS_CODE === $operation->qpStatusCode; + } + + public static function isApprovedOfType(?Operation $operation, OperationType $type): bool + { + return null !== $operation && $type === $operation->type() && self::isApproved($operation); + } + + /** + * @param list $operations + */ + public static function isLatestApproved(array $operations, OperationType $type): bool + { + return self::isApprovedOfType(self::latest($operations), $type); + } + + /** + * The amount of the most recent approved authorize operation, or 0 if there is none. + * + * @param list $operations + */ + public static function authorizedAmount(array $operations): int + { + foreach (array_reverse($operations) as $operation) { + if (OperationType::Authorize === $operation->type() && self::isApproved($operation)) { + return (int) $operation->amount; + } + } + + return 0; + } +} diff --git a/src/QuickPayGatewayFactory.php b/src/QuickPayGatewayFactory.php index 7f32db4..54fabc7 100644 --- a/src/QuickPayGatewayFactory.php +++ b/src/QuickPayGatewayFactory.php @@ -5,6 +5,7 @@ namespace Setono\Payum\QuickPay; use Payum\Core\Bridge\Spl\ArrayObject; +use Payum\Core\Exception\LogicException; use Payum\Core\GatewayFactory; use Setono\Payum\QuickPay\Action\Api\ConfirmPaymentAction; use Setono\Payum\QuickPay\Action\AuthorizeAction; @@ -14,6 +15,8 @@ use Setono\Payum\QuickPay\Action\NotifyAction; use Setono\Payum\QuickPay\Action\RefundAction; use Setono\Payum\QuickPay\Action\StatusAction; +use Setono\Quickpay\Client\Client; +use Setono\Quickpay\Client\ClientInterface; class QuickPayGatewayFactory extends GatewayFactory { @@ -35,28 +38,48 @@ protected function populateConfig(ArrayObject $config): void if (!$config->offsetExists('payum.api')) { $config['payum.default_options'] = [ 'apikey' => '', - 'merchant' => '', - 'agreement' => '', 'privatekey' => '', 'payment_methods' => '', 'auto_capture' => 0, 'order_prefix' => '', - 'syncronized' => false, 'language' => 'en', + 'synchronized' => false, + // optional: maps to CreateLinkRequest::agreementId + 'agreement' => '', + // optional: maps to the QuickPay branding id on the payment link + 'branding_id' => '', ]; $config->defaults($config['payum.default_options']); $config['payum.required_options'] = [ 'apikey', - 'merchant', - 'agreement', 'privatekey', - 'language', ]; $config['payum.api'] = static function (ArrayObject $config): Api { $config->validateNotEmpty($config['payum.required_options']); - return new Api((array) $config, $config['payum.http_client'], $config['httplug.message_factory']); + // Consumers (and the test suite) may inject a preconfigured SDK client — e.g. one + // built around a specific PSR-18 client — via the "quickpay.client" option. + // Otherwise we build one from the api key and let php-http/discovery find a client. + $client = $config['quickpay.client'] ?? new Client((string) $config['apikey']); + if (!$client instanceof ClientInterface) { + throw new LogicException(sprintf( + 'The "quickpay.client" option must be an instance of %s', + ClientInterface::class, + )); + } + + return new Api( + client: $client, + privateKey: (string) $config['privatekey'], + orderPrefix: (string) $config['order_prefix'], + paymentMethods: (string) $config['payment_methods'], + language: (string) $config['language'], + autoCapture: (bool) (int) $config['auto_capture'], + synchronized: (bool) $config['synchronized'], + agreementId: '' !== (string) $config['agreement'] ? (int) $config['agreement'] : null, + brandingId: '' !== (string) $config['branding_id'] ? (int) $config['branding_id'] : null, + ); }; } } diff --git a/tests/Action/Api/ConfirmPaymentActionTest.php b/tests/Action/Api/ConfirmPaymentActionTest.php new file mode 100644 index 0000000..dd631df --- /dev/null +++ b/tests/Action/Api/ConfirmPaymentActionTest.php @@ -0,0 +1,139 @@ +action($this->api); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The payment has not been created'); + + $action->execute(new ConfirmPayment(new ArrayObject(['amount' => 100]))); + } + + /** + * @test + */ + public function shouldThrowWhenThereIsNoLatestOperation(): void + { + $this->queuePayment(['state' => PaymentState::Initial->value, 'operations' => []]); + + $action = $this->action($this->api); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('latest operation'); + + $action->execute(new ConfirmPayment(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]))); + } + + /** + * @test + */ + public function shouldCaptureWhenAutoCaptureAndAmountMatches(): void + { + $this->queuePayment([ + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, amount: 100)], + ]); + $this->queuePayment([ + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture)], + ]); + + $action = $this->action($this->api); + $action->execute(new ConfirmPayment(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]))); + + $requests = $this->getRequests(); + self::assertCount(2, $requests); + $this->assertRequest($requests[0], 'GET', '#/payments/1001$#'); + $this->assertRequest($requests[1], 'POST', '#/payments/1001/capture$#'); + self::assertSame(100, $this->decodeBody($requests[1])['amount']); + } + + /** + * @test + */ + public function shouldThrowWhenAuthorizedAmountDoesNotMatch(): void + { + $this->queuePayment([ + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, amount: 100)], + ]); + + $action = $this->action($this->api); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Authorized amount does not match'); + + $action->execute(new ConfirmPayment(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 99]))); + } + + /** + * @test + */ + public function shouldNotCaptureWhenAutoCaptureDisabled(): void + { + $api = new Api( + client: new Client('test-apikey', $this->httpClient), + privateKey: 'test-privatekey', + autoCapture: false, + ); + + $this->queuePayment([ + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, amount: 100)], + ]); + + $action = $this->action($api); + $action->execute(new ConfirmPayment(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]))); + + // Only the reload happened, no capture. + self::assertCount(1, $this->getRequests()); + } + + /** + * @test + */ + public function shouldNotCaptureWhenLatestOperationIsNotAnAuthorize(): void + { + $this->queuePayment([ + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture, amount: 100)], + ]); + + $action = $this->action($this->api); + $action->execute(new ConfirmPayment(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]))); + + self::assertCount(1, $this->getRequests()); + } + + private function action(Api $api): ConfirmPaymentAction + { + $action = new ConfirmPaymentAction(); + $action->setApi($api); + $action->setGateway($this->gateway); + + return $action; + } +} diff --git a/tests/Action/AuthorizeActionTest.php b/tests/Action/AuthorizeActionTest.php index d5e18a9..617d67d 100644 --- a/tests/Action/AuthorizeActionTest.php +++ b/tests/Action/AuthorizeActionTest.php @@ -8,15 +8,11 @@ use Payum\Core\Model\Token; use Payum\Core\Reply\HttpRedirect; use Payum\Core\Request\Authorize; -use Payum\Core\Request\Convert; use Payum\Core\Security\GenericTokenFactoryAwareInterface; use Payum\Core\Security\GenericTokenFactoryInterface; use ReflectionClass; use ReflectionException; use Setono\Payum\QuickPay\Action\AuthorizeAction; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; class AuthorizeActionTest extends ActionTestAbstract { @@ -39,26 +35,19 @@ public function shouldImplementGenericTokenFactoryAwareInterface(): void /** * @test */ - public function shouldRedirectToPaymentLink(): void + public function shouldCreatePaymentLinkAndRedirectToIt(): void { - $payment = $this->createPayment(); - $token = new Token(); $token->setTargetUrl('theCallbackUrl'); $token->setAfterUrl('theContinueUrl'); $token->setGatewayName('quickpay'); - $convert = new Convert($payment, 'array', $token); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - - $convertPaymentAction = new ConvertPaymentAction(); - $convertPaymentAction->setGateway($this->gateway); - $convertPaymentAction->setApi($this->api); - $convertPaymentAction->execute($convert); - - $payment->setDetails($convert->getResult()); - $details = ArrayObject::ensureArrayObject($payment->getDetails()); + $details = new ArrayObject([ + 'quickpayPaymentId' => 1001, + 'amount' => 100, + 'continue_url' => 'theContinueUrl', + 'cancel_url' => 'theContinueUrl', + ]); $token->setDetails($details); /** @var Authorize $authorize */ @@ -68,46 +57,36 @@ public function shouldRedirectToPaymentLink(): void $tokenFactory = $this->prophesize(GenericTokenFactoryInterface::class); $tokenFactory->createNotifyToken('quickpay', $details)->shouldBeCalledOnce()->willReturn($token); - /** @var AuthorizeAction $action */ - $action = new $this->actionClass(); + $action = new AuthorizeAction(); $action->setGateway($this->gateway); $action->setApi($this->api); $action->setGenericTokenFactory($tokenFactory->reveal()); - // Executing the action creates the payment link and redirects to it. $this->queueResponse('{"url":"https://payment.quickpay.net/payments/1001/payment-window"}'); try { $action->execute($authorize); + self::fail('An HttpRedirect reply should have been thrown'); } catch (HttpRedirect $redirect) { - self::assertStringStartsWith('https://payment.quickpay.net/payments/', $redirect->getUrl()); + self::assertSame('https://payment.quickpay.net/payments/1001/payment-window', $redirect->getUrl()); } - // Authorize payment with test card. - $details['card'] = $this->getTestCard()->toArray(); - $details['acquirer'] = 'clearhaus'; - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], - ]); - $quickpayPayment = $this->api->authorizePayment($details['quickpayPayment'], $details); - - // Validate that we received the payment from the operation. - self::assertEquals($details['quickpayPayment']->getId(), $quickpayPayment->getId()); - - // Reload payment to get the status of the authorize operation. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['quickpayPaymentId' => $quickpayPayment->getId()])); - - // Validate authorize operation. - $latestOperation = $quickpayPayment->getLatestOperation(); - self::assertEquals(QuickPayPaymentOperation::TYPE_AUTHORIZE, $latestOperation->getType()); - self::assertEquals(QuickPayPaymentOperation::STATUS_CODE_APPROVED, $latestOperation->getStatusCode()); - self::assertEquals($details['amount'], $latestOperation->getAmount()); + // The callback url is built from the notify token. + self::assertSame('theCallbackUrl', $details['callback_url']); + + // The link request shape. + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'PUT', '#/payments/1001/link$#'); + + $body = $this->decodeBody($requests[0]); + self::assertSame(100, $body['amount']); + self::assertSame('theContinueUrl', $body['continue_url']); + self::assertSame('theContinueUrl', $body['cancel_url']); + self::assertSame('theCallbackUrl', $body['callback_url']); + self::assertSame('en', $body['language']); + self::assertSame('visa', $body['payment_methods']); + self::assertTrue($body['auto_capture']); + self::assertSame(266017, $body['agreement_id']); } } diff --git a/tests/Action/CancelActionTest.php b/tests/Action/CancelActionTest.php index 461a917..7e3fb4e 100644 --- a/tests/Action/CancelActionTest.php +++ b/tests/Action/CancelActionTest.php @@ -5,13 +5,11 @@ namespace Setono\Payum\QuickPay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Model\Token; use Payum\Core\Request\Cancel; -use Payum\Core\Request\Convert; use Setono\Payum\QuickPay\Action\CancelAction; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; +use Setono\Quickpay\Exception\ValidationException; class CancelActionTest extends ActionTestAbstract { @@ -24,61 +22,69 @@ class CancelActionTest extends ActionTestAbstract */ public function shouldCancelPayment(): void { - $payment = $this->createPayment(); + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); - $token = new Token(); - $token->setTargetUrl('theCallbackUrl'); - $token->setAfterUrl('theContinueUrl'); - $token->setGatewayName('quickpay'); + /** @var Cancel $cancel */ + $cancel = new $this->requestClass($details); - $convert = new Convert($payment, 'array', $token); + $action = new CancelAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); + $this->queuePayment([ + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Cancel)], + ]); - $convertPaymentAction = new ConvertPaymentAction(); - $convertPaymentAction->setGateway($this->gateway); - $convertPaymentAction->setApi($this->api); - $convertPaymentAction->execute($convert); + $action->execute($cancel); - $payment->setDetails($convert->getResult()); - $details = ArrayObject::ensureArrayObject($payment->getDetails()); - $token->setDetails($details); + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'POST', '#/payments/1001/cancel$#'); + // Cancel takes no body. + self::assertSame('', (string) $requests[0]->getBody()); + } - // Authorize payment with test card. - $details['card'] = $this->getTestCard()->toArray(); - $details['acquirer'] = 'clearhaus'; - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], - ]); - $quickpayPayment = $this->api->authorizePayment($details['quickpayPayment'], $details); - self::assertEquals(QuickPayPaymentOperation::TYPE_AUTHORIZE, $quickpayPayment->getLatestOperation()->getType()); + /** + * @test + */ + public function shouldSwallowWrongStateError(): void + { + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); /** @var Cancel $cancel */ - $cancel = new $this->requestClass($token); - $cancel->setModel($details); + $cancel = new $this->requestClass($details); - /** @var CancelAction $action */ - $action = new $this->actionClass(); + $action = new CancelAction(); $action->setGateway($this->gateway); $action->setApi($this->api); - // The cancel operation itself. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CANCEL)], - ]); + // QuickPay reports an already finalized payment as a wrong-state error; the action treats it + // as a no-op so cancelling is idempotent. + $this->queueResponse('{"message":"Transaction in wrong state for this operation"}', 400); + $action->execute($cancel); - // Reload to assert the cancel operation. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CANCEL)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['quickpayPaymentId' => $details['quickpayPayment']->getId()])); - self::assertEquals(QuickPayPaymentOperation::TYPE_CANCEL, $quickpayPayment->getLatestOperation()->getType()); + self::assertCount(1, $this->getRequests()); + } + + /** + * @test + */ + public function shouldRethrowOtherErrors(): void + { + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); + + /** @var Cancel $cancel */ + $cancel = new $this->requestClass($details); + + $action = new CancelAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + + $this->queueResponse('{"message":"Some other error"}', 400); + + $this->expectException(ValidationException::class); + $action->execute($cancel); } } diff --git a/tests/Action/CaptureActionTest.php b/tests/Action/CaptureActionTest.php index 98874ab..7f5950e 100644 --- a/tests/Action/CaptureActionTest.php +++ b/tests/Action/CaptureActionTest.php @@ -5,17 +5,14 @@ namespace Setono\Payum\QuickPay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Exception\Http\HttpException; -use Payum\Core\Model\Token; use Payum\Core\Request\Capture; -use Payum\Core\Request\Convert; use Payum\Core\Security\GenericTokenFactoryAwareInterface; use ReflectionClass; use ReflectionException; use Setono\Payum\QuickPay\Action\CaptureAction; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; +use Setono\Quickpay\Exception\ValidationException; class CaptureActionTest extends ActionTestAbstract { @@ -40,77 +37,46 @@ public function shouldImplementGenericTokenFactoryAwareInterface(): void */ public function shouldCapturePayment(): void { - $payment = $this->createPayment(); + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); - $token = new Token(); - $token->setTargetUrl('theCallbackUrl'); - $token->setAfterUrl('theContinueUrl'); - $token->setGatewayName('quickpay'); + /** @var Capture $capture */ + $capture = new $this->requestClass($details); + + $action = new CaptureAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); - $convert = new Convert($payment, 'array', $token); + $this->queuePayment([ + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture)], + ]); - // ConvertPaymentAction creates the QuickPay payment. - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); + $action->execute($capture); - $convertPaymentAction = new ConvertPaymentAction(); - $convertPaymentAction->setGateway($this->gateway); - $convertPaymentAction->setApi($this->api); - $convertPaymentAction->execute($convert); + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'POST', '#/payments/1001/capture$#'); + self::assertSame(100, $this->decodeBody($requests[0])['amount']); + } - $payment->setDetails($convert->getResult()); - $details = ArrayObject::ensureArrayObject($payment->getDetails()); - $token->setDetails($details); + /** + * @test + */ + public function shouldThrowWhenCapturingNonAuthorizedPayment(): void + { + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); /** @var Capture $capture */ - $capture = new $this->requestClass($token); - $capture->setModel($details); + $capture = new $this->requestClass($details); - /** @var CaptureAction $action */ - $action = new $this->actionClass(); + $action = new CaptureAction(); $action->setGateway($this->gateway); $action->setApi($this->api); - // Capturing before the payment is authorized fails with a validation error. + // Capturing a payment that has not been authorized fails with a validation error (400). $this->queueResponse('{"message":"Validation error in capture"}', 400); - try { - $action->execute($capture); - } catch (HttpException $e) { - $body = json_decode((string) $e->getResponse()->getBody(), false, 512, \JSON_THROW_ON_ERROR); - self::assertStringStartsWith('Validation error', $body->message); - } - - // Authorize the payment with the test card. - $details['card'] = $this->getTestCard()->toArray(); - $details['acquirer'] = 'clearhaus'; - $this->queuePayment([ - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], - ]); - $this->api->authorizePayment($details['quickpayPayment'], $details); - - $quickpayPayment = $this->api->getPayment($details); - self::assertEquals(QuickPayPayment::STATE_INITIAL, $quickpayPayment->getState()); - - // Capture again, this time it succeeds. - $this->queuePayment([ - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], - ]); + $this->expectException(ValidationException::class); $action->execute($capture); - - // Reload the payment to assert the captured state. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - - self::assertEquals(QuickPayPayment::STATE_PROCESSED, $quickpayPayment->getState()); - self::assertEquals(QuickPayPaymentOperation::TYPE_CAPTURE, $quickpayPayment->getLatestOperation()->getType()); - self::assertEquals(QuickPayPaymentOperation::STATUS_CODE_APPROVED, $quickpayPayment->getLatestOperation()->getStatusCode()); } } diff --git a/tests/Action/ConvertPaymentActionTest.php b/tests/Action/ConvertPaymentActionTest.php new file mode 100644 index 0000000..c1a9f86 --- /dev/null +++ b/tests/Action/ConvertPaymentActionTest.php @@ -0,0 +1,123 @@ +supports(new Convert(new Payment(), 'array'))); + self::assertFalse($action->supports(new Convert(new Payment(), 'json'))); + self::assertFalse($action->supports(new Convert(new stdClass(), 'array'))); + self::assertFalse($action->supports('foo')); + } + + /** + * @test + */ + public function shouldCreatePaymentAndStoreOnlyScalarDetails(): void + { + $payment = $this->createPayment(); + + $token = new Token(); + $token->setAfterUrl('theContinueUrl'); + $token->setGatewayName('quickpay'); + + $convert = new Convert($payment, 'array', $token); + + $this->queuePayment(['id' => 1001, 'order_id' => 'ut000000000001']); + + $action = new ConvertPaymentAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + $action->execute($convert); + + /** @var array $result */ + $result = $convert->getResult(); + + self::assertSame(1001, $result['quickpayPaymentId']); + self::assertSame(100, $result['amount']); + self::assertSame('DKK', $result['currency']); + self::assertSame('ut000000000001', $result['order_id']); + self::assertSame('theContinueUrl', $result['continue_url']); + self::assertSame('theContinueUrl', $result['cancel_url']); + + // Only scalars may be persisted — no SDK DTO or Payum model object leaks into the details. + self::assertArrayNotHasKey('quickpayPayment', $result); + self::assertArrayNotHasKey('payment', $result); + foreach ($result as $value) { + self::assertIsNotObject($value, 'Details must not contain objects'); + } + + // The create request carries only order_id + currency. + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'POST', '#/payments$#'); + $body = $this->decodeBody($requests[0]); + self::assertSame('ut000000000001', $body['order_id']); + self::assertSame('DKK', $body['currency']); + self::assertArrayNotHasKey('card', $body); + self::assertArrayNotHasKey('payment', $body); + } + + /** + * @test + */ + public function shouldNotCreateAgainWhenPaymentAlreadyExists(): void + { + $payment = $this->createPayment(); + $payment->setDetails(['quickpayPaymentId' => 555]); + + $token = new Token(); + $token->setAfterUrl('theContinueUrl'); + + $convert = new Convert($payment, 'array', $token); + + $action = new ConvertPaymentAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); + $action->execute($convert); + + /** @var array $result */ + $result = $convert->getResult(); + + self::assertSame(555, $result['quickpayPaymentId']); + self::assertCount(0, $this->getRequests(), 'No create request should be issued'); + } +} diff --git a/tests/Action/NotifyActionTest.php b/tests/Action/NotifyActionTest.php index 4e89bc8..21f75a7 100644 --- a/tests/Action/NotifyActionTest.php +++ b/tests/Action/NotifyActionTest.php @@ -5,11 +5,12 @@ namespace Setono\Payum\QuickPay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Exception\LogicException; +use Payum\Core\Reply\HttpResponse; use Payum\Core\Request\Notify; use Setono\Payum\QuickPay\Action\NotifyAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Quickpay\Callback\CallbackValidator; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; class NotifyActionTest extends ActionTestAbstract { @@ -20,86 +21,81 @@ class NotifyActionTest extends ActionTestAbstract /** * @test */ - public function shouldHandleNotify(): void + public function shouldConfirmPaymentWhenChecksumIsValid(): void { - $payment = $this->createPayment(); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $payment])); + $body = '{"id":1001}'; + $this->httpRequestAction->setHttpRequest($body, [ + CallbackValidator::CHECKSUM_HEADER => hash_hmac('sha256', $body, 'test-privatekey'), + ]); + // ConfirmPayment reloads the payment and, with auto_capture on + matching amount, captures. $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, QuickPayPaymentOperation::STATUS_CODE_APPROVED, 100)], + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, amount: 100)], ]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => $payment->getTotalAmount(), - ])); - - /** @var Notify $notify */ - $notify = new $this->requestClass([]); - $notify->setModel(new ArrayObject([])); - - /** @var NotifyAction $action */ - $action = new $this->actionClass(); + $this->queuePayment([ + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture)], + ]); + + $action = new NotifyAction(); $action->setGateway($this->gateway); $action->setApi($this->api); - // No payment id in the model -> ConfirmPayment fails (no HTTP call). - try { - $action->execute($notify); - } catch (LogicException $le) { - self::assertEquals('The payment has not been created', $le->getMessage()); - } + $action->execute($this->notify()); - // Wrong amount -> the authorized amount does not match (ConfirmPayment reloads the payment). - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, QuickPayPaymentOperation::STATUS_CODE_APPROVED, 100)], + $requests = $this->getRequests(); + self::assertCount(2, $requests); + $this->assertRequest($requests[0], 'GET', '#/payments/1001$#'); + $this->assertRequest($requests[1], 'POST', '#/payments/1001/capture$#'); + } + + /** + * @test + */ + public function shouldRejectInvalidChecksum(): void + { + $this->httpRequestAction->setHttpRequest('{"id":1001}', [ + CallbackValidator::CHECKSUM_HEADER => 'an-invalid-checksum', ]); - $notify->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - 'amount' => $payment->getTotalAmount() - 1, - ])); + + $action = new NotifyAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); try { - $action->execute($notify); - } catch (LogicException $le) { - self::assertStringStartsWith('Authorized amount does not match', $le->getMessage()); + $action->execute($this->notify()); + self::fail('An HttpResponse reply should have been thrown'); + } catch (HttpResponse $reply) { + self::assertSame(400, $reply->getStatusCode()); } - // Correct amount -> the payment is captured (reload, then capture). - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, QuickPayPaymentOperation::STATUS_CODE_APPROVED, 100)], - ]); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], - ]); - $notify->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - 'amount' => $payment->getTotalAmount(), - ])); + self::assertCount(0, $this->getRequests(), 'No API call should be made for an invalid callback'); + } - $action->execute($notify); + /** + * @test + */ + public function shouldRejectMissingChecksum(): void + { + $this->httpRequestAction->setHttpRequest('{"id":1001}', []); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); + $action = new NotifyAction(); + $action->setGateway($this->gateway); + $action->setApi($this->api); - self::assertEquals(QuickPayPayment::STATE_PROCESSED, $quickpayPayment->getState()); - self::assertEquals(QuickPayPaymentOperation::TYPE_CAPTURE, $quickpayPayment->getLatestOperation()->getType()); - self::assertEquals(QuickPayPaymentOperation::STATUS_CODE_APPROVED, $quickpayPayment->getLatestOperation()->getStatusCode()); + try { + $action->execute($this->notify()); + self::fail('An HttpResponse reply should have been thrown'); + } catch (HttpResponse $reply) { + self::assertSame(400, $reply->getStatusCode()); + } + + self::assertCount(0, $this->getRequests(), 'No API call should be made for an unsigned callback'); + } + + private function notify(): Notify + { + return new Notify(new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100])); } } diff --git a/tests/Action/RefundActionTest.php b/tests/Action/RefundActionTest.php index 915b3c0..8cb7f80 100644 --- a/tests/Action/RefundActionTest.php +++ b/tests/Action/RefundActionTest.php @@ -5,13 +5,10 @@ namespace Setono\Payum\QuickPay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Model\Token; -use Payum\Core\Request\Convert; use Payum\Core\Request\Refund; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; use Setono\Payum\QuickPay\Action\RefundAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; class RefundActionTest extends ActionTestAbstract { @@ -26,70 +23,25 @@ class RefundActionTest extends ActionTestAbstract */ public function shouldRefundPayment(): void { - $payment = $this->createPayment(); - - $token = new Token(); - $token->setTargetUrl('theCallbackUrl'); - $token->setAfterUrl('theContinueUrl'); - $token->setGatewayName('quickpay'); - - $convert = new Convert($payment, 'array', $token); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - - $convertPaymentAction = new ConvertPaymentAction(); - $convertPaymentAction->setGateway($this->gateway); - $convertPaymentAction->setApi($this->api); - $convertPaymentAction->execute($convert); - - $payment->setDetails($convert->getResult()); - $details = ArrayObject::ensureArrayObject($payment->getDetails()); - $token->setDetails($details); - - // Authorize payment with test card. - $details['card'] = $this->getTestCard()->toArray(); - $details['acquirer'] = 'clearhaus'; - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], - ]); - $quickpayPayment = $this->api->authorizePayment($details['quickpayPayment'], $details); - self::assertEquals(QuickPayPaymentOperation::TYPE_AUTHORIZE, $quickpayPayment->getLatestOperation()->getType()); - - // Capture payment. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], - ]); - $quickpayPayment = $this->api->capturePayment($details['quickpayPayment'], $details); - self::assertEquals(QuickPayPaymentOperation::TYPE_CAPTURE, $quickpayPayment->getLatestOperation()->getType()); + $details = new ArrayObject(['quickpayPaymentId' => 1001, 'amount' => 100]); /** @var Refund $refund */ - $refund = new $this->requestClass($token); - $refund->setModel($details); + $refund = new $this->requestClass($details); - /** @var RefundAction $action */ - $action = new $this->actionClass(); + $action = new RefundAction(); $action->setGateway($this->gateway); $action->setApi($this->api); - // The refund operation itself. $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_REFUND)], + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Refund)], ]); + $action->execute($refund); - // Reload to assert the refund operation. - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_REFUND)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['quickpayPaymentId' => $details['quickpayPayment']->getId()])); - self::assertEquals(QuickPayPaymentOperation::TYPE_REFUND, $quickpayPayment->getLatestOperation()->getType()); + $requests = $this->getRequests(); + self::assertCount(1, $requests); + $this->assertRequest($requests[0], 'POST', '#/payments/1001/refund$#'); + self::assertSame(100, $this->decodeBody($requests[0])['amount']); } } diff --git a/tests/Action/StatusActionTest.php b/tests/Action/StatusActionTest.php index d98ba94..7289c12 100644 --- a/tests/Action/StatusActionTest.php +++ b/tests/Action/StatusActionTest.php @@ -7,8 +7,8 @@ use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Request\GetHumanStatus; use Setono\Payum\QuickPay\Action\StatusAction; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; class StatusActionTest extends ActionTestAbstract { @@ -21,11 +21,12 @@ class StatusActionTest extends ActionTestAbstract */ public function shouldMarkEmptyAsNew(): void { - $statusRequest = new GetHumanStatus([]); + $request = new GetHumanStatus([]); $action = new StatusAction(); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isNew(), 'Request should be marked as new'); + $action->execute($request); + + self::assertTrue($request->isNew(), 'Request should be marked as new'); } /** @@ -33,87 +34,44 @@ public function shouldMarkEmptyAsNew(): void */ public function shouldMarkInitialAsNew(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); + $this->queuePayment(['state' => PaymentState::Initial->value]); - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); + $request = $this->statusRequest(); + $this->executeStatus($request); - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isNew(), 'Request should be marked as new'); + self::assertTrue($request->isNew(), 'Request should be marked as new'); } /** * @test */ - public function shouldMarkNewAsAuthorized(): void + public function shouldMarkNewWithApprovedAuthorizeAsAuthorized(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 1, - ])); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE)], + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize)], ]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isAuthorized(), 'Request should be marked as authorized'); + + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertTrue($request->isAuthorized(), 'Request should be marked as authorized'); } /** * @test */ - public function shouldMarkNewAsFailed(): void + public function shouldMarkNewWithRejectedAuthorizeAsFailed(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getCaptureRejectedTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->capturePayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - - // A new payment whose authorize operation was not approved -> failed. $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, 40000)], + 'state' => PaymentState::New->value, + 'operations' => [$this->operation(OperationType::Authorize, '40000')], ]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isFailed(), 'Request should be marked as failed'); + + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertTrue($request->isFailed(), 'Request should be marked as failed'); } /** @@ -121,26 +79,12 @@ public function shouldMarkNewAsFailed(): void */ public function shouldMarkPendingAsPending(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PENDING]); - $quickpayPayment = $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 1, - ])); - self::assertEquals(QuickPayPayment::STATE_PENDING, $quickpayPayment->getState()); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPayment' => $quickpayPayment, - ])); + $this->queuePayment(['state' => PaymentState::Pending->value]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertEquals($statusRequest::STATUS_PENDING, $statusRequest->getValue()); + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertSame($request::STATUS_PENDING, $request->getValue()); } /** @@ -148,192 +92,103 @@ public function shouldMarkPendingAsPending(): void */ public function shouldMarkRejectedAsFailed(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); + $this->queuePayment(['state' => PaymentState::Rejected->value]); - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_REJECTED]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getAuthorizeRejectedTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 1, - ])); + $request = $this->statusRequest(); + $this->executeStatus($request); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_REJECTED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, 40000)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); + self::assertTrue($request->isFailed(), 'Request should be marked as failed'); + } - self::assertEquals(QuickPayPayment::STATE_REJECTED, $quickpayPayment->getState()); - self::assertEquals(QuickPayPaymentOperation::TYPE_AUTHORIZE, $quickpayPayment->getLatestOperation()->getType()); + /** + * @test + */ + public function shouldMarkInvalidAsFailed(): void + { + $this->queuePayment(['state' => PaymentState::Invalid->value]); - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPayment' => $quickpayPayment, - ])); + $request = $this->statusRequest(); + $this->executeStatus($request); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isFailed(), 'Request should be marked as failed'); + self::assertTrue($request->isFailed(), 'Request should be marked as failed'); } /** * @test */ - public function shouldMarkProcessedAsCaptured(): void + public function shouldMarkProcessedWithCaptureAsCaptured(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PROCESSED]); - $this->api->capturePayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CAPTURE)], + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture)], ]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isCaptured(), 'Request should be marked as captured'); + + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertTrue($request->isCaptured(), 'Request should be marked as captured'); } /** * @test */ - public function shouldMarkProcessedAsCanceled(): void + public function shouldMarkProcessedWithRefundAsRefunded(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PROCESSED]); - $this->api->cancelPayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CANCEL)], + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Refund)], ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - - self::assertEquals(QuickPayPayment::STATE_PROCESSED, $quickpayPayment->getState()); - self::assertEquals(QuickPayPaymentOperation::TYPE_CANCEL, $quickpayPayment->getLatestOperation()->getType()); - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPayment' => $quickpayPayment, - ])); + $request = $this->statusRequest(); + $this->executeStatus($request); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertTrue($statusRequest->isCanceled(), 'Request should be marked as canceled'); + self::assertSame($request::STATUS_REFUNDED, $request->getValue()); } /** * @test */ - public function shouldMarkRefunded(): void + public function shouldMarkProcessedWithCancelAsCanceled(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PROCESSED]); - $this->api->capturePayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PROCESSED]); - $this->api->refundPayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_REFUND)], + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Cancel)], ]); - $action = new StatusAction(); - $action->setApi($this->api); - $action->execute($statusRequest); - self::assertEquals($statusRequest::STATUS_REFUNDED, $statusRequest->getValue()); + + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertSame($request::STATUS_CANCELED, $request->getValue()); } /** * @test */ - public function shouldMarkCanceled(): void + public function shouldMarkProcessedWithoutApprovedOperationAsUnknown(): void { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_PROCESSED]); - $this->api->cancelPayment($quickpayPayment, new ArrayObject([ - 'amount' => 100, - ])); - - $statusRequest = new GetHumanStatus([]); - $statusRequest->setModel(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_CANCEL)], + 'state' => PaymentState::Processed->value, + 'operations' => [$this->operation(OperationType::Capture, '40000')], ]); + + $request = $this->statusRequest(); + $this->executeStatus($request); + + self::assertSame($request::STATUS_UNKNOWN, $request->getValue()); + } + + private function statusRequest(): GetHumanStatus + { + $request = new GetHumanStatus([]); + $request->setModel(new ArrayObject(['quickpayPaymentId' => 1001])); + + return $request; + } + + private function executeStatus(GetHumanStatus $request): void + { $action = new StatusAction(); $action->setApi($this->api); - $action->execute($statusRequest); - self::assertEquals($statusRequest::STATUS_CANCELED, $statusRequest->getValue()); + $action->execute($request); } } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 4d1d807..44b9464 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -4,12 +4,9 @@ namespace Setono\Payum\QuickPay\Tests; -use Exception; -use GuzzleHttp\Psr7\Response; -use Payum\Core\Bridge\Spl\ArrayObject; -use Payum\Core\Exception\LogicException; use PHPUnit\Framework\TestCase; use Setono\Payum\QuickPay\Api; +use Setono\Quickpay\Client\Endpoint\PaymentsEndpoint; class ApiTest extends TestCase { @@ -17,34 +14,39 @@ class ApiTest extends TestCase /** * @test - * - * @throws Exception */ - public function shouldValidateChecksum(): void + public function shouldExposeConfiguredOptions(): void { - $body = 'This is a fine looking body'; - $checksum = Api::checksum($body, '1234'); - $response = new Response(200, ['QuickPay-Checksum-Sha256' => $checksum], $body); - Api::assertValidResponse($response, '1234'); - - try { - Api::assertValidResponse($response, '12345'); - } catch (LogicException $le) { - self::assertEquals('Invalid checksum', $le->getMessage()); - } + self::assertSame('test-privatekey', $this->api->getPrivateKey()); + self::assertSame('ut', $this->api->getOrderPrefix()); + self::assertSame('visa', $this->api->getPaymentMethods()); + self::assertSame('en', $this->api->getLanguage()); + self::assertTrue($this->api->isAutoCapture()); + self::assertFalse($this->api->isSynchronized()); + self::assertSame(266017, $this->api->getAgreementId()); + self::assertNull($this->api->getBrandingId()); + self::assertInstanceOf(PaymentsEndpoint::class, $this->api->payments()); } /** * @test - * - * @throws Exception */ - public function getPaymentShouldFailOnInvalidPaymentInfo(): void + public function shouldReturnNullPaymentMethodsWhenEmpty(): void { - try { - $this->api->getPayment(new ArrayObject(), false); - } catch (LogicException $le) { - self::assertEquals('Payment does not exist', $le->getMessage()); - } + $api = new Api(client: $this->api->getClient(), privateKey: 'test-privatekey', paymentMethods: ''); + + self::assertNull($api->getPaymentMethods()); + } + + /** + * @test + */ + public function shouldCreateCallbackValidatorBoundToThePrivateKey(): void + { + $validator = $this->api->createCallbackValidator(); + + $body = '{"foo":"bar"}'; + self::assertTrue($validator->isValid($body, hash_hmac('sha256', $body, 'test-privatekey'))); + self::assertFalse($validator->isValid($body, 'not-the-checksum')); } } diff --git a/tests/ApiTestTrait.php b/tests/ApiTestTrait.php index 7f08751..fa5776a 100644 --- a/tests/ApiTestTrait.php +++ b/tests/ApiTestTrait.php @@ -4,63 +4,66 @@ namespace Setono\Payum\QuickPay\Tests; -use DateTime; use GuzzleHttp\Psr7\Response; -use Http\Message\MessageFactory\GuzzleMessageFactory; +use Http\Mock\Client as MockHttpClient; use Payum\Core\Gateway; use Payum\Core\GatewayInterface; use Payum\Core\Model\Payment; +use Psr\Http\Message\RequestInterface; use Setono\Payum\QuickPay\Action\Api\ConfirmPaymentAction; use Setono\Payum\QuickPay\Api; -use Setono\Payum\QuickPay\Model\QuickpayCard; -use Setono\Payum\QuickPay\Model\QuickPayPayment; -use Setono\Payum\QuickPay\Model\QuickPayPaymentOperation; +use Setono\Payum\QuickPay\Operations; +use Setono\Quickpay\Client\Client; +use Setono\Quickpay\Enum\OperationType; +use Setono\Quickpay\Enum\PaymentState; /** - * Shared setup for tests that exercise the {@see Api} and the actions. The Api is built around a - * {@see StubHttpClient}, so no test touches the live QuickPay API: each test queues the responses - * the QuickPay API would return for the requests it triggers. + * Shared setup for tests exercising the actions and the {@see Api}. The SDK client is built around a + * PSR-18 {@see MockHttpClient}, so no test touches the live QuickPay API: each test queues the + * responses the API would return, in the order the code under test performs the requests, and may + * assert on the recorded requests via {@see self::getRequests()}. */ trait ApiTestTrait { - protected StubHttpClient $httpClient; + protected MockHttpClient $httpClient; protected Api $api; protected GatewayInterface $gateway; + protected StubGetHttpRequestAction $httpRequestAction; + public function setUp(): void { parent::setUp(); - $this->httpClient = new StubHttpClient(); - $this->api = new Api($this->apiOptions(), $this->httpClient, new GuzzleMessageFactory()); + $this->httpClient = new MockHttpClient(); + + $this->api = new Api( + client: new Client('test-apikey', $this->httpClient), + privateKey: 'test-privatekey', + orderPrefix: 'ut', + paymentMethods: 'visa', + language: 'en', + autoCapture: true, + synchronized: false, + agreementId: 266017, + ); + + // A real gateway is needed so actions that dispatch sub-requests stay offline: + // NotifyAction -> GetHttpRequest (StubGetHttpRequestAction) and -> ConfirmPayment. + $confirmPaymentAction = new ConfirmPaymentAction(); + $confirmPaymentAction->setApi($this->api); + + $this->httpRequestAction = new StubGetHttpRequestAction(); - // A real gateway is only needed so actions that dispatch sub-requests (NotifyAction -> - // ConfirmPayment) stay offline; it shares the same stubbed Api. $gateway = new Gateway(); $gateway->addApi($this->api); - $gateway->addAction(new ConfirmPaymentAction()); + $gateway->addAction($confirmPaymentAction); + $gateway->addAction($this->httpRequestAction); $this->gateway = $gateway; } - /** - * @return array - */ - protected function apiOptions(): array - { - return [ - 'apikey' => 'test-apikey', - 'privatekey' => 'test-privatekey', - 'merchant' => '75015', - 'agreement' => '266017', - 'order_prefix' => 'ut', - 'payment_methods' => 'visa', - 'auto_capture' => '1', - 'language' => 'en', - ]; - } - protected function queueResponse(string $body, int $status = 200): void { $this->httpClient->addResponse(new Response($status, [], $body)); @@ -85,8 +88,11 @@ protected function paymentJson(array $overrides = []): string 'id' => 1001, 'order_id' => 'ut0001', 'currency' => 'DKK', + 'merchant_id' => 75015, + 'accepted' => true, + 'test_mode' => true, + 'state' => PaymentState::Initial->value, 'fee' => null, - 'state' => QuickPayPayment::STATE_INITIAL, 'operations' => [], ], $overrides), \JSON_THROW_ON_ERROR); } @@ -94,13 +100,14 @@ protected function paymentJson(array $overrides = []): string /** * @return array */ - protected function operation(string $type, int $statusCode = QuickPayPaymentOperation::STATUS_CODE_APPROVED, int $amount = 100): array + protected function operation(OperationType $type, string $statusCode = Operations::APPROVED_STATUS_CODE, int $amount = 100): array { return [ 'id' => 1, - 'type' => $type, + 'type' => $type->value, 'amount' => $amount, - 'qp_status_code' => (string) $statusCode, + 'pending' => false, + 'qp_status_code' => $statusCode, ]; } @@ -114,28 +121,39 @@ protected function createPayment(): Payment return $payment; } - protected function getTestCard(): QuickpayCard + /** + * @return list + */ + protected function getRequests(): array { - return QuickpayCard::createFromArray([ - 'number' => 1000000000000008, - 'expiration' => (new DateTime())->format('ym'), - 'cvd' => 123, - ]); + return $this->httpClient->getRequests(); } - protected function getAuthorizeRejectedTestCard(): QuickpayCard + /** + * Asserts the method, path and the auth/version headers the SDK client sets on every request. + */ + protected function assertRequest(RequestInterface $request, string $method, string $pathPattern): void { - $card = $this->getTestCard(); - $card->setNumber($card->getNumber() + 8); - - return $card; + self::assertSame($method, $request->getMethod()); + self::assertMatchesRegularExpression($pathPattern, $request->getUri()->getPath()); + self::assertSame('Basic ' . base64_encode(':test-apikey'), $request->getHeaderLine('Authorization')); + self::assertSame('v10', $request->getHeaderLine('Accept-Version')); + self::assertSame('application/json', $request->getHeaderLine('Accept')); } - protected function getCaptureRejectedTestCard(): QuickpayCard + /** + * @return array + */ + protected function decodeBody(RequestInterface $request): array { - $card = $this->getTestCard(); - $card->setNumber($card->getNumber() + 24); + $body = (string) $request->getBody(); + if ('' === $body) { + return []; + } + + /** @var array $decoded */ + $decoded = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); - return $card; + return $decoded; } } diff --git a/tests/Model/QuickpayModelTest.php b/tests/Model/QuickpayModelTest.php deleted file mode 100644 index 2e4d251..0000000 --- a/tests/Model/QuickpayModelTest.php +++ /dev/null @@ -1,126 +0,0 @@ -format('ym'); - $card = $this->getTestCard(); - - self::assertEquals(1000000000000008, $card->getNumber()); - self::assertEquals($exp, $card->getExpiration()); - self::assertEquals(123, $card->getCvd()); - } - - /** - * @test - */ - public function quickpayEmptyPayment(): void - { - $data = (object) [ - 'id' => 100, - 'order_id' => 't100', - 'operations' => [], - 'currency' => 'DKK', - 'fee' => null, - 'state' => QuickpayPayment::STATE_NEW, - ]; - $quickpayPayment = QuickpayPayment::createFromObject($data); - - self::assertEquals($data->id, $quickpayPayment->getId()); - self::assertEquals($data->currency, $quickpayPayment->getCurrency()); - self::assertEquals($data->order_id, $quickpayPayment->getOrderId()); - self::assertGreaterThanOrEqual(0, $quickpayPayment->getAuthorizedAmount()); - self::assertEquals(QuickPayPayment::STATE_NEW, $quickpayPayment->getState()); - self::assertEquals($data->fee, $quickpayPayment->getFee()); - self::assertNull($quickpayPayment->getLatestOperation()); - } - - /** - * @test - */ - public function quickpayPayment(): void - { - $this->queueResponse('[' . $this->paymentJson([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_PROCESSED, - ]) . ']'); - $quickpayPayments = $this->api->getPayments(new ArrayObject(['page_size' => 1, 'state' => QuickpayPayment::STATE_PROCESSED])); - - self::assertCount(1, $quickpayPayments); - - $quickpayPayment = $quickpayPayments[0]; - - self::assertGreaterThan(0, $quickpayPayment->getId()); - self::assertGreaterThanOrEqual(0, $quickpayPayment->getAuthorizedAmount()); - self::assertEquals(3, \strlen($quickpayPayment->getCurrency())); - self::assertNotEmpty($quickpayPayment->getOrderId()); - self::assertEquals(QuickPayPayment::STATE_PROCESSED, $quickpayPayment->getState()); - if (null !== $quickpayPayment->getLatestOperation()) { - self::assertInstanceOf(QuickPayPaymentOperation::class, $quickpayPayment->getLatestOperation()); - } - } - - /** - * @test - */ - public function quickpayPaymentOperation(): void - { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_NEW]); - $this->api->authorizePayment($quickpayPayment, new ArrayObject([ - 'card' => $this->getTestCard()->toArray(), - 'acquirer' => 'clearhaus', - 'amount' => 100, - ])); - - $this->queuePayment([ - 'id' => 1001, - 'state' => QuickPayPayment::STATE_NEW, - 'operations' => [$this->operation(QuickPayPaymentOperation::TYPE_AUTHORIZE, QuickPayPaymentOperation::STATUS_CODE_APPROVED, 100)], - ]); - $quickpayPayment = $this->api->getPayment(new ArrayObject([ - 'quickpayPaymentId' => $quickpayPayment->getId(), - ])); - - $quickpayPaymentOperation = $quickpayPayment->getLatestOperation(); - - self::assertInstanceOf(QuickPayPaymentOperation::class, $quickpayPaymentOperation); - self::assertGreaterThan(0, $quickpayPaymentOperation->getId()); - self::assertEquals(QuickPayPaymentOperation::TYPE_AUTHORIZE, $quickpayPaymentOperation->getType()); - self::assertEquals(QuickPayPaymentOperation::STATUS_CODE_APPROVED, $quickpayPaymentOperation->getStatusCode()); - self::assertEquals(100, $quickpayPaymentOperation->getAmount()); - } - - /** - * @test - */ - public function quickpayPaymentLink(): void - { - $this->queuePayment(['id' => 1001, 'state' => QuickPayPayment::STATE_INITIAL]); - $quickpayPayment = $this->api->getPayment(new ArrayObject(['payment' => $this->createPayment()])); - - $this->queueResponse('{"url":"https://payment.quickpay.net/payments/1001/payment-window"}'); - $quickpayPaymentLink = $this->api->createPaymentLink($quickpayPayment, new ArrayObject(['continue_url' => '-', 'cancel_url' => '-', 'callback_url' => '-', 'amount' => 100])); - - self::assertNotEmpty($quickpayPaymentLink->getUrl()); - } -} diff --git a/tests/OperationsTest.php b/tests/OperationsTest.php new file mode 100644 index 0000000..4d583a1 --- /dev/null +++ b/tests/OperationsTest.php @@ -0,0 +1,106 @@ +operation(1, OperationType::Authorize); + $last = $this->operation(2, OperationType::Capture); + + self::assertSame($last, Operations::latest([$first, $last])); + } + + /** + * @test + */ + public function isApprovedReflectsTheStatusCode(): void + { + self::assertTrue(Operations::isApproved($this->operation(1, OperationType::Capture, '20000'))); + self::assertFalse(Operations::isApproved($this->operation(1, OperationType::Capture, '40000'))); + self::assertFalse(Operations::isApproved($this->operation(1, OperationType::Capture, null))); + } + + /** + * @test + */ + public function isApprovedOfTypeChecksBothTypeAndApproval(): void + { + $approvedCapture = $this->operation(1, OperationType::Capture, '20000'); + + self::assertTrue(Operations::isApprovedOfType($approvedCapture, OperationType::Capture)); + self::assertFalse(Operations::isApprovedOfType($approvedCapture, OperationType::Refund)); + self::assertFalse(Operations::isApprovedOfType($this->operation(1, OperationType::Capture, '40000'), OperationType::Capture)); + self::assertFalse(Operations::isApprovedOfType(null, OperationType::Capture)); + } + + /** + * @test + */ + public function isLatestApprovedLooksAtTheLastOperation(): void + { + $operations = [ + $this->operation(1, OperationType::Authorize, '20000'), + $this->operation(2, OperationType::Capture, '20000'), + ]; + + self::assertTrue(Operations::isLatestApproved($operations, OperationType::Capture)); + self::assertFalse(Operations::isLatestApproved($operations, OperationType::Authorize)); + self::assertFalse(Operations::isLatestApproved([], OperationType::Capture)); + } + + /** + * @test + */ + public function authorizedAmountReturnsTheMostRecentApprovedAuthorizeAmount(): void + { + $operations = [ + $this->operation(1, OperationType::Authorize, '20000', 50), + $this->operation(2, OperationType::Authorize, '20000', 100), + $this->operation(3, OperationType::Capture, '20000', 100), + ]; + + self::assertSame(100, Operations::authorizedAmount($operations)); + } + + /** + * @test + */ + public function authorizedAmountIsZeroWithoutAnApprovedAuthorize(): void + { + self::assertSame(0, Operations::authorizedAmount([])); + self::assertSame(0, Operations::authorizedAmount([ + $this->operation(1, OperationType::Authorize, '40000', 100), + $this->operation(2, OperationType::Capture, '20000', 100), + ])); + } + + private function operation(int $id, OperationType $type, ?string $qpStatusCode = '20000', int $amount = 100): Operation + { + return new Operation( + id: $id, + type: $type->value, + amount: $amount, + qpStatusCode: $qpStatusCode, + ); + } +} diff --git a/tests/QuickPayGatewayFactoryTest.php b/tests/QuickPayGatewayFactoryTest.php index c20c86f..c8a8542 100644 --- a/tests/QuickPayGatewayFactoryTest.php +++ b/tests/QuickPayGatewayFactoryTest.php @@ -4,7 +4,9 @@ namespace Setono\Payum\QuickPay\Tests; +use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\CoreGatewayFactory; +use Payum\Core\Exception\LogicException; use Payum\Core\Extension\ExtensionCollection; use Payum\Core\Gateway; use Payum\Core\GatewayFactory; @@ -12,7 +14,10 @@ use ReflectionClass; use ReflectionException; use ReflectionProperty; +use Setono\Payum\QuickPay\Api; use Setono\Payum\QuickPay\QuickPayGatewayFactory; +use Setono\Quickpay\Client\Client; +use stdClass; class QuickPayGatewayFactoryTest extends TestCase { @@ -54,8 +59,6 @@ public function shouldAllowCreateGateway(): void $gateway = $factory->create([ 'apikey' => '1234', 'privatekey' => '1234', - 'merchant' => '1234', - 'agreement' => '1234', ]); self::assertInstanceOf(Gateway::class, $gateway); self::assertNotEmpty(self::readProperty($gateway, 'apis')); @@ -66,6 +69,48 @@ public function shouldAllowCreateGateway(): void self::assertNotEmpty(self::readProperty($extensions, 'extensions')); } + /** + * @test + */ + public function shouldBuildApiWithInjectedClient(): void + { + $client = new Client('injected-key'); + + $factory = new QuickPayGatewayFactory(); + $config = $factory->createConfig([ + 'apikey' => '1234', + 'privatekey' => 'private', + 'quickpay.client' => $client, + 'order_prefix' => 'sylius-', + ]); + + self::assertArrayHasKey('payum.api', $config); + self::assertIsCallable($config['payum.api']); + + $api = $config['payum.api'](ArrayObject::ensureArrayObject($config)); + + self::assertInstanceOf(Api::class, $api); + self::assertSame($client, $api->getClient()); + self::assertSame('sylius-', $api->getOrderPrefix()); + } + + /** + * @test + */ + public function shouldThrowWhenInjectedClientIsInvalid(): void + { + $factory = new QuickPayGatewayFactory(); + $config = $factory->createConfig([ + 'apikey' => '1234', + 'privatekey' => 'private', + 'quickpay.client' => new stdClass(), + ]); + + $this->expectException(LogicException::class); + + $config['payum.api'](ArrayObject::ensureArrayObject($config)); + } + /** * @test */ diff --git a/tests/StubGetHttpRequestAction.php b/tests/StubGetHttpRequestAction.php new file mode 100644 index 0000000..34c31cc --- /dev/null +++ b/tests/StubGetHttpRequestAction.php @@ -0,0 +1,51 @@ + $headers + */ + public function __construct( + private string $content = '', + private array $headers = [], + ) { + } + + /** + * @param array $headers + */ + public function setHttpRequest(string $content, array $headers): void + { + $this->content = $content; + $this->headers = $headers; + } + + /** + * @param mixed|GetHttpRequest $request + */ + public function execute($request): void + { + RequestNotSupportedException::assertSupports($this, $request); + + $request->content = $this->content; + $request->headers = $this->headers; + } + + public function supports($request): bool + { + return $request instanceof GetHttpRequest; + } +} diff --git a/tests/StubHttpClient.php b/tests/StubHttpClient.php deleted file mode 100644 index 529cc01..0000000 --- a/tests/StubHttpClient.php +++ /dev/null @@ -1,56 +0,0 @@ - */ - private array $responses = []; - - /** @var list */ - private array $requests = []; - - public function addResponse(ResponseInterface $response): void - { - $this->responses[] = $response; - } - - /** - * @return ResponseInterface - */ - public function send(RequestInterface $request) - { - $this->requests[] = $request; - - $response = array_shift($this->responses); - if (null === $response) { - throw new RuntimeException(sprintf( - 'No stubbed response queued for request "%s %s".', - $request->getMethod(), - (string) $request->getUri(), - )); - } - - return $response; - } - - /** - * @return list - */ - public function getRequests(): array - { - return $this->requests; - } -} From 0a4e8f6cd1c1305dbc4439f904b8d43e9ae48e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 30 Jun 2026 15:06:15 +0200 Subject: [PATCH 2/4] Wire Infection mutation testing into CI Add minMsi 65 / minCoveredMsi 70 gates to infection.json.dist and a dedicated mutation-tests CI job (PHP 8.3, highest, pcov) running composer infection. --- .github/workflows/build.yaml | 32 ++++++++++++++++++++++++++++++++ infection.json.dist | 4 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 47be09c..1837188 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -194,3 +194,35 @@ jobs: with: token: "${{ secrets.CODECOV_TOKEN }}" files: ".build/logs/clover.xml" + + mutation-tests: + name: "Mutation tests" + + runs-on: "ubuntu-latest" + + strategy: + matrix: + php-version: + - "8.3" + + dependencies: + - "highest" + + steps: + - name: "Checkout" + uses: "actions/checkout@v6" + + - name: "Setup PHP, with composer and extensions" + uses: "shivammathur/setup-php@v2" + with: + coverage: "pcov" + extensions: "${{ env.PHP_EXTENSIONS }}" + php-version: "${{ matrix.php-version }}" + + - name: "Install composer dependencies" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "${{ matrix.dependencies }}" + + - name: "Run infection/infection" + run: "composer infection" diff --git a/infection.json.dist b/infection.json.dist index 2e2fc22..cebcf5a 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -7,5 +7,7 @@ }, "logs": { "text": "php://stderr" - } + }, + "minMsi": 65, + "minCoveredMsi": 70 } From d1ee5d3b5c86dfcd6455b8a03239ef83489b1b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 30 Jun 2026 15:06:15 +0200 Subject: [PATCH 3/4] Update docs for the 2.x SDK-based gateway Rewrite the stale CLAUDE.md sections (the suite is mocked, not live; Api is an SDK wrapper; models replaced by SDK DTOs + Operations helper; PHP 8.1-8.5 matrix), document the PSR-18/17 requirement and gateway options in the README, and add docs/UPGRADE-2.0.md. --- CLAUDE.md | 129 +++++++++++++++++++++++++++----------------- README.md | 36 ++++++++++++- docs/UPGRADE-2.0.md | 65 ++++++++++++++++++++++ 3 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 docs/UPGRADE-2.0.md diff --git a/CLAUDE.md b/CLAUDE.md index cbb0f57..71a895f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,15 +23,20 @@ binaries live in `vendor/bin`, and the user's shell aliases (`ca`, `cf`, etc.) m - `composer check-style` — ECS (Easy Coding Standard) dry-run check - `composer fix-style` — ECS auto-fix - `composer rector` — Rector dry/apply (config in `rector.php`; not run in CI) +- `composer infection` — Infection mutation testing (`infection.json.dist`: source `src`, gates + `minMsi 65` / `minCoveredMsi 70`; needs a coverage driver — CI runs it on PHP 8.3 with pcov) - `composer checks` — style check + static analysis - `composer all` — `checks` + `test` (the full local gate) - `composer normalize` — normalize `composer.json` (CI enforces `--dry-run`) -- `vendor/bin/composer-dependency-analyser` — shipmonk dependency hygiene (shadow/unused deps) +- `vendor/bin/composer-dependency-analyser` — shipmonk dependency hygiene (shadow/unused deps); config + in `composer-dependency-analyser.php` ignores the `php-http/message-factory` unused-dependency error + (it is a runtime dependency of `payum/core`'s gateway wiring, not referenced by our code) The dev toolchain is inlined directly into `require-dev` (PHPStan + extensions, PHPUnit, Rector, -Infection, ECS via `sylius-labs/coding-standard`, shipmonk) rather than pulled via a meta-package. -CI (`.github/workflows/build.yaml`) runs the matrix on **PHP 8.1–8.4**, both `lowest` and `highest` -Composer dependency resolutions. +Infection, ECS via `sylius-labs/coding-standard`, shipmonk, `php-http/mock-client`) rather than pulled +via a meta-package. CI (`.github/workflows/build.yaml`) runs the matrix on **PHP 8.1–8.5** — `unit-tests` +on both `lowest` and `highest` Composer resolutions, everything else on `highest` — plus dedicated +`code-coverage` (Codecov) and `mutation-tests` (Infection) jobs on PHP 8.3. ## Architecture @@ -39,67 +44,93 @@ This package follows Payum's request/action gateway pattern. A consumer creates `QuickPayGatewayFactory`, then `execute()`s Payum request objects against it. Each request is routed to a matching Action. +### The SDK + +All HTTP and (de)serialization is delegated to [`setono/quickpay-php-sdk`](https://github.com/Setono/quickpay-php-sdk) +(namespace `Setono\Quickpay\`) — a PSR-18/PSR-17 client (auto-discovered via `php-http/discovery`) with +typed `payments()` endpoints, request/response DTOs, non-exhaustive `PaymentState`/`OperationType` enums, +a `QuickpayException` hierarchy, and a timing-safe `CallbackValidator`. Basic auth, the mandatory +`Accept-Version: v10` header and host pinning to `api.quickpay.net` all live in the SDK. The SDK is +currently pinned at `^1.0@alpha`. + ### Wiring `QuickPayGatewayFactory::populateConfig()` is the composition root. It registers every action under a -`payum.action.*` key and defines `payum.api` — a factory closure that builds the `Api` service from the -gateway's options after validating required options (`apikey`, `merchant`, `agreement`, `privatekey`, -`language`). Options also include behavior flags such as `auto_capture`, `order_prefix`, and -`payment_methods`. +`payum.action.*` key and defines `payum.api` — a factory closure that builds the `Api` value object. +Required options are just `apikey` and `privatekey`; other options (`payment_methods`, `auto_capture`, +`order_prefix`, `language`, `synchronized`, `agreement` → link `agreementId`, `branding_id`) are +defaulted. The closure constructs the SDK `Client` from the api key (or accepts a prebuilt +`Setono\Quickpay\Client\ClientInterface` via the optional `quickpay.client` option — used by tests). ### Actions (`src/Action/`) Actions are the unit of behavior. Each implements `ActionInterface` plus the aware-interfaces it needs (`ApiAwareInterface` via `Action/Api/ApiAwareTrait`, `GatewayAwareInterface`, `GenericTokenFactoryAwareInterface`). `supports()` gates on the Payum request type **and** the model -being an `ArrayAccess`. The model is always normalized with `ArrayObject::ensureArrayObject($request->getModel())`, -and that same `ArrayObject` is passed straight through to `Api` methods as the request params (so model -keys like `amount`, `quickpayPaymentId`, `card`, `continue_url` double as API parameters). - -Request → Action flow: -- **Convert** → `ConvertPaymentAction` — turns a Payum `PaymentInterface` into the details array; - creates the QuickPay payment if absent and stores `quickpayPayment` / `quickpayPaymentId`; sets - `continue_url`/`cancel_url` from the token's after-URL. -- **Authorize** → `AuthorizeAction` — builds a notify (callback) token, creates a QuickPay payment link, - and **throws `HttpRedirect`** to send the user to QuickPay's hosted payment window. -- **Capture / Refund / Cancel** → corresponding actions call `Api::getPayment()` then the matching - `Api::*Payment()` method. `CancelAction` swallows the "cannot be cancelled" case instead of throwing. -- **Notify** → `NotifyAction` — entry point for QuickPay's server-to-server callback; delegates to the - internal `ConfirmPayment` request. +being an `ArrayAccess`. The model is normalized with `ArrayObject::ensureArrayObject($request->getModel())`; +the `quickpayPaymentId` (int) it carries is the single source of truth — actions re-fetch the payment via +`Api::payments()->getById()` rather than passing the model through as API params. + +Request → Action flow (amounts are integer minor units everywhere — no conversion): +- **Convert** → `ConvertPaymentAction` — turns a Payum `PaymentInterface` into the details array; creates + the QuickPay payment (via `CreatePaymentRequest`) if absent and stores **only scalars** — + `quickpayPaymentId`, `amount`, `currency`, `order_id` — plus `continue_url`/`cancel_url` from the + token's after-URL. It never persists DTO/model objects. +- **Authorize** → `AuthorizeAction` — builds a notify (callback) token into `callback_url`, creates a + payment link (`createLink` + `CreateLinkRequest`), and **throws `HttpRedirect`** to QuickPay's hosted + payment window. +- **Capture / Refund / Cancel** → call `Api::payments()->capture/refund/cancel(...)`. Operations are + asynchronous by default (final state arrives via the callback); `Api::isSynchronized()` (the + `synchronized` option, default off) is the single toggle that flips them to synchronous. `CancelAction` + catches the typed `QuickpayException` and swallows the "Transaction in wrong state for this operation" + case (so cancel is idempotent), rethrowing anything else. +- **Notify** → `NotifyAction` — entry point for QuickPay's server-to-server callback. It fetches the raw + body + `QuickPay-Checksum-Sha256` header (via Payum's `GetHttpRequest`), **verifies the HMAC signature** + with the SDK `CallbackValidator`, and rejects an invalid/unsigned callback with a 400 `HttpResponse` + before delegating to the internal `ConfirmPayment` request. - **ConfirmPayment** (internal, `src/Request/Api/` + `Action/Api/ConfirmPaymentAction`) — when - `auto_capture` is on and the latest operation is an approved authorize whose amount matches, it - captures automatically. -- **GetStatus** → `StatusAction` — maps QuickPay payment `state` + latest operation to Payum marks + `auto_capture` is on and the latest operation is an approved authorize whose amount matches, it captures + automatically. +- **GetStatus** → `StatusAction` — maps the SDK `PaymentState` + latest operation to Payum marks (`markCaptured`, `markRefunded`, `markAuthorized`, `markFailed`, etc.). ### Api (`src/Api.php`) -The single HTTP-facing class. Wraps a Payum `HttpClientInterface` + PSR-7 `MessageFactory`. Every call -goes through `doRequest()`, which sets Basic auth from `apikey`, the `Accept-Version: v10` header, -JSON-encodes the body, and throws `HttpException` on non-2xx. Responses carrying a -`QuickPay-Checksum-Sha256` header are HMAC-SHA256 verified against `privatekey` (`assertValidResponse` / -`validateChecksum`). The endpoint is hard-coded to `https://api.quickpay.net`. +A `final`, immutable value object injected as `payum.api`. It performs **no HTTP itself** — it wraps the +configured SDK `ClientInterface` (exposed via `getClient()` / `payments()`) plus the behavior options the +actions need (`getOrderPrefix()`, `getPaymentMethods()`, `getLanguage()`, `isAutoCapture()`, +`isSynchronized()`, `getAgreementId()`, `getBrandingId()`), and exposes `createCallbackValidator()` for +notify verification. -### Models (`src/Model/`) +### Operations helper (`src/Operations.php`) -Plain data objects hydrated from QuickPay JSON responses. `QuickPayModel` is the base; subclasses expose -typed getters and `createFromResponse()` / `createFromObject()` factories. State logic lives here: -`QuickPayPayment` exposes `STATE_*` constants and `getLatestOperation()` / `getAuthorizedAmount()`; -`QuickPayPaymentOperation` exposes `TYPE_*` constants and `isApproved()` (status code `20000`). The -`StatusAction` and `ConfirmPaymentAction` decisions are driven entirely by these constants — change them -in lockstep with QuickPay's API semantics. +The custom `src/Model/*` classes are gone — responses are the SDK's readonly DTOs (`Response\Payment\ +{Payment,Operation,Link}`) plus the `PaymentState`/`OperationType` enums. The behavior that used to live +on those models now lives in the stateless `Operations` helper over a `list`: `latest()`, +`isApproved()` (status code `20000`), `isApprovedOfType()`, `isLatestApproved()`, `authorizedAmount()`. +`StatusAction` and `ConfirmPaymentAction` decisions are driven by these helpers plus the SDK enums. ## Testing -Tests mirror `src/` under `tests/`. Action tests extend `ActionTestAbstract` (which extends Payum's -`GenericActionTest`) and assert the action implements the expected interfaces in addition to behavior. - -**The suite is integration-style, not mocked:** `ApiTestTrait::createGatewayMock()` builds a real -gateway with hard-coded QuickPay test credentials, and the action/API tests make **live HTTP calls** to -`https://api.quickpay.net`. This means the suite needs network access and is inherently flaky — several -tests (`CaptureActionTest`, `StatusActionTest`, `RefundActionTest`) depend on QuickPay's asynchronous -operation processing and shared test-account state, so individual runs occasionally fail on timing and -pass on re-run. `tests/bootstrap.php` also adds Payum core's own `tests/` dir to the autoloader so -`GenericActionTest` resolves. PHPStan intentionally does not analyze `tests/` (the loosely-typed Payum -base class makes a clean pass impractical), so test-only type regressions are caught by PHPUnit, not -PHPStan. +Tests mirror `src/` under `tests/` and are **fully offline and deterministic** — no network, no shared +QuickPay account. The HTTP seam is a PSR-18 `Http\Mock\Client` (`php-http/mock-client`) injected into the +SDK `Client`; `tests/ApiTestTrait` builds the `Api` around it with fake credentials and provides +`queueResponse()` / `queuePayment()` (FIFO response queue), `operation()`, fixture builders, and +request-shape assertion helpers (`assertRequest()` checks method, path, Basic auth and `Accept-Version`). +Tests assert both the resulting Payum marks/replies **and** that the correct QuickPay requests were sent +(`getRequests()`). + +Most action tests extend `ActionTestAbstract` → `GenericActionTestCase`, a **local copy** of Payum's +`GenericActionTest` (Payum ships it `export-ignore`, so it is absent on `--prefer-dist`/CI installs); it +uses Prophecy for gateway/token doubles. `ConvertPaymentActionTest` stands alone because Payum's `Convert` +is not a `Generic` request. `NotifyAction`'s callback verification is tested via +`tests/StubGetHttpRequestAction` (feeds a raw body + checksum header into `GetHttpRequest`). + +Two important Valinor gotchas when writing fixtures: the SDK mapper is strict, so a payment JSON must +include every typed non-optional field (`id`, `order_id`, `currency`, `state`, `merchant_id`) and a single +mistyped nested field fails the **whole** mapping; queue the response with `queuePayment()`/`paymentJson()` +which already supply these. + +PHPStan intentionally analyzes `src` only — the loosely-typed Payum test base classes and Prophecy produce +unavoidable noise in `tests/`, so test-only type regressions are caught by PHPUnit (and mutation testing), +not PHPStan. diff --git a/README.md b/README.md index e3bb4e4..524fcc8 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,46 @@ [![Build Status][ico-github-actions]][link-github-actions] [![Code Coverage][ico-code-coverage]][link-code-coverage] -This component enables the use of QuickPay with Payum. +This component enables the use of QuickPay with Payum. Under the hood it uses the +[`setono/quickpay-php-sdk`](https://github.com/Setono/quickpay-php-sdk) client. + +> **Upgrading from 1.x?** See [`docs/UPGRADE-2.0.md`](docs/UPGRADE-2.0.md). ## Installation -``composer require setono/payum-quickpay`` +```bash +composer require setono/payum-quickpay +``` + +The SDK relies on [HTTPlug discovery](https://docs.php-http.org/en/latest/discovery.html), so your +project must provide a [PSR-18 HTTP client](https://packagist.org/providers/psr/http-client-implementation) +and [PSR-17 message factories](https://packagist.org/providers/psr/http-factory-implementation). If you do +not already have them, install e.g.: + +```bash +composer require kriswallsmith/buzz nyholm/psr7 +``` + +> **Note:** the SDK is currently released as `1.0.0-alpha.1`. Until a stable release is tagged, your +> project needs `"minimum-stability": "alpha"` (with `"prefer-stable": true`) for Composer to resolve it. ## Configuration +The gateway requires your QuickPay **API key** (Settings → API user) and **private key** (Settings → +Integration — used to verify callback signatures). Other options are optional: + +| Option | Default | Description | +|-------------------|---------|----------------------------------------------------------------------| +| `apikey` | — | **Required.** QuickPay API key. | +| `privatekey` | — | **Required.** Private key; used to verify the callback HMAC. | +| `auto_capture` | `0` | Capture automatically once an approved authorize is confirmed. | +| `payment_methods` | `''` | Restrict the payment-window methods (e.g. `creditcard`). | +| `order_prefix` | `''` | Prepended to the Payum payment number to form the QuickPay order id. | +| `language` | `en` | Payment-window language. | +| `synchronized` | `false` | Run capture/refund/cancel synchronously instead of via callbacks. | +| `agreement` | `''` | Optional payment-window agreement id. | +| `branding_id` | `''` | Optional payment-window branding id. | + ```php Date: Tue, 30 Jun 2026 15:25:09 +0200 Subject: [PATCH 4/4] Standardize brand casing from QuickPay to Quickpay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the \Setono\Payum\Quickpay namespace, the QuickpayGatewayFactory class (and its file), the 'Quickpay' factory title, and all prose to match the SDK's \Setono\Quickpay convention. The 'QuickPay-Checksum-Sha256' callback header is left as-is — it is QuickPay's actual wire header name (held by the SDK's CallbackValidator::CHECKSUM_HEADER). Also drop the now-obsolete rector.php ReadOnlyPropertyRector skip that referenced the deleted dynamically-hydrated models. --- CLAUDE.md | 18 ++++++------ README.md | 12 ++++---- composer.json | 6 ++-- docs/UPGRADE-2.0.md | 14 +++++----- rector.php | 7 ----- src/Action/Api/ApiAwareTrait.php | 4 +-- src/Action/Api/ConfirmPaymentAction.php | 6 ++-- src/Action/AuthorizeAction.php | 8 +++--- src/Action/CancelAction.php | 6 ++-- src/Action/CaptureAction.php | 4 +-- src/Action/ConvertPaymentAction.php | 4 +-- src/Action/NotifyAction.php | 10 +++---- src/Action/RefundAction.php | 4 +-- src/Action/StatusAction.php | 6 ++-- src/Api.php | 4 +-- src/Operations.php | 8 +++--- ...Factory.php => QuickpayGatewayFactory.php} | 24 ++++++++-------- src/Request/Api/ConfirmPayment.php | 2 +- tests/Action/ActionTestAbstract.php | 6 ++-- tests/Action/Api/ConfirmPaymentActionTest.php | 10 +++---- tests/Action/AuthorizeActionTest.php | 4 +-- tests/Action/CancelActionTest.php | 6 ++-- tests/Action/CaptureActionTest.php | 4 +-- tests/Action/ConvertPaymentActionTest.php | 6 ++-- tests/Action/NotifyActionTest.php | 4 +-- tests/Action/RefundActionTest.php | 4 +-- tests/Action/StatusActionTest.php | 4 +-- tests/ApiTest.php | 4 +-- tests/ApiTestTrait.php | 12 ++++---- tests/GenericActionTestCase.php | 2 +- tests/OperationsTest.php | 4 +-- ...est.php => QuickpayGatewayFactoryTest.php} | 28 +++++++++---------- tests/StubGetHttpRequestAction.php | 2 +- 33 files changed, 120 insertions(+), 127 deletions(-) rename src/{QuickPayGatewayFactory.php => QuickpayGatewayFactory.php} (84%) rename tests/{QuickPayGatewayFactoryTest.php => QuickpayGatewayFactoryTest.php} (83%) diff --git a/CLAUDE.md b/CLAUDE.md index 71a895f..be44aac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview `setono/payum-quickpay` is a [Payum](https://github.com/Payum/Payum) gateway that integrates the -[QuickPay](https://quickpay.net) payment provider (a Danish PSP). It is a library — there is no +[Quickpay](https://quickpay.net) payment provider (a Danish PSP). It is a library — there is no runnable application — consumed by projects that wire it into Payum (commonly Sylius shops). The -package targets **PHP >= 8.1** and the QuickPay API **v10**. +package targets **PHP >= 8.1** and the Quickpay API **v10**. ## Commands @@ -41,7 +41,7 @@ on both `lowest` and `highest` Composer resolutions, everything else on `highest ## Architecture This package follows Payum's request/action gateway pattern. A consumer creates a gateway via -`QuickPayGatewayFactory`, then `execute()`s Payum request objects against it. Each request is routed +`QuickpayGatewayFactory`, then `execute()`s Payum request objects against it. Each request is routed to a matching Action. ### The SDK @@ -55,7 +55,7 @@ currently pinned at `^1.0@alpha`. ### Wiring -`QuickPayGatewayFactory::populateConfig()` is the composition root. It registers every action under a +`QuickpayGatewayFactory::populateConfig()` is the composition root. It registers every action under a `payum.action.*` key and defines `payum.api` — a factory closure that builds the `Api` value object. Required options are just `apikey` and `privatekey`; other options (`payment_methods`, `auto_capture`, `order_prefix`, `language`, `synchronized`, `agreement` → link `agreementId`, `branding_id`) are @@ -73,18 +73,18 @@ the `quickpayPaymentId` (int) it carries is the single source of truth — actio Request → Action flow (amounts are integer minor units everywhere — no conversion): - **Convert** → `ConvertPaymentAction` — turns a Payum `PaymentInterface` into the details array; creates - the QuickPay payment (via `CreatePaymentRequest`) if absent and stores **only scalars** — + the Quickpay payment (via `CreatePaymentRequest`) if absent and stores **only scalars** — `quickpayPaymentId`, `amount`, `currency`, `order_id` — plus `continue_url`/`cancel_url` from the token's after-URL. It never persists DTO/model objects. - **Authorize** → `AuthorizeAction` — builds a notify (callback) token into `callback_url`, creates a - payment link (`createLink` + `CreateLinkRequest`), and **throws `HttpRedirect`** to QuickPay's hosted + payment link (`createLink` + `CreateLinkRequest`), and **throws `HttpRedirect`** to Quickpay's hosted payment window. - **Capture / Refund / Cancel** → call `Api::payments()->capture/refund/cancel(...)`. Operations are asynchronous by default (final state arrives via the callback); `Api::isSynchronized()` (the `synchronized` option, default off) is the single toggle that flips them to synchronous. `CancelAction` catches the typed `QuickpayException` and swallows the "Transaction in wrong state for this operation" case (so cancel is idempotent), rethrowing anything else. -- **Notify** → `NotifyAction` — entry point for QuickPay's server-to-server callback. It fetches the raw +- **Notify** → `NotifyAction` — entry point for Quickpay's server-to-server callback. It fetches the raw body + `QuickPay-Checksum-Sha256` header (via Payum's `GetHttpRequest`), **verifies the HMAC signature** with the SDK `CallbackValidator`, and rejects an invalid/unsigned callback with a 400 `HttpResponse` before delegating to the internal `ConfirmPayment` request. @@ -113,11 +113,11 @@ on those models now lives in the stateless `Operations` helper over a `list **Upgrading from 1.x?** See [`docs/UPGRADE-2.0.md`](docs/UPGRADE-2.0.md). @@ -30,16 +30,16 @@ composer require kriswallsmith/buzz nyholm/psr7 ## Configuration -The gateway requires your QuickPay **API key** (Settings → API user) and **private key** (Settings → +The gateway requires your Quickpay **API key** (Settings → API user) and **private key** (Settings → Integration — used to verify callback signatures). Other options are optional: | Option | Default | Description | |-------------------|---------|----------------------------------------------------------------------| -| `apikey` | — | **Required.** QuickPay API key. | +| `apikey` | — | **Required.** Quickpay API key. | | `privatekey` | — | **Required.** Private key; used to verify the callback HMAC. | | `auto_capture` | `0` | Capture automatically once an approved authorize is confirmed. | | `payment_methods` | `''` | Restrict the payment-window methods (e.g. `creditcard`). | -| `order_prefix` | `''` | Prepended to the Payum payment number to form the QuickPay order id. | +| `order_prefix` | `''` | Prepended to the Payum payment number to form the Quickpay order id. | | `language` | `en` | Payment-window language. | | `synchronized` | `false` | Run capture/refund/cancel synchronously instead of via callbacks. | | `agreement` | `''` | Optional payment-window agreement id. | @@ -55,7 +55,7 @@ $defaultConfig = []; $payum = (new PayumBuilder) ->addGatewayFactory('quickpay', function(array $config, GatewayFactoryInterface $coreGatewayFactory) { - return new \Setono\Payum\QuickPay\QuickPayGatewayFactory($config, $coreGatewayFactory); + return new \Setono\Payum\Quickpay\QuickpayGatewayFactory($config, $coreGatewayFactory); }) ->addGateway('quickpay', [ 'factory' => 'quickpay' diff --git a/composer.json b/composer.json index 3943c80..247820a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "setono/payum-quickpay", - "description": "QuickPay gateway for Payum", + "description": "Quickpay gateway for Payum", "license": "MIT", "type": "library", "keywords": [ @@ -39,12 +39,12 @@ }, "autoload": { "psr-4": { - "Setono\\Payum\\QuickPay\\": "src/" + "Setono\\Payum\\Quickpay\\": "src/" } }, "autoload-dev": { "psr-4": { - "Setono\\Payum\\QuickPay\\Tests\\": "tests/" + "Setono\\Payum\\Quickpay\\Tests\\": "tests/" } }, "config": { diff --git a/docs/UPGRADE-2.0.md b/docs/UPGRADE-2.0.md index c997c7f..f1bcb41 100644 --- a/docs/UPGRADE-2.0.md +++ b/docs/UPGRADE-2.0.md @@ -1,6 +1,6 @@ # Upgrading from 1.x to 2.0 -Version 2.0 replaces the library's hand-rolled QuickPay API client with the +Version 2.0 replaces the library's hand-rolled Quickpay API client with the [`setono/quickpay-php-sdk`](https://github.com/Setono/quickpay-php-sdk) package, hardens callback handling, and modernizes the test suite. This is a major release with breaking changes. @@ -14,7 +14,7 @@ handling, and modernizes the test suite. This is a major release with breaking c ## Gateway options -- **`merchant` was removed.** QuickPay API v10 authenticates with the API key alone; the option was +- **`merchant` was removed.** Quickpay API v10 authenticates with the API key alone; the option was never used. Remove it from your gateway configuration (passing it is harmless — it is ignored). - **Only `apikey` and `privatekey` are now required.** `language` is defaulted to `en`. - **`agreement` is now optional** and maps to the payment-window `agreement_id`. @@ -28,7 +28,7 @@ The details array stored on a payment (the `ArrayObject` model) now contains **o - `1.x` stored a hydrated `quickpayPayment` **object** alongside `quickpayPaymentId`. - `2.0` stores only `quickpayPaymentId` (int) — the source of truth — plus `amount`, `currency`, - `order_id`, `continue_url`, `cancel_url`, `callback_url`. The payment is re-fetched from QuickPay when + `order_id`, `continue_url`, `cancel_url`, `callback_url`. The payment is re-fetched from Quickpay when needed. If your code reads `$details['quickpayPayment']`, switch to fetching the payment via the SDK using @@ -49,11 +49,11 @@ response. Ensure: These were internal implementation details; they are gone in 2.0: -- `Setono\Payum\QuickPay\Model\*` (`QuickPayPayment`, `QuickPayPaymentOperation`, `QuickPayPaymentLink`, - `QuickPayModel`, `QuickpayCard`) — replaced by the SDK's `Setono\Quickpay\Response\Payment\*` DTOs and +- `Setono\Payum\Quickpay\Model\*` (`QuickpayPayment`, `QuickpayPaymentOperation`, `QuickpayPaymentLink`, + `QuickpayModel`, `QuickpayCard`) — replaced by the SDK's `Setono\Quickpay\Response\Payment\*` DTOs and the `Setono\Quickpay\Enum\{PaymentState,OperationType}` enums. State logic now lives in the - `Setono\Payum\QuickPay\Operations` helper. -- `Setono\Payum\QuickPay\Api` is no longer an HTTP client. It is now an immutable value object wrapping + `Setono\Payum\Quickpay\Operations` helper. +- `Setono\Payum\Quickpay\Api` is no longer an HTTP client. It is now an immutable value object wrapping the SDK `Setono\Quickpay\Client\ClientInterface` and the gateway options (`payments()`, `getClient()`, `isAutoCapture()`, `createCallbackValidator()`, …). Its old `getPayment()` / `capturePayment()` / `checksum()` / `assertValidResponse()` methods were removed. diff --git a/rector.php b/rector.php index 411f01e..dae46b0 100644 --- a/rector.php +++ b/rector.php @@ -4,7 +4,6 @@ use Rector\Caching\ValueObject\Storage\FileCacheStorage; use Rector\Config\RectorConfig; -use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; use Rector\Set\ValueObject\LevelSetList; return static function (RectorConfig $rectorConfig): void { @@ -19,10 +18,4 @@ $rectorConfig->sets([ LevelSetList::UP_TO_PHP_81, ]); - - $rectorConfig->skip([ - // The models are hydrated dynamically (see QuickPayModel), so their properties must - // remain writable and cannot be promoted to readonly. - ReadOnlyPropertyRector::class, - ]); }; diff --git a/src/Action/Api/ApiAwareTrait.php b/src/Action/Api/ApiAwareTrait.php index 9e0c98b..350001f 100644 --- a/src/Action/Api/ApiAwareTrait.php +++ b/src/Action/Api/ApiAwareTrait.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action\Api; +namespace Setono\Payum\Quickpay\Action\Api; use Payum\Core\Exception\UnsupportedApiException; -use Setono\Payum\QuickPay\Api; +use Setono\Payum\Quickpay\Api; trait ApiAwareTrait { diff --git a/src/Action/Api/ConfirmPaymentAction.php b/src/Action/Api/ConfirmPaymentAction.php index ca0c975..a30b893 100644 --- a/src/Action/Api/ConfirmPaymentAction.php +++ b/src/Action/Api/ConfirmPaymentAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action\Api; +namespace Setono\Payum\Quickpay\Action\Api; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -12,8 +12,8 @@ use Payum\Core\Exception\RequestNotSupportedException; use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; -use Setono\Payum\QuickPay\Operations; -use Setono\Payum\QuickPay\Request\Api\ConfirmPayment; +use Setono\Payum\Quickpay\Operations; +use Setono\Payum\Quickpay\Request\Api\ConfirmPayment; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Request\Payment\CaptureRequest; diff --git a/src/Action/AuthorizeAction.php b/src/Action/AuthorizeAction.php index 5c38001..6002852 100644 --- a/src/Action/AuthorizeAction.php +++ b/src/Action/AuthorizeAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -16,7 +16,7 @@ use Payum\Core\Request\Authorize; use Payum\Core\Security\GenericTokenFactoryAwareInterface; use Payum\Core\Security\GenericTokenFactoryAwareTrait; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; use Setono\Quickpay\Request\Payment\CreateLinkRequest; class AuthorizeAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface, GenericTokenFactoryAwareInterface @@ -56,10 +56,10 @@ public function execute($request): void )); if (null === $link->url) { - throw new LogicException('QuickPay did not return a payment link url'); + throw new LogicException('Quickpay did not return a payment link url'); } - // Redirect the customer to the QuickPay payment window. + // Redirect the customer to the Quickpay payment window. throw new HttpRedirect($link->url); } diff --git a/src/Action/CancelAction.php b/src/Action/CancelAction.php index 8bdf35d..3116728 100644 --- a/src/Action/CancelAction.php +++ b/src/Action/CancelAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -12,7 +12,7 @@ use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\Cancel; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; use Setono\Quickpay\Exception\QuickpayException; use Setono\Quickpay\Exception\ResponseAwareException; @@ -33,7 +33,7 @@ public function execute($request): void try { $this->api->payments()->cancel((int) $model['quickpayPaymentId'], synchronized: $this->api->isSynchronized()); } catch (QuickpayException $e) { - // QuickPay rejects cancelling a payment that is already captured/cancelled with a + // Quickpay rejects cancelling a payment that is already captured/cancelled with a // "Transaction in wrong state for this operation" error. Treat that as a no-op so a // cancel is idempotent; rethrow anything else. if ($e instanceof ResponseAwareException && diff --git a/src/Action/CaptureAction.php b/src/Action/CaptureAction.php index 969bb54..b9b8b13 100644 --- a/src/Action/CaptureAction.php +++ b/src/Action/CaptureAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -14,7 +14,7 @@ use Payum\Core\Request\Capture; use Payum\Core\Security\GenericTokenFactoryAwareInterface; use Payum\Core\Security\GenericTokenFactoryAwareTrait; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; use Setono\Quickpay\Request\Payment\CaptureRequest; class CaptureAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface, GenericTokenFactoryAwareInterface diff --git a/src/Action/ConvertPaymentAction.php b/src/Action/ConvertPaymentAction.php index a26f529..6c2fb7d 100644 --- a/src/Action/ConvertPaymentAction.php +++ b/src/Action/ConvertPaymentAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use Payum\Core\Action\ActionInterface; use Payum\Core\ApiAwareInterface; @@ -12,7 +12,7 @@ use Payum\Core\GatewayAwareTrait; use Payum\Core\Model\PaymentInterface; use Payum\Core\Request\Convert; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; use Setono\Quickpay\Request\Payment\CreatePaymentRequest; class ConvertPaymentAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface diff --git a/src/Action/NotifyAction.php b/src/Action/NotifyAction.php index 09a1b6b..9840e71 100644 --- a/src/Action/NotifyAction.php +++ b/src/Action/NotifyAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -14,12 +14,12 @@ use Payum\Core\Reply\HttpResponse; use Payum\Core\Request\GetHttpRequest; use Payum\Core\Request\Notify; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; -use Setono\Payum\QuickPay\Request\Api\ConfirmPayment; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Request\Api\ConfirmPayment; use Setono\Quickpay\Callback\CallbackValidator; /** - * Handles the server-to-server callback (webhook) from QuickPay. + * Handles the server-to-server callback (webhook) from Quickpay. * * The raw request body is verified against the `QuickPay-Checksum-Sha256` HMAC signature (computed * with the account private key) before the callback is acted upon, so forged or tampered callbacks @@ -57,7 +57,7 @@ public function supports($request): bool } /** - * Reads the QuickPay checksum header off the (Symfony-bridge populated) `headers` property of the + * Reads the Quickpay checksum header off the (Symfony-bridge populated) `headers` property of the * http request. The lookup is case-insensitive because Payum's bridges normalize header casing * inconsistently, and the value may be a list (PSR/Symfony) or a plain string. */ diff --git a/src/Action/RefundAction.php b/src/Action/RefundAction.php index 4f0cc52..0dc56b1 100644 --- a/src/Action/RefundAction.php +++ b/src/Action/RefundAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -12,7 +12,7 @@ use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\Refund; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; use Setono\Quickpay\Request\Payment\RefundRequest; class RefundAction implements ActionInterface, ApiAwareInterface, GatewayAwareInterface diff --git a/src/Action/StatusAction.php b/src/Action/StatusAction.php index a85508d..60f43ea 100644 --- a/src/Action/StatusAction.php +++ b/src/Action/StatusAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Action; +namespace Setono\Payum\Quickpay\Action; use ArrayAccess; use Payum\Core\Action\ActionInterface; @@ -12,8 +12,8 @@ use Payum\Core\GatewayAwareInterface; use Payum\Core\GatewayAwareTrait; use Payum\Core\Request\GetStatusInterface; -use Setono\Payum\QuickPay\Action\Api\ApiAwareTrait; -use Setono\Payum\QuickPay\Operations; +use Setono\Payum\Quickpay\Action\Api\ApiAwareTrait; +use Setono\Payum\Quickpay\Operations; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; diff --git a/src/Api.php b/src/Api.php index 83eaf4c..a24cdf2 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay; +namespace Setono\Payum\Quickpay; use Setono\Quickpay\Callback\CallbackValidator; use Setono\Quickpay\Client\ClientInterface; use Setono\Quickpay\Client\Endpoint\PaymentsEndpoint; /** - * Immutable value object injected as Payum's `payum.api`. It wraps the configured QuickPay SDK + * Immutable value object injected as Payum's `payum.api`. It wraps the configured Quickpay SDK * client together with the gateway behavior options the actions need. * * It performs no HTTP itself — the SDK client does (Basic auth, the mandatory `Accept-Version: v10` diff --git a/src/Operations.php b/src/Operations.php index 26c4495..af67072 100644 --- a/src/Operations.php +++ b/src/Operations.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay; +namespace Setono\Payum\Quickpay; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Response\Payment\Operation; @@ -10,14 +10,14 @@ /** * Stateless helpers over a payment's list of {@see Operation}s. * - * This replaces the behavior that used to live on the deleted `QuickPayPayment` / - * `QuickPayPaymentOperation` models — the SDK response DTOs are readonly data holders without + * This replaces the behavior that used to live on the deleted `QuickpayPayment` / + * `QuickpayPaymentOperation` models — the SDK response DTOs are readonly data holders without * behavior of their own. */ final class Operations { /** - * QuickPay's status code for an approved operation. + * Quickpay's status code for an approved operation. */ public const APPROVED_STATUS_CODE = '20000'; diff --git a/src/QuickPayGatewayFactory.php b/src/QuickpayGatewayFactory.php similarity index 84% rename from src/QuickPayGatewayFactory.php rename to src/QuickpayGatewayFactory.php index 54fabc7..32f8452 100644 --- a/src/QuickPayGatewayFactory.php +++ b/src/QuickpayGatewayFactory.php @@ -2,29 +2,29 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay; +namespace Setono\Payum\Quickpay; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Exception\LogicException; use Payum\Core\GatewayFactory; -use Setono\Payum\QuickPay\Action\Api\ConfirmPaymentAction; -use Setono\Payum\QuickPay\Action\AuthorizeAction; -use Setono\Payum\QuickPay\Action\CancelAction; -use Setono\Payum\QuickPay\Action\CaptureAction; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; -use Setono\Payum\QuickPay\Action\NotifyAction; -use Setono\Payum\QuickPay\Action\RefundAction; -use Setono\Payum\QuickPay\Action\StatusAction; +use Setono\Payum\Quickpay\Action\Api\ConfirmPaymentAction; +use Setono\Payum\Quickpay\Action\AuthorizeAction; +use Setono\Payum\Quickpay\Action\CancelAction; +use Setono\Payum\Quickpay\Action\CaptureAction; +use Setono\Payum\Quickpay\Action\ConvertPaymentAction; +use Setono\Payum\Quickpay\Action\NotifyAction; +use Setono\Payum\Quickpay\Action\RefundAction; +use Setono\Payum\Quickpay\Action\StatusAction; use Setono\Quickpay\Client\Client; use Setono\Quickpay\Client\ClientInterface; -class QuickPayGatewayFactory extends GatewayFactory +class QuickpayGatewayFactory extends GatewayFactory { protected function populateConfig(ArrayObject $config): void { $config->defaults([ 'payum.factory_name' => 'quickpay', - 'payum.factory_title' => 'QuickPay', + 'payum.factory_title' => 'Quickpay', 'payum.action.capture' => new CaptureAction(), 'payum.action.authorize' => new AuthorizeAction(), 'payum.action.refund' => new RefundAction(), @@ -46,7 +46,7 @@ protected function populateConfig(ArrayObject $config): void 'synchronized' => false, // optional: maps to CreateLinkRequest::agreementId 'agreement' => '', - // optional: maps to the QuickPay branding id on the payment link + // optional: maps to the Quickpay branding id on the payment link 'branding_id' => '', ]; $config->defaults($config['payum.default_options']); diff --git a/src/Request/Api/ConfirmPayment.php b/src/Request/Api/ConfirmPayment.php index 5367e4e..7b506df 100644 --- a/src/Request/Api/ConfirmPayment.php +++ b/src/Request/Api/ConfirmPayment.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Request\Api; +namespace Setono\Payum\Quickpay\Request\Api; use Payum\Core\Request\Generic; diff --git a/tests/Action/ActionTestAbstract.php b/tests/Action/ActionTestAbstract.php index d5b910b..140fdd7 100644 --- a/tests/Action/ActionTestAbstract.php +++ b/tests/Action/ActionTestAbstract.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Action\ActionInterface; use Payum\Core\ApiAwareInterface; use Payum\Core\GatewayAwareInterface; use ReflectionClass; use ReflectionException; -use Setono\Payum\QuickPay\Tests\ApiTestTrait; -use Setono\Payum\QuickPay\Tests\GenericActionTestCase; +use Setono\Payum\Quickpay\Tests\ApiTestTrait; +use Setono\Payum\Quickpay\Tests\GenericActionTestCase; abstract class ActionTestAbstract extends GenericActionTestCase { diff --git a/tests/Action/Api/ConfirmPaymentActionTest.php b/tests/Action/Api/ConfirmPaymentActionTest.php index dd631df..5b8dfcf 100644 --- a/tests/Action/Api/ConfirmPaymentActionTest.php +++ b/tests/Action/Api/ConfirmPaymentActionTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action\Api; +namespace Setono\Payum\Quickpay\Tests\Action\Api; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Exception\LogicException; use PHPUnit\Framework\TestCase; -use Setono\Payum\QuickPay\Action\Api\ConfirmPaymentAction; -use Setono\Payum\QuickPay\Api; -use Setono\Payum\QuickPay\Request\Api\ConfirmPayment; -use Setono\Payum\QuickPay\Tests\ApiTestTrait; +use Setono\Payum\Quickpay\Action\Api\ConfirmPaymentAction; +use Setono\Payum\Quickpay\Api; +use Setono\Payum\Quickpay\Request\Api\ConfirmPayment; +use Setono\Payum\Quickpay\Tests\ApiTestTrait; use Setono\Quickpay\Client\Client; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; diff --git a/tests/Action/AuthorizeActionTest.php b/tests/Action/AuthorizeActionTest.php index 617d67d..3d5b13d 100644 --- a/tests/Action/AuthorizeActionTest.php +++ b/tests/Action/AuthorizeActionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Model\Token; @@ -12,7 +12,7 @@ use Payum\Core\Security\GenericTokenFactoryInterface; use ReflectionClass; use ReflectionException; -use Setono\Payum\QuickPay\Action\AuthorizeAction; +use Setono\Payum\Quickpay\Action\AuthorizeAction; class AuthorizeActionTest extends ActionTestAbstract { diff --git a/tests/Action/CancelActionTest.php b/tests/Action/CancelActionTest.php index 7e3fb4e..5b24204 100644 --- a/tests/Action/CancelActionTest.php +++ b/tests/Action/CancelActionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Request\Cancel; -use Setono\Payum\QuickPay\Action\CancelAction; +use Setono\Payum\Quickpay\Action\CancelAction; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; use Setono\Quickpay\Exception\ValidationException; @@ -59,7 +59,7 @@ public function shouldSwallowWrongStateError(): void $action->setGateway($this->gateway); $action->setApi($this->api); - // QuickPay reports an already finalized payment as a wrong-state error; the action treats it + // Quickpay reports an already finalized payment as a wrong-state error; the action treats it // as a no-op so cancelling is idempotent. $this->queueResponse('{"message":"Transaction in wrong state for this operation"}', 400); diff --git a/tests/Action/CaptureActionTest.php b/tests/Action/CaptureActionTest.php index 7f5950e..a2be5e2 100644 --- a/tests/Action/CaptureActionTest.php +++ b/tests/Action/CaptureActionTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Request\Capture; use Payum\Core\Security\GenericTokenFactoryAwareInterface; use ReflectionClass; use ReflectionException; -use Setono\Payum\QuickPay\Action\CaptureAction; +use Setono\Payum\Quickpay\Action\CaptureAction; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; use Setono\Quickpay\Exception\ValidationException; diff --git a/tests/Action/ConvertPaymentActionTest.php b/tests/Action/ConvertPaymentActionTest.php index c1a9f86..128e8ef 100644 --- a/tests/Action/ConvertPaymentActionTest.php +++ b/tests/Action/ConvertPaymentActionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Action\ActionInterface; use Payum\Core\ApiAwareInterface; @@ -11,8 +11,8 @@ use Payum\Core\Model\Token; use Payum\Core\Request\Convert; use PHPUnit\Framework\TestCase; -use Setono\Payum\QuickPay\Action\ConvertPaymentAction; -use Setono\Payum\QuickPay\Tests\ApiTestTrait; +use Setono\Payum\Quickpay\Action\ConvertPaymentAction; +use Setono\Payum\Quickpay\Tests\ApiTestTrait; use stdClass; /** diff --git a/tests/Action/NotifyActionTest.php b/tests/Action/NotifyActionTest.php index 21f75a7..974073e 100644 --- a/tests/Action/NotifyActionTest.php +++ b/tests/Action/NotifyActionTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Reply\HttpResponse; use Payum\Core\Request\Notify; -use Setono\Payum\QuickPay\Action\NotifyAction; +use Setono\Payum\Quickpay\Action\NotifyAction; use Setono\Quickpay\Callback\CallbackValidator; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; diff --git a/tests/Action/RefundActionTest.php b/tests/Action/RefundActionTest.php index 8cb7f80..238f333 100644 --- a/tests/Action/RefundActionTest.php +++ b/tests/Action/RefundActionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Request\Refund; -use Setono\Payum\QuickPay\Action\RefundAction; +use Setono\Payum\Quickpay\Action\RefundAction; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; diff --git a/tests/Action/StatusActionTest.php b/tests/Action/StatusActionTest.php index 7289c12..d23e1e7 100644 --- a/tests/Action/StatusActionTest.php +++ b/tests/Action/StatusActionTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests\Action; +namespace Setono\Payum\Quickpay\Tests\Action; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\Request\GetHumanStatus; -use Setono\Payum\QuickPay\Action\StatusAction; +use Setono\Payum\Quickpay\Action\StatusAction; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 44b9464..73391b5 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use PHPUnit\Framework\TestCase; -use Setono\Payum\QuickPay\Api; +use Setono\Payum\Quickpay\Api; use Setono\Quickpay\Client\Endpoint\PaymentsEndpoint; class ApiTest extends TestCase diff --git a/tests/ApiTestTrait.php b/tests/ApiTestTrait.php index fa5776a..a14555f 100644 --- a/tests/ApiTestTrait.php +++ b/tests/ApiTestTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use GuzzleHttp\Psr7\Response; use Http\Mock\Client as MockHttpClient; @@ -10,16 +10,16 @@ use Payum\Core\GatewayInterface; use Payum\Core\Model\Payment; use Psr\Http\Message\RequestInterface; -use Setono\Payum\QuickPay\Action\Api\ConfirmPaymentAction; -use Setono\Payum\QuickPay\Api; -use Setono\Payum\QuickPay\Operations; +use Setono\Payum\Quickpay\Action\Api\ConfirmPaymentAction; +use Setono\Payum\Quickpay\Api; +use Setono\Payum\Quickpay\Operations; use Setono\Quickpay\Client\Client; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Enum\PaymentState; /** * Shared setup for tests exercising the actions and the {@see Api}. The SDK client is built around a - * PSR-18 {@see MockHttpClient}, so no test touches the live QuickPay API: each test queues the + * PSR-18 {@see MockHttpClient}, so no test touches the live Quickpay API: each test queues the * responses the API would return, in the order the code under test performs the requests, and may * assert on the recorded requests via {@see self::getRequests()}. */ @@ -70,7 +70,7 @@ protected function queueResponse(string $body, int $status = 200): void } /** - * Queues a QuickPay payment JSON response built from sensible defaults plus the given overrides. + * Queues a Quickpay payment JSON response built from sensible defaults plus the given overrides. * * @param array $overrides */ diff --git a/tests/GenericActionTestCase.php b/tests/GenericActionTestCase.php index c8c2890..a3d37b8 100644 --- a/tests/GenericActionTestCase.php +++ b/tests/GenericActionTestCase.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use ArrayObject; use Iterator; diff --git a/tests/OperationsTest.php b/tests/OperationsTest.php index 4d583a1..349a309 100644 --- a/tests/OperationsTest.php +++ b/tests/OperationsTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use PHPUnit\Framework\TestCase; -use Setono\Payum\QuickPay\Operations; +use Setono\Payum\Quickpay\Operations; use Setono\Quickpay\Enum\OperationType; use Setono\Quickpay\Response\Payment\Operation; diff --git a/tests/QuickPayGatewayFactoryTest.php b/tests/QuickpayGatewayFactoryTest.php similarity index 83% rename from tests/QuickPayGatewayFactoryTest.php rename to tests/QuickpayGatewayFactoryTest.php index c8a8542..32168a2 100644 --- a/tests/QuickPayGatewayFactoryTest.php +++ b/tests/QuickpayGatewayFactoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use Payum\Core\Bridge\Spl\ArrayObject; use Payum\Core\CoreGatewayFactory; @@ -14,12 +14,12 @@ use ReflectionClass; use ReflectionException; use ReflectionProperty; -use Setono\Payum\QuickPay\Api; -use Setono\Payum\QuickPay\QuickPayGatewayFactory; +use Setono\Payum\Quickpay\Api; +use Setono\Payum\Quickpay\QuickpayGatewayFactory; use Setono\Quickpay\Client\Client; use stdClass; -class QuickPayGatewayFactoryTest extends TestCase +class QuickpayGatewayFactoryTest extends TestCase { /** * @test @@ -28,7 +28,7 @@ class QuickPayGatewayFactoryTest extends TestCase */ public function shouldSubClassGatewayFactory(): void { - $rc = new ReflectionClass(QuickPayGatewayFactory::class); + $rc = new ReflectionClass(QuickpayGatewayFactory::class); self::assertTrue($rc->isSubclassOf(GatewayFactory::class)); } @@ -37,8 +37,8 @@ public function shouldSubClassGatewayFactory(): void */ public function couldBeConstructedWithoutAnyArguments(): void { - $factory = new QuickPayGatewayFactory(); - self::assertInstanceOf(QuickPayGatewayFactory::class, $factory); + $factory = new QuickpayGatewayFactory(); + self::assertInstanceOf(QuickpayGatewayFactory::class, $factory); } /** @@ -46,7 +46,7 @@ public function couldBeConstructedWithoutAnyArguments(): void */ public function shouldCreateCoreGatewayFactoryIfNotPassed(): void { - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); self::assertInstanceOf(CoreGatewayFactory::class, self::readProperty($factory, 'coreGatewayFactory')); } @@ -55,7 +55,7 @@ public function shouldCreateCoreGatewayFactoryIfNotPassed(): void */ public function shouldAllowCreateGateway(): void { - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); $gateway = $factory->create([ 'apikey' => '1234', 'privatekey' => '1234', @@ -76,7 +76,7 @@ public function shouldBuildApiWithInjectedClient(): void { $client = new Client('injected-key'); - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); $config = $factory->createConfig([ 'apikey' => '1234', 'privatekey' => 'private', @@ -99,7 +99,7 @@ public function shouldBuildApiWithInjectedClient(): void */ public function shouldThrowWhenInjectedClientIsInvalid(): void { - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); $config = $factory->createConfig([ 'apikey' => '1234', 'privatekey' => 'private', @@ -116,7 +116,7 @@ public function shouldThrowWhenInjectedClientIsInvalid(): void */ public function shouldAllowCreateGatewayConfig(): void { - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); $config = $factory->createConfig(); self::assertIsArray($config); self::assertNotEmpty($config); @@ -127,13 +127,13 @@ public function shouldAllowCreateGatewayConfig(): void */ public function shouldConfigContainFactoryNameAndTitle(): void { - $factory = new QuickPayGatewayFactory(); + $factory = new QuickpayGatewayFactory(); $config = $factory->createConfig(); self::assertIsArray($config); self::assertArrayHasKey('payum.factory_name', $config); self::assertEquals('quickpay', $config['payum.factory_name']); self::assertArrayHasKey('payum.factory_title', $config); - self::assertEquals('QuickPay', $config['payum.factory_title']); + self::assertEquals('Quickpay', $config['payum.factory_title']); } /** diff --git a/tests/StubGetHttpRequestAction.php b/tests/StubGetHttpRequestAction.php index 34c31cc..f3a6d6b 100644 --- a/tests/StubGetHttpRequestAction.php +++ b/tests/StubGetHttpRequestAction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Setono\Payum\QuickPay\Tests; +namespace Setono\Payum\Quickpay\Tests; use Payum\Core\Action\ActionInterface; use Payum\Core\Exception\RequestNotSupportedException;