|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## What this is |
| 6 | + |
| 7 | +A PHP SDK for the [Quickpay API](https://learn.quickpay.net/tech-talk/api/). Library — not an application. Requires PHP >= 8.1. Installed by consumers as `setono/quickpay-php-sdk`; root namespace `Setono\Quickpay\`. Scope is deliberately narrow: the `payments` resource, the `/ping` health check, the payment-window **link** flow, and **callback** (webhook) verification. |
| 8 | + |
| 9 | +## Commands |
| 10 | + |
| 11 | +Composer scripts (run via `composer <script>`): |
| 12 | + |
| 13 | +- `composer phpunit` — PHPUnit suite (`tests/`). Single test: `vendor/bin/phpunit --filter <name>`. |
| 14 | +- `composer analyse` — PHPStan `level: max`. Needs `phpstan ^2.2` because `phpstan.neon.dist` includes Valinor's two PHPStan extensions (type inference + pure-error suppression for `registerTransformer`). |
| 15 | +- `composer check-style` / `composer fix-style` — ECS (sylius-labs standard). |
| 16 | +- `vendor/bin/rector` (`--dry-run` in CI) — modernization, `UP_TO_PHP_81`. It skips `ReadOnlyPropertyRector` for `src/Response` and `src/Request` (see DTO sections). |
| 17 | +- `vendor/bin/infection` — mutation testing. Gates: `minMsi 50`, `minCoveredMsi 70`. The Stryker dashboard upload is branch-gated in `infection.json.dist` (`stryker.badge`) — currently `1.x`; update it when the working branch changes. |
| 18 | +- `composer e2e:smoke` / `e2e:listen` / `e2e:create` / `e2e:operate` — the dev-only end-to-end harness (`examples/e2e/`), documented in `examples/e2e/README.md`. It hits the **real** API, so it loads a gitignored `.env.local` (`QUICKPAY_API_KEY`, `QUICKPAY_PRIVATE_KEY`). `e2e:smoke` is the quickest real check (ping → create → link → get, charges nothing). |
| 19 | + |
| 20 | +CI (`.github/workflows/build.yaml`, branch `1.x`): coding-standards, dependency-analysis, static-analysis and unit-tests run PHP **8.1–8.5 × lowest/highest**; code-coverage (Codecov, `codecov/codecov-action@v5`) and mutation-tests (Stryker) run on 8.3. When changing dependency constraints, check both `lowest` and `highest` still resolve on 8.5. |
| 21 | + |
| 22 | +## Architecture |
| 23 | + |
| 24 | +**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. |
| 25 | + |
| 26 | +**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). |
| 27 | + |
| 28 | +**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. |
| 29 | + |
| 30 | +**Response DTOs (`src/Response/`)** — entry DTOs extend `Resource` (`public array $raw`, stamped by the endpoint after mapping). `final class` (NOT `final readonly`, so `$raw` can be set post-construction — hence the rector skip). **Type only the stable, commonly-used fields; reach everything else via `$raw`** (original snake_case keys). `Payment`, `Operation`, `Link`, `Metadata`, and `Collection<T>` (passive carrier; pagination logic lives on the endpoint). **GOTCHA learned the hard way: a single mis-typed *nested* field fails the WHOLE resource mapping** (Valinor is strict; the `$raw` fallback only protects fields you DON'T type). E.g. `Metadata::$is3dSecure` is `?bool` even though the API docs label it "string" — the live API returns a boolean. Verify nested field types against real responses, not the docs, and keep the typed subset conservative. Dates are `?\DateTimeImmutable` (`supportDateFormats('Y-m-d\TH:i:sP', 'Y-m-d\TH:i:s.uP')`). |
| 31 | + |
| 32 | +**Callbacks (`src/Callback/`)** — `CallbackValidator` verifies `hash_hmac('sha256', rawBody, privateKey)` against the `QuickPay-Checksum-Sha256` header using `hash_equals`. The **private key (Settings → Integration) is NOT the API key**, and the HMAC is over the **raw, un-re-encoded body**. `CallbackHandler::handle(ServerRequestInterface)` is the primary entry point (reads body + `QuickPay-*` headers); `handleRaw(rawBody, checksum, resourceType)` is for raw pieces (superglobals, or a framework that consumed the body). Both return a verified `Callback` value object (`body`, `type`, `accountId`, `apiVersion`). The `QuickPay-Resource-Type` header is **required and strictly validated** against the `ResourceType` enum (`Payment`/`Subscription`) — unknown/missing throws `InvalidCallbackException`. A callback is NOT assumed to be a payment: check `isPayment()` / `type`, then `payment()` (guarded) or `toArray()`. |
| 33 | + |
| 34 | +**Exceptions (`src/Exception/`)** — `QuickpayException` (marker interface on every SDK throw) → `ResponseAwareException` (lazy-parses `getMessageText()`/`getErrorCode()`/`getValidationErrors()` from Quickpay's `{message, errors, error_code}` body) → `ClientErrorException` (4xx) / `ServerErrorException` (5xx) → concrete: `Unauthorized` (401), `Forbidden` (402/403), `NotFound` (404), `MethodNotAllowed` (405), `Conflict` (409), `Validation` (400/422), `TooManyRequests` (429), `InternalServerError` (5xx), `UnexpectedStatusCode` (fallback). `MalformedResponseException` → `MappingException` for 2xx bodies that fail to decode/map. Non-response throws: `InvalidUrlException` (host pinning), `InvalidCallbackException` (bad callback body / resource type), `InvalidChecksumException` (bad signature). |
| 35 | + |
| 36 | +**Enums (`src/Enum/`)** — `PaymentState` and `OperationType` are **non-exhaustive**: the DTO keeps the value as a `string` and exposes a `state()`/`type()` helper via `tryFrom`, so a server-added value never throws. `ResourceType` is the deliberate exception — it is **strict** (the callback handler rejects anything not in it). |
| 37 | + |
| 38 | +## Key facts / gotchas |
| 39 | + |
| 40 | +- **No sandbox, no test key.** Consumers use their real API key; a payment becomes a *test* payment (`test_mode: true`) only when paid with a [test card](https://learn.quickpay.net/tech-talk/appendixes/test/). Test callbacks are real and signed exactly like production. |
| 41 | +- **Valinor wiring:** `camelCaseKeys` on input, `camelToSnake` + null/`[]`-strip (the `Payload` transformer) on output. We do NOT register Valinor converters (they leak memory); `$raw` is stamped inline in `Endpoint::mapItem()`. |
| 42 | +- `examples/e2e/` is committed dev tooling and is intentionally OUT of the phpstan/ecs/rector paths (`src` + `tests` only) — check those scripts with `php -l`. Secrets live only in the gitignored `.env.local`; never commit them. |
| 43 | + |
| 44 | +## Testing |
| 45 | + |
| 46 | +`tests/` autoloads under the same `Setono\Quickpay\` namespace (`autoload-dev`). The HTTP layer is faked with `tests/TestDouble/ScriptedHttpClient.php` (a URI-keyed fake — no mocking framework); build clients via `QuickpayTestCase::client()`, and load captured payloads from `tests/Fixtures/`. New code needs tests (the mutation gate is `minCoveredMsi 70`). `tests/Client/LiveClientTest.php` hits the real API and is skipped unless `QUICKPAY_LIVE=1` and `QUICKPAY_API_KEY` are set. |
0 commit comments