Skip to content

Commit 9fad41c

Browse files
committed
Add CLAUDE.md; fix README test-mode note and Contributing commands
- Add a CLAUDE.md (project guidance: commands, CI matrix, architecture, the hard-won gotchas, testing conventions). - README: correct the test-mode note (no separate sandbox/test key — test mode comes from paying with a test card), and list the rector/infection/e2e:smoke commands in Contributing.
1 parent 830ffae commit 9fad41c

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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.

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ composer require kriswallsmith/buzz nyholm/psr7
3030

3131
Authenticate with your Quickpay **API key** (Quickpay manager → Settings → API user). The SDK uses
3232
the key as the HTTP Basic password with an empty username, exactly as Quickpay expects. There is no
33-
separate sandbox host — use a **test API key** to run in test mode.
33+
separate sandbox host or test key — a payment becomes a *test* payment (`test_mode: true`) when it's
34+
paid with a [test card](https://learn.quickpay.net/tech-talk/appendixes/test/).
3435

3536
```php
3637
use Setono\Quickpay\Client\Client;
@@ -235,10 +236,13 @@ $client = new Client(
235236

236237
```bash
237238
composer install
238-
composer phpunit # tests
239-
composer analyse # PHPStan (level max)
240-
composer check-style # ECS
241-
composer fix-style # ECS, auto-fixing
239+
composer phpunit # tests
240+
composer analyse # PHPStan (level max)
241+
composer check-style # ECS
242+
composer fix-style # ECS, auto-fixing
243+
composer rector -- --dry-run # Rector modernization (CI runs --dry-run)
244+
vendor/bin/infection # mutation testing (min covered MSI 70%)
245+
composer e2e:smoke # real-API smoke test (needs QUICKPAY_API_KEY — see examples/e2e/)
242246
```
243247

244248
Live API tests are skipped unless `QUICKPAY_LIVE=1` and `QUICKPAY_API_KEY` are set. (Quickpay has no

0 commit comments

Comments
 (0)