diff --git a/CLAUDE.md b/CLAUDE.md index 773eb58..3c59c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ CI (`.github/workflows/build.yaml`, branch `1.x`): coding-standards, dependency- **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). -**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`, and `$amount` on `CreateLinkRequest`/`CaptureRequest`/`RefundRequest`/`AuthorizePaymentRequest`. 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. +**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` (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')`). diff --git a/src/Request/Payment/Address.php b/src/Request/Payment/Address.php index 9f6773c..f450326 100644 --- a/src/Request/Payment/Address.php +++ b/src/Request/Payment/Address.php @@ -9,6 +9,9 @@ /** * An invoice or shipping address attached to a payment. Sent as a nested object; property names are * converted to the snake_case keys Quickpay expects (e.g. `zipCode` → `zip_code`). + * + * All fields are genuinely optional — verified against the live API, which accepts a partial + * address (e.g. only `name`) and stores the omitted fields as `null`. */ final class Address extends Payload { diff --git a/src/Request/Payment/BasketItem.php b/src/Request/Payment/BasketItem.php index 243ec17..3449484 100644 --- a/src/Request/Payment/BasketItem.php +++ b/src/Request/Payment/BasketItem.php @@ -9,15 +9,22 @@ /** * A single line in a payment's basket. Property names are converted to the snake_case keys Quickpay * expects (e.g. `itemNo` → `item_no`). + * + * All five fields are required — verified against the live API, which treats a basket item as + * all-or-nothing: any partial item is rejected with a per-field `"is missing"` error for every + * omitted field. (An all-empty item is also dangerous on the wire: it would serialize as `[]`, a + * shape that triggers an HTTP 500 on Quickpay's side — required fields make that unrepresentable.) + * + * `itemPrice` is per item, in the payment's currency expressed in the smallest unit. */ final class BasketItem extends Payload { public function __construct( - public ?int $qty = null, - public ?string $itemNo = null, - public ?string $itemName = null, - public ?int $itemPrice = null, - public ?float $vatRate = null, + public int $qty, + public string $itemNo, + public string $itemName, + public int $itemPrice, + public float $vatRate, ) { } } diff --git a/src/Request/Payment/Shipping.php b/src/Request/Payment/Shipping.php index b8d6754..9a6d15f 100644 --- a/src/Request/Payment/Shipping.php +++ b/src/Request/Payment/Shipping.php @@ -10,6 +10,12 @@ * Shipping/delivery details attached to a payment. Sent as a nested object on create/update; * property names are converted to the snake_case keys Quickpay expects (e.g. `trackingNumber` → * `tracking_number`). `amount` is in the payment's currency expressed in the smallest unit. + * + * All fields are genuinely optional — verified against the live API, which accepts a partial + * shipping object (e.g. only `amount`) and stores the omitted fields as `null`. Beware `method`: + * when present it is validated server-side against a fixed set of values (`home_delivery` is + * accepted; e.g. `pickup` is rejected with `"does not have a valid value"`) — check the Quickpay + * docs for the accepted list. */ final class Shipping extends Payload { diff --git a/tests/Client/Endpoint/PaymentsEndpointTest.php b/tests/Client/Endpoint/PaymentsEndpointTest.php index ca92f11..f601c34 100644 --- a/tests/Client/Endpoint/PaymentsEndpointTest.php +++ b/tests/Client/Endpoint/PaymentsEndpointTest.php @@ -8,6 +8,7 @@ use Setono\Quickpay\Enum\PaymentState; use Setono\Quickpay\QuickpayTestCase; use Setono\Quickpay\Request\Payment\AuthorizePaymentRequest; +use Setono\Quickpay\Request\Payment\BasketItem; use Setono\Quickpay\Request\Payment\CaptureRequest; use Setono\Quickpay\Request\Payment\CreateLinkRequest; use Setono\Quickpay\Request\Payment\CreatePaymentRequest; @@ -207,6 +208,25 @@ public function it_can_request_a_synchronized_operation(): void self::assertSame(self::BASE . '/payments/1234/capture?synchronized', (string) $http->sentRequests[0]->getUri()); } + #[Test] + public function it_serializes_basket_items_as_a_list_of_snake_case_objects(): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/payments', self::fixture('payment.json')); + + $request = new CreatePaymentRequest('order-0001', 'DKK'); + $request->basket = [new BasketItem(qty: 2, itemNo: 'sku-1', itemName: 'Widget', itemPrice: 500, vatRate: 0.25)]; + $this->client($http)->payments()->create($request); + + /** @var array $body */ + $body = json_decode((string) $http->sentRequests[0]->getBody(), true, flags: \JSON_THROW_ON_ERROR); + // The basket must stay a JSON LIST of complete snake_cased objects — the live API rejects + // partial items and 500s on `[]`-shaped ones. + self::assertSame( + [['qty' => 2, 'item_no' => 'sku-1', 'item_name' => 'Widget', 'item_price' => 500, 'vat_rate' => 0.25]], + $body['basket'], + ); + } + #[Test] public function it_sends_an_empty_json_object_for_an_empty_payload(): void {