Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,19 @@ 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,
?RequestFactoryInterface $requestFactory = null,
?StreamFactoryInterface $streamFactory = null,
?MapperBuilder $mapperBuilder = null,
?NormalizerBuilder $normalizerBuilder = null,
private readonly bool $synchronized = false,
) {
$this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/Client/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
20 changes: 12 additions & 8 deletions src/Client/Endpoint/PaymentsEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 5 additions & 3 deletions src/Client/Endpoint/ResourceEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
12 changes: 12 additions & 0 deletions tests/Client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
20 changes: 20 additions & 0 deletions tests/Client/Endpoint/PaymentsEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
18 changes: 16 additions & 2 deletions tests/QuickpayTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand Down
Loading