From 5ccca4d1488e1e4c437de1ef4e5b5a70c9e62e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Thu, 6 Aug 2026 09:48:30 +0200 Subject: [PATCH] Add a client-wide default for the synchronized operation flag The payment operation methods (authorize/capture/refund/cancel) now take ?bool $synchronized = null, falling back to a new 'synchronized' flag on the Client constructor (exposed as ClientInterface::isSynchronized()). Integrations that always want to wait for the completed transaction can set the default once instead of repeating the flag on every call; a non-null per-call argument still overrides it. --- CLAUDE.md | 4 ++-- README.md | 10 ++++++++++ src/Client/Client.php | 11 ++++++++++ src/Client/ClientInterface.php | 7 +++++++ src/Client/Endpoint/PaymentsEndpoint.php | 20 +++++++++++-------- src/Client/Endpoint/ResourceEndpoint.php | 8 +++++--- tests/Client/ClientTest.php | 12 +++++++++++ .../Client/Endpoint/PaymentsEndpointTest.php | 20 +++++++++++++++++++ tests/QuickpayTestCase.php | 18 +++++++++++++++-- 9 files changed, 95 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 089a4c8..c7e6f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,9 @@ CI (`.github/workflows/build.yaml`, branch `1.x`): coding-standards, dependency- ## Architecture -**Client (`src/Client/Client.php`, `ClientInterface`)** — PSR-18/17 + `php-http/discovery`, Valinor for (de)serialization. Constructor: `(string $apiKey, ?HttpClientInterface, ?RequestFactoryInterface, ?StreamFactoryInterface, ?MapperBuilder, ?NormalizerBuilder)` — only `$apiKey` is required; the rest are discovered/defaulted. Immutable (`private readonly`, no setters). **Auth is HTTP Basic with an EMPTY username and the API key as the password** (`Basic base64(':'.$apiKey)`), plus a mandatory **`Accept-Version: v10`** header. Single host `https://api.quickpay.net` (there is no sandbox host). `request()` stamps the headers, tracks `lastRequest`/`lastResponse`, and routes non-2xx through `assertStatusCode()` (a `match` on the status code). Helpers: `get()`, `post()`, `put()`, `patch()` (the body-carrying ones take `?Payload` — nullable because cancel/authorize send no body; **payment update is PATCH, not PUT**), and `ping(): bool`. `payments()` is lazily memoized. `resolveUrl()` pins the host: an absolute URL to any other host, a non-default port, or an absolute URL combined with a `$query` throws `InvalidUrlException` — the credential-leak guard. `configureMapperBuilder()` / `registerNormalizerTransformers()` are the public hooks for consumers wiring a cached Valinor builder. +**Client (`src/Client/Client.php`, `ClientInterface`)** — PSR-18/17 + `php-http/discovery`, Valinor for (de)serialization. Constructor: `(string $apiKey, ?HttpClientInterface, ?RequestFactoryInterface, ?StreamFactoryInterface, ?MapperBuilder, ?NormalizerBuilder, bool $synchronized = false)` — only `$apiKey` is required; the rest are discovered/defaulted. `$synchronized` (exposed via `isSynchronized()` on `ClientInterface`) is the client-wide default for the payment operation methods' `$synchronized` flag. Immutable (`private readonly`, no setters). **Auth is HTTP Basic with an EMPTY username and the API key as the password** (`Basic base64(':'.$apiKey)`), plus a mandatory **`Accept-Version: v10`** header. Single host `https://api.quickpay.net` (there is no sandbox host). `request()` stamps the headers, tracks `lastRequest`/`lastResponse`, and routes non-2xx through `assertStatusCode()` (a `match` on the status code). Helpers: `get()`, `post()`, `put()`, `patch()` (the body-carrying ones take `?Payload` — nullable because cancel/authorize send no body; **payment update is PATCH, not PUT**), and `ping(): bool`. `payments()` is lazily memoized. `resolveUrl()` pins the host: an absolute URL to any other host, a non-default port, or an absolute URL combined with a `$query` throws `InvalidUrlException` — the credential-leak guard. `configureMapperBuilder()` / `registerNormalizerTransformers()` are the public hooks for consumers wiring a cached Valinor builder. -**Endpoint hierarchy (`src/Client/Endpoint/`)** — `Endpoint` (base: `$client` + `$mapperBuilder`; `mapItem()` runs the source through Valinor `Source::camelCaseKeys()`, maps to the typed DTO, stamps `$raw`, and converts Valinor `MappingError` → `MappingException`) → `ResourceEndpoint` (`getOne`/`createOne`/`update` [PATCH]/`operation` [POST `{id}/{action}`, appends `?synchronized` when asked]/`putSub`) → `CollectionEndpoint` (`getPage`/`paginate`). Quickpay list pagination is **header-less** — `?page=N&page_size=M` returns a bare JSON array, so `paginate()` stops when a page returns fewer items than `pageSize`. `PaymentsEndpoint` (`final`) exposes `getById`/`create`/`updatePayment`/`authorize`/`capture`/`refund`/`cancel`/`createLink`; the operation methods take an optional `bool $synchronized = false` (Quickpay processes operations async by default and returns a pending op; `synchronized: true` waits for the completed transaction). +**Endpoint hierarchy (`src/Client/Endpoint/`)** — `Endpoint` (base: `$client` + `$mapperBuilder`; `mapItem()` runs the source through Valinor `Source::camelCaseKeys()`, maps to the typed DTO, stamps `$raw`, and converts Valinor `MappingError` → `MappingException`) → `ResourceEndpoint` (`getOne`/`createOne`/`update` [PATCH]/`operation` [POST `{id}/{action}`, appends `?synchronized` when asked]/`putSub`) → `CollectionEndpoint` (`getPage`/`paginate`). Quickpay list pagination is **header-less** — `?page=N&page_size=M` returns a bare JSON array, so `paginate()` stops when a page returns fewer items than `pageSize`. `PaymentsEndpoint` (`final`) exposes `getById`/`create`/`updatePayment`/`authorize`/`capture`/`refund`/`cancel`/`createLink`; the operation methods take an optional `?bool $synchronized = null` — `null` falls back to the client-wide `synchronized` constructor flag (Quickpay processes operations async by default and returns a pending op; `synchronized: true` waits for the completed transaction). **Request DTOs (`src/Request/`)** — `Payload` is a mutable marker base. Concrete DTOs are `final class` with plain `public` promoted properties, **all optional/nullable with no construction-time validation** — Quickpay enforces required fields (a missing one surfaces as a `ValidationException`). On serialization the `Payload` normalizer transformer strips `null`/`[]` and converts camelCase → snake_case (`Client::camelToSnake`). `CreatePaymentRequest`, `UpdatePaymentRequest` (PATCH; **no `order_id`/`basket` — not updatable**), `AuthorizePaymentRequest`, `CaptureRequest`, `RefundRequest`, `CreateLinkRequest`, plus nested `Address`/`BasketItem`/`Shipping`. `CollectionRequestOptions` (`page`/`pageSize`, asserted `>= 1`, `toArray()` → `page`/`page_size`). Capture/refund/authorize take an `extras` hash (acquirer-specific) — **`extras` keys pass through verbatim, NOT snake_cased**; `acquirer` is a *link* param, not an operation param. diff --git a/README.md b/README.md index 281f25d..903af97 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,16 @@ pending operation. Pass `synchronized: true` to wait for and receive the complet $payment = $client->payments()->capture($payment->id, new CaptureRequest(1000), synchronized: true); ``` +If your integration always (or never) wants to wait, set the default once on the client instead of +repeating the flag on every call — a non-null per-call `synchronized:` argument still overrides it: + +```php +$client = new Client('YOUR_API_KEY', synchronized: true); + +$client->payments()->capture($payment->id, new CaptureRequest(1000)); // waits (client default) +$client->payments()->refund($payment->id, new RefundRequest(250), synchronized: false); // fire-and-forget +``` + ### Updating a payment Before a payment is authorized you can update some of its fields (`PATCH /payments/{id}`). Note the diff --git a/src/Client/Client.php b/src/Client/Client.php index 818bef3..1d618ac 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -53,6 +53,11 @@ final class Client implements ClientInterface private readonly NormalizerBuilder $normalizerBuilder; + /** + * @param bool $synchronized the client-wide default for the `$synchronized` flag on the payment + * operation methods (authorize/capture/refund/cancel); a non-null per-call argument + * overrides it + */ public function __construct( private readonly string $apiKey, ?HttpClientInterface $httpClient = null, @@ -60,6 +65,7 @@ public function __construct( ?StreamFactoryInterface $streamFactory = null, ?MapperBuilder $mapperBuilder = null, ?NormalizerBuilder $normalizerBuilder = null, + private readonly bool $synchronized = false, ) { $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); @@ -68,6 +74,11 @@ public function __construct( $this->normalizerBuilder = $normalizerBuilder ?? self::defaultNormalizerBuilder(); } + public function isSynchronized(): bool + { + return $this->synchronized; + } + public function getLastRequest(): ?RequestInterface { return $this->lastRequest; diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index 2afad12..d803cd3 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -13,6 +13,13 @@ interface ClientInterface { + /** + * The client-wide default for the `$synchronized` flag on the payment operation methods + * (authorize/capture/refund/cancel). When an operation method is called with + * `$synchronized = null` this default decides whether `?synchronized` is appended. + */ + public function isSynchronized(): bool; + /** * The last request sent to the API, or `null` if no request has been dispatched yet. */ diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index c6130ee..8493a49 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -49,36 +49,40 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment /** * POST `/payments/{id}/authorize`. Pass `$synchronized = true` to wait for and return the - * completed transaction instead of the default asynchronous (pending) response. + * completed transaction instead of the default asynchronous (pending) response; `null` (the + * default) falls back to the client-wide `synchronized` flag set on the `Client` constructor. */ - public function authorize(int $id, ?AuthorizePaymentRequest $request = null, bool $synchronized = false): Payment + public function authorize(int $id, ?AuthorizePaymentRequest $request = null, ?bool $synchronized = null): Payment { return $this->operation($id, 'authorize', $request, $synchronized); } /** * POST `/payments/{id}/capture`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response. + * transaction instead of the default asynchronous (pending) response; `null` (the default) falls + * back to the client-wide `synchronized` flag set on the `Client` constructor. */ - public function capture(int $id, CaptureRequest $request, bool $synchronized = false): Payment + public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null): Payment { return $this->operation($id, 'capture', $request, $synchronized); } /** * POST `/payments/{id}/refund`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response. + * transaction instead of the default asynchronous (pending) response; `null` (the default) falls + * back to the client-wide `synchronized` flag set on the `Client` constructor. */ - public function refund(int $id, RefundRequest $request, bool $synchronized = false): Payment + public function refund(int $id, RefundRequest $request, ?bool $synchronized = null): Payment { return $this->operation($id, 'refund', $request, $synchronized); } /** * POST `/payments/{id}/cancel`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response. + * transaction instead of the default asynchronous (pending) response; `null` (the default) falls + * back to the client-wide `synchronized` flag set on the `Client` constructor. */ - public function cancel(int $id, bool $synchronized = false): Payment + public function cancel(int $id, ?bool $synchronized = null): Payment { return $this->operation($id, 'cancel', null, $synchronized); } diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index 92e1ab0..554d0db 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -77,14 +77,16 @@ protected function update(int|string $id, Payload $request): Resource * * Quickpay processes operations asynchronously by default and returns a `202 Accepted` with the * operation still pending. Pass `$synchronized = true` to add the `?synchronized` flag, which - * makes Quickpay wait and return the completed transaction (its final state) instead. + * makes Quickpay wait and return the completed transaction (its final state) instead. When + * `$synchronized` is `null` the client-wide default ({@see \Setono\Quickpay\Client\ClientInterface::isSynchronized()}) + * applies. * * @return T */ - protected function operation(int|string $id, string $action, ?Payload $request = null, bool $synchronized = false): Resource + protected function operation(int|string $id, string $action, ?Payload $request = null, ?bool $synchronized = null): Resource { $path = sprintf('%s/%s/%s', static::getPath(), $id, $action); - if ($synchronized) { + if ($synchronized ?? $this->client->isSynchronized()) { $path .= '?synchronized'; } diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index 854b4b7..3d0cc53 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -69,6 +69,18 @@ public function it_pings(): void self::assertTrue($this->client($http)->ping()); } + #[Test] + public function it_is_not_synchronized_by_default(): void + { + self::assertFalse($this->client(new ScriptedHttpClient())->isSynchronized()); + } + + #[Test] + public function it_exposes_the_synchronized_flag_given_to_the_constructor(): void + { + self::assertTrue($this->client(new ScriptedHttpClient(), synchronized: true)->isSynchronized()); + } + #[Test] public function it_has_no_last_request_or_response_before_dispatching(): void { diff --git a/tests/Client/Endpoint/PaymentsEndpointTest.php b/tests/Client/Endpoint/PaymentsEndpointTest.php index d74be65..d1eeaed 100644 --- a/tests/Client/Endpoint/PaymentsEndpointTest.php +++ b/tests/Client/Endpoint/PaymentsEndpointTest.php @@ -203,6 +203,26 @@ public function it_can_request_a_synchronized_operation(): void self::assertSame(self::BASE . '/payments/1234/capture?synchronized', (string) $http->sentRequests[0]->getUri()); } + #[Test] + public function it_uses_the_client_wide_synchronized_default(): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/payments/1234/capture?synchronized', self::fixture('payment.json')); + + $this->client($http, synchronized: true)->payments()->capture(1234, new CaptureRequest(1000)); + + self::assertSame(self::BASE . '/payments/1234/capture?synchronized', (string) $http->sentRequests[0]->getUri()); + } + + #[Test] + public function it_overrides_the_client_wide_synchronized_default_per_call(): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/payments/1234/capture', self::fixture('payment.json')); + + $this->client($http, synchronized: true)->payments()->capture(1234, new CaptureRequest(1000), synchronized: false); + + self::assertSame(self::BASE . '/payments/1234/capture', (string) $http->sentRequests[0]->getUri()); + } + #[Test] public function it_lists_payments(): void { diff --git a/tests/QuickpayTestCase.php b/tests/QuickpayTestCase.php index 4ac9420..8f08029 100644 --- a/tests/QuickpayTestCase.php +++ b/tests/QuickpayTestCase.php @@ -15,11 +15,25 @@ abstract class QuickpayTestCase extends TestCase protected const API_KEY = 'apikey'; - protected function client(ScriptedHttpClient $http): Client + /** + * @param bool|null $synchronized when `null` the `Client` constructor default is used, so tests + * without an explicit flag exercise the real default + */ + protected function client(ScriptedHttpClient $http, ?bool $synchronized = null): Client { $psr17 = new Psr17Factory(); - return new Client(self::API_KEY, httpClient: $http, requestFactory: $psr17, streamFactory: $psr17); + if (null === $synchronized) { + return new Client(self::API_KEY, httpClient: $http, requestFactory: $psr17, streamFactory: $psr17); + } + + return new Client( + self::API_KEY, + httpClient: $http, + requestFactory: $psr17, + streamFactory: $psr17, + synchronized: $synchronized, + ); } /**