Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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')`).

Expand Down
3 changes: 3 additions & 0 deletions src/Request/Payment/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
17 changes: 12 additions & 5 deletions src/Request/Payment/BasketItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
6 changes: 6 additions & 0 deletions src/Request/Payment/Shipping.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
20 changes: 20 additions & 0 deletions tests/Client/Endpoint/PaymentsEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed> $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
{
Expand Down
Loading