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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
137 changes: 84 additions & 53 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,83 +23,114 @@ 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

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

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`.
`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
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<Operation>`: `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.
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,50 @@
# Payum QuickPay Gateway
# Payum Quickpay Gateway

[![Latest Version on Packagist][ico-version]][link-packagist]
[![Software License][ico-license]](LICENSE)
[![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
<?php

Expand All @@ -23,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'
Expand Down
14 changes: 14 additions & 0 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;

return (new Configuration())
// payum/core's CoreGatewayFactory eagerly builds its (deprecated) httplug stack during gateway
// construction — httplug.message_factory / httplug.stream_factory are resolved through
// php-http's MessageFactoryDiscovery, which needs the php-http/message-factory interface package.
// Our own code never references it, but it must stay declared so a gateway can be constructed
// standalone (the SDK itself uses PSR-17/PSR-18 and does not need it).
->ignoreErrorsOnPackage('php-http/message-factory', [ErrorType::UNUSED_DEPENDENCY]);
12 changes: 7 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "setono/payum-quickpay",
"description": "QuickPay gateway for Payum",
"description": "Quickpay gateway for Payum",
"license": "MIT",
"type": "library",
"keywords": [
Expand All @@ -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",
Expand All @@ -24,26 +24,27 @@
"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",
"phpstan/phpstan-phpunit": "^2.0",
"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"
},
"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": {
Expand All @@ -68,6 +69,7 @@
"@analyse"
],
"fix-style": "ecs check --fix",
"infection": "infection --threads=max",
"phpunit": "phpunit",
"rector": "rector",
"test": [
Expand Down
Loading
Loading