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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ CI (`.github/workflows/build.yaml`, branch `1.x`): coding-standards, dependency-

**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 sends no body (authorize requires one — the live API rejects a bodyless authorize); **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 = 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).
**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`/`updateOne` [PATCH]/`postOperation` [POST `{id}/{action}`, appends `?synchronized` when asked]/`putSubResource`) → `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. Fields the API **unconditionally** requires are required, non-nullable constructor params — verified against the LIVE API (2026-08-06), not the docs: `CreatePaymentRequest::$orderId`+`$currency`, `$amount` on `CreateLinkRequest`/`CaptureRequest`/`RefundRequest`/`AuthorizePaymentRequest`, and ALL five `BasketItem` fields (the API treats a basket item as all-or-nothing — any partial item gets per-field `is missing` errors; a `[]`-serialized empty item even triggers an HTTP 500 on their side). `Address` and `Shipping` are verified lenient (partial accepted, stored with nulls) — but `Shipping::$method`, when present, is server-validated against a fixed value set (`home_delivery` ok, `pickup` rejected). An all-empty nested Payload set as a DIRECT property (shipping/invoiceAddress) is stripped from the body entirely by the parent's `[]`-strip, so it never reaches the wire; only empty items INSIDE a list (basket) could — which required BasketItem fields now make unrepresentable. Everything else is optional/nullable with no construction-time validation — Quickpay enforces conditional requirements and format rules (violations surface as a `ValidationException`). Before marking a new field required, verify with a live probe — and mind the validation ORDER: body shape → transaction state → params. A `[]` (JSON array) body fails with the generic `body: "is invalid"`, and state errors mask param validation, so operation params (capture/refund) can only be probed on a payment in the right state. To get one without the payment window: authorize via the API with a test card (`card: {number: '1000000000000008', expiration: '2612', cvd: '123'}`) — it works (at least on this account), producing a `test_mode` payment that charges nothing. 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
12 changes: 6 additions & 6 deletions src/Client/Endpoint/PaymentsEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function create(CreatePaymentRequest $request): Payment
*/
public function updatePayment(int $id, UpdatePaymentRequest $request): Payment
{
return $this->update($id, $request);
return $this->updateOne($id, $request);
}

/**
Expand All @@ -62,7 +62,7 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment
*/
public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null): Payment
{
return $this->operation($id, 'authorize', $request, $synchronized);
return $this->postOperation($id, 'authorize', $request, $synchronized);
}

/**
Expand All @@ -79,7 +79,7 @@ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $sync
*/
public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null): Payment
{
return $this->operation($id, 'capture', $request, $synchronized);
return $this->postOperation($id, 'capture', $request, $synchronized);
}

/**
Expand All @@ -96,7 +96,7 @@ public function capture(int $id, CaptureRequest $request, ?bool $synchronized =
*/
public function refund(int $id, RefundRequest $request, ?bool $synchronized = null): Payment
{
return $this->operation($id, 'refund', $request, $synchronized);
return $this->postOperation($id, 'refund', $request, $synchronized);
}

/**
Expand All @@ -113,7 +113,7 @@ public function refund(int $id, RefundRequest $request, ?bool $synchronized = nu
*/
public function cancel(int $id, ?bool $synchronized = null): Payment
{
return $this->operation($id, 'cancel', null, $synchronized);
return $this->postOperation($id, 'cancel', null, $synchronized);
}

/**
Expand All @@ -122,7 +122,7 @@ public function cancel(int $id, ?bool $synchronized = null): Payment
*/
public function createLink(int $id, CreateLinkRequest $request): Link
{
return $this->mapItem(Link::class, $this->putSub($id, 'link', $request));
return $this->mapItem(Link::class, $this->putSubResource($id, 'link', $request));
}

protected static function getPath(): string
Expand Down
20 changes: 10 additions & 10 deletions src/Client/Endpoint/ResourceEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
* Subclasses declare two protected hints: {@see self::getPath()} (the resource URL path) and
* {@see self::getItemClass()} (the typed DTO class). Shared helpers map the JSON response to that
* DTO and stamp `$raw`:
* - {@see self::getOne()} — GET `"{getPath()}"` or `"{getPath()}/{$id}"`.
* - {@see self::createOne()} — POST a typed body.
* - {@see self::update()} — PUT a typed body to `"{getPath()}/{$id}"`.
* - {@see self::operation()} — POST (optionally a body) to `"{getPath()}/{$id}/{$action}"`.
* - {@see self::putSub()} — PUT a typed body to `"{getPath()}/{$id}/{$sub}"`, returning the raw
* decoded array (for sub-resources mapped to a class other than the
* endpoint's item class, e.g. the payment link).
* - {@see self::getOne()} — GET `"{getPath()}"` or `"{getPath()}/{$id}"`.
* - {@see self::createOne()} — POST a typed body.
* - {@see self::updateOne()} — PATCH a typed body to `"{getPath()}/{$id}"`.
* - {@see self::postOperation()} — POST (optionally a body) to `"{getPath()}/{$id}/{$action}"`.
* - {@see self::putSubResource()} — PUT a typed body to `"{getPath()}/{$id}/{$sub}"`, returning
* the raw decoded array (for sub-resources mapped to a class
* other than the endpoint's item class, e.g. the payment link).
*
* @template T of Resource
*/
Expand Down Expand Up @@ -63,7 +63,7 @@ protected function createOne(Payload $request): Resource
/**
* @return T
*/
protected function update(int|string $id, Payload $request): Resource
protected function updateOne(int|string $id, Payload $request): Resource
{
return $this->mapItem(
static::getItemClass(),
Expand All @@ -85,7 +85,7 @@ protected function update(int|string $id, Payload $request): Resource
*
* @return T
*/
protected function operation(int|string $id, string $action, ?Payload $request = null, ?bool $synchronized = null): Resource
protected function postOperation(int|string $id, string $action, ?Payload $request = null, ?bool $synchronized = null): Resource
{
$path = sprintf('%s/%s/%s', static::getPath(), $id, $action);
if ($synchronized ?? $this->client->isSynchronized()) {
Expand All @@ -101,7 +101,7 @@ protected function operation(int|string $id, string $action, ?Payload $request =
*
* @return array<array-key, mixed>
*/
protected function putSub(int|string $id, string $sub, Payload $request): array
protected function putSubResource(int|string $id, string $sub, Payload $request): array
{
return $this->client->put(sprintf('%s/%s/%s', static::getPath(), $id, $sub), $request);
}
Expand Down
Loading