Skip to content

Latest commit

 

History

History
46 lines (27 loc) · 11.8 KB

File metadata and controls

46 lines (27 loc) · 11.8 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A PHP SDK for the Quickpay 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.

Commands

Composer scripts (run via composer <script>):

  • composer phpunit — PHPUnit suite (tests/). Single test: vendor/bin/phpunit --filter <name>.
  • 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).
  • composer check-style / composer fix-style — ECS (sylius-labs standard).
  • vendor/bin/rector (--dry-run in CI) — modernization, UP_TO_PHP_81. It skips ReadOnlyPropertyRector for src/Response and src/Request (see DTO sections).
  • 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.
  • 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).

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. .github/workflows/backwards-compatibility-check.yaml runs Roave BC check on every PR against its base branch (PHP 8.4, current tool) — a flagged break must either be reverted or be a deliberate, documented major-version change. When changing dependency constraints, check both lowest and highest still resolve on 8.5.

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, 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(), delete() (the body-carrying ones take Payload|array $body = [] — a typed DTO, or a plain array sent as given for unmodeled endpoints; never null: an empty body goes out as {}, which the live API accepts even on cancel (verified) while it rejects []; authorize still requires amount; 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 MappingErrorMappingException) → 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 = nullnull 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.

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')).

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().

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). MalformedResponseExceptionMappingException for 2xx bodies that fail to decode/map. Non-response throws: InvalidUrlException (host pinning), InvalidCallbackException (bad callback body / resource type), InvalidChecksumException (bad signature).

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).

Key facts / gotchas

  • 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. Test callbacks are real and signed exactly like production.
  • 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(). A Payload with no set fields normalizes to [], which the API rejects (body: "is invalid") — Client::send() rewrites it to {}.
  • 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.

Testing

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.