diff --git a/CLAUDE.md b/CLAUDE.md index 3c59c80..3fe4a36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index ff67e33..2e9127f 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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); } /** @@ -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 diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index b89d520..9aaf5aa 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -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 */ @@ -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(), @@ -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()) { @@ -101,7 +101,7 @@ protected function operation(int|string $id, string $action, ?Payload $request = * * @return array */ - 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); }