From 4e3a809289392b258f6fe7155c99a82f5ee87607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 14:17:47 +0200 Subject: [PATCH 1/4] Per-operation callback URL on authorize/capture/refund/cancel; extra request headers; Link::$autoCapture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quickpay POSTs the callback of an API-issued operation to the ACCOUNT-WIDE callback URL (empty by default), not to the payment link's callback_url — so integrations that only set the link's URL never hear about their captures, refunds and cancels. The swagger lists a QuickPay-Callback-Url request header on authorize/capture/refund/cancel (and renew/session/fraud-report) that routes that one operation's callback; verified live in #22. - PaymentsEndpoint::authorize()/capture()/refund()/cancel() take ?string $callbackUrl (after $synchronized); ResourceEndpoint::postOperation() turns it into the header. Client::CALLBACK_URL_HEADER names it. - ClientInterface/Client: get()/post()/put()/patch()/delete() accept an optional array $headers (the SDK's own headers always win), so unmodeled operations can send it too. - Link::$autoCapture (?bool) and $autoCaptureAt (?string) typed. - README: "where does the callback for a capture/refund/cancel go"; callback best practices note. examples/e2e/operate.php passes the listener's /callback when QUICKPAY_CALLBACK_BASE (or --callback-base=) is set. Closes #22. --- CHANGELOG.md | 5 ++ README.md | 21 ++++- examples/e2e/operate.php | 42 ++++++++-- src/Client/Client.php | 48 ++++++++--- src/Client/ClientInterface.php | 19 +++-- src/Client/Endpoint/PaymentsEndpoint.php | 34 +++++--- src/Client/Endpoint/ResourceEndpoint.php | 18 +++- src/Response/Payment/Link.php | 6 ++ tests/Client/ClientTest.php | 33 ++++++++ .../Client/Endpoint/PaymentsEndpointTest.php | 83 +++++++++++++++++++ 10 files changed, 268 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00e5d4..7253244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ Developer-experience follow-ups from the v1.0.0 review ([#10](https://github.com - `Payment::variables()`, `Payment::$deadlineAt`, `Payment::$acquirer`; `CreatePaymentRequest::$shopsystem` (`Shopsystem` payload); the SDK version in the `User-Agent` (`Client::version()`) ([#19](https://github.com/Setono/quickpay-php-sdk/pull/19)). +- Per-operation callback URL: `authorize()`/`capture()`/`refund()`/`cancel()` take a `callbackUrl` + argument, sent as the `QuickPay-Callback-Url` header (`Client::CALLBACK_URL_HEADER`) so Quickpay + notifies that URL for the operation instead of the account-wide callback URL; the low-level + `get()`/`post()`/`put()`/`patch()`/`delete()` accept extra request headers; `Link::$autoCapture` / + `$autoCaptureAt` are typed ([#26](https://github.com/Setono/quickpay-php-sdk/pull/26), closes #22). - Outcome predicates and status views: `Operation::hasOutcome()` / `isDeclined()`; `Payment::latestOperationOfType()`, `latestApprovedOperation()`, `hasApprovedOperation(?type)`, and `hasPendingOperation()` now takes an optional type ([#27](https://github.com/Setono/quickpay-php-sdk/pull/27), closes #25). diff --git a/README.md b/README.md index ec0adbe..a2760c5 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,23 @@ $client->payments()->capture($payment->id, new CaptureRequest(1000)); // waits ( $client->payments()->refund($payment->id, new RefundRequest(250), synchronized: false); // fire-and-forget ``` +**Where does the callback for a capture/refund/cancel go?** Not to the `callbackUrl` you set on the +payment link — Quickpay POSTs the callback of an *API-issued* operation to the **account-wide** +callback URL (manager → Settings → Integration), which is empty by default. So a shop that only ever +set the link's `callbackUrl` never hears about its captures, refunds and cancels. Pass `callbackUrl:` +on the operation and Quickpay notifies that URL for it (sent as the `QuickPay-Callback-Url` header; +verified live — the operation's own `callbackUrl` reflects it and the callback arrives there): + +```php +$client->payments()->capture($payment->id, new CaptureRequest(1000), callbackUrl: 'https://shop.example/callback'); +$client->payments()->refund($payment->id, new RefundRequest(250), callbackUrl: 'https://shop.example/callback'); +$client->payments()->cancel($payment->id, callbackUrl: 'https://shop.example/callback'); +``` + +Typically that's the same endpoint as the link's `callbackUrl` (plus whatever token your framework +needs to route it back to the order). For unmodeled operations use the header directly: +`$client->post('payments/1/renew', [], [Client::CALLBACK_URL_HEADER => $url])`. + ### Reading what happened to a payment Everything that happened to a payment is recorded in its `operations` (authorize, capture, refund, @@ -420,7 +437,9 @@ A few facts about Quickpay's callback service shape how your endpoint should beh [their callback docs](https://learn.quickpay.net/tech-talk/api/callback/), verified in the e2e harness): - **Every operation triggers a callback, and the body is the whole payment as it exists after the - change** (equivalent to `GET /payments/{id}`) — not a "capture succeeded" event. Work out what + change** (equivalent to `GET /payments/{id}`) — not a "capture succeeded" event. Callbacks for + operations *you* issue via the API go to the account-wide callback URL unless you pass + `callbackUrl:` on the operation (see [Capturing, refunding, cancelling](#capturing-refunding-cancelling)). Work out what happened from the operations: `$payment->latestOperation()` is usually the one that fired it, but compare against what you've already recorded rather than assuming. - **Deliveries are retried up to 24 times** with growing delays until you answer `2xx` (or `302`/`303`). diff --git a/examples/e2e/operate.php b/examples/e2e/operate.php index 4e940f9..4b79800 100644 --- a/examples/e2e/operate.php +++ b/examples/e2e/operate.php @@ -8,7 +8,9 @@ * php examples/e2e/operate.php [amount] * * capture/refund/cancel run with ?synchronized so the API returns the completed transaction for - * immediate feedback; the asynchronous callback still lands in the listener. + * immediate feedback. Their callbacks go to the ACCOUNT-WIDE callback url unless told otherwise, so + * when QUICKPAY_CALLBACK_BASE is set (or --callback-base= given) the listener's /callback is passed + * as the per-operation callback url and the asynchronous callback lands there. */ use Setono\Quickpay\Request\Payment\CaptureRequest; @@ -17,9 +19,15 @@ require __DIR__ . '/bootstrap.php'; -$action = $argv[1] ?? ''; -$id = isset($argv[2]) ? (int) $argv[2] : 0; -$amount = isset($argv[3]) ? (int) $argv[3] : null; +$positional = array_values(array_filter( + array_slice($argv, 1), + static fn (string $arg): bool => !str_starts_with($arg, '--'), +)); + +$action = $positional[0] ?? ''; +$id = isset($positional[1]) ? (int) $positional[1] : 0; +$amount = isset($positional[2]) ? (int) $positional[2] : null; +$callbackUrl = e2e_optional_callback_url($argv); if ('' === $action || $id <= 0) { e2e_fail('Usage: php examples/e2e/operate.php [amount]'); @@ -29,14 +37,34 @@ $payment = match ($action) { 'get' => $payments->getById($id), - 'capture' => $payments->capture($id, new CaptureRequest(e2e_require_amount($amount)), synchronized: true), - 'refund' => $payments->refund($id, new RefundRequest(e2e_require_amount($amount)), synchronized: true), - 'cancel' => $payments->cancel($id, synchronized: true), + 'capture' => $payments->capture($id, new CaptureRequest(e2e_require_amount($amount)), synchronized: true, callbackUrl: $callbackUrl), + 'refund' => $payments->refund($id, new RefundRequest(e2e_require_amount($amount)), synchronized: true, callbackUrl: $callbackUrl), + 'cancel' => $payments->cancel($id, synchronized: true, callbackUrl: $callbackUrl), default => e2e_fail(sprintf('Unknown action "%s". Use one of: get, capture, refund, cancel.', $action)), }; e2e_print_payment($payment); +/** + * The listener's /callback url when a callback base is configured, else null (account-wide default). + * + * @param list $argv + */ +function e2e_optional_callback_url(array $argv): ?string +{ + $base = ''; + foreach ($argv as $arg) { + if (str_starts_with($arg, '--callback-base=')) { + $base = substr($arg, strlen('--callback-base=')); + } + } + if ('' === $base) { + $base = e2e_env('QUICKPAY_CALLBACK_BASE', false); + } + + return '' === $base ? null : rtrim($base, '/') . '/callback'; +} + function e2e_require_amount(?int $amount): int { if (null === $amount || $amount <= 0) { diff --git a/src/Client/Client.php b/src/Client/Client.php index fb0a791..bf4f3d1 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -39,6 +39,17 @@ final class Client implements ClientInterface */ public const API_VERSION = 'v10'; + /** + * Request header accepted by the payment operations (`authorize`, `capture`, `refund`, `cancel`, + * and the unmodeled `renew`, `session`, `fraud-report`): the URL Quickpay POSTs THIS operation's + * callback to, overriding the account-wide callback URL (manager → Settings → Integration). + * Without it, API-issued operations notify the account-wide URL only — which is empty by + * default, so a shop that only ever set `callback_url` on the payment link never hears about + * its captures/refunds/cancels. The `PaymentsEndpoint` operation methods take a `$callbackUrl` + * argument that sets this header for you. + */ + public const CALLBACK_URL_HEADER = 'QuickPay-Callback-Url'; + private const HOST = 'https://api.quickpay.net'; private ?RequestInterface $lastRequest = null; @@ -137,31 +148,31 @@ public function request(RequestInterface $request): ResponseInterface return $response; } - public function get(string $uri, array $query = []): array + public function get(string $uri, array $query = [], array $headers = []): array { - $request = $this->requestFactory->createRequest('GET', $this->resolveUrl($uri, $query)); + $request = self::withHeaders($this->requestFactory->createRequest('GET', $this->resolveUrl($uri, $query)), $headers); return self::decodeJson($request, $this->request($request)); } - public function post(string $uri, Payload|array $body = []): array + public function post(string $uri, Payload|array $body = [], array $headers = []): array { - return $this->send('POST', $uri, $body); + return $this->send('POST', $uri, $body, $headers); } - public function put(string $uri, Payload|array $body = []): array + public function put(string $uri, Payload|array $body = [], array $headers = []): array { - return $this->send('PUT', $uri, $body); + return $this->send('PUT', $uri, $body, $headers); } - public function patch(string $uri, Payload|array $body = []): array + public function patch(string $uri, Payload|array $body = [], array $headers = []): array { - return $this->send('PATCH', $uri, $body); + return $this->send('PATCH', $uri, $body, $headers); } - public function delete(string $uri): array + public function delete(string $uri, array $headers = []): array { - $request = $this->requestFactory->createRequest('DELETE', $this->resolveUrl($uri)); + $request = self::withHeaders($this->requestFactory->createRequest('DELETE', $this->resolveUrl($uri)), $headers); return self::decodeJson($request, $this->request($request)); } @@ -248,12 +259,13 @@ static function (Payload $payload, callable $next): array { /** * @param Payload|array $body + * @param array $headers * * @return array */ - private function send(string $method, string $uri, Payload|array $body): array + private function send(string $method, string $uri, Payload|array $body, array $headers = []): array { - $request = $this->requestFactory->createRequest($method, $this->resolveUrl($uri)); + $request = self::withHeaders($this->requestFactory->createRequest($method, $this->resolveUrl($uri)), $headers); // Both shapes go through the SDK's normalizer: a Payload gets snake_cased + null-stripped by // the Payload transformer; a plain array is encoded as given (its keys untouched), but any @@ -326,6 +338,18 @@ private function resolveUrl(string $uri, array $query = []): string return $url; } + /** + * @param array $headers + */ + private static function withHeaders(RequestInterface $request, array $headers): RequestInterface + { + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request; + } + /** * The credential-leak guard: refuse any absolute URL that does not point at the Quickpay API * host on its default port. Applied both when the SDK builds a URL from a path diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index abac65a..10e80b6 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -48,13 +48,15 @@ public function request(RequestInterface $request): ResponseInterface; * GET the given URI and return the decoded JSON body. * * @param array $query + * @param array $headers extra request headers for this call (the SDK's own — + * `Authorization`, `Accept-Version`, `Accept`, `User-Agent` — always win) * * @return array * * @throws TransportException if the request could not be sent / no response was received (wraps the PSR-18 exception) * @throws QuickpayException if the response is non-2xx, or the body is not valid JSON */ - public function get(string $uri, array $query = []): array; + public function get(string $uri, array $query = [], array $headers = []): array; /** * POST to `$uri` and return the decoded JSON body. @@ -66,50 +68,57 @@ public function get(string $uri, array $query = []): array; * an empty body is sent as the empty JSON object `{}`, which the API accepts. * * @param Payload|array $body + * @param array $headers extra request headers for this call — e.g. + * `[Client::CALLBACK_URL_HEADER => 'https://shop.example/callback']` to route an + * operation's callback (the SDK's own headers always win) * * @return array * * @throws TransportException if the request could not be sent / no response was received (wraps the PSR-18 exception) * @throws QuickpayException if the response is non-2xx, or the body is not valid JSON */ - public function post(string $uri, Payload|array $body = []): array; + public function post(string $uri, Payload|array $body = [], array $headers = []): array; /** * PUT to `$uri` and return the decoded JSON body. Used for creating (or updating) a payment * link. The `$body` is normalized exactly as in {@see self::post()}. * * @param Payload|array $body + * @param array $headers extra request headers for this call * * @return array * * @throws TransportException if the request could not be sent / no response was received (wraps the PSR-18 exception) * @throws QuickpayException if the response is non-2xx, or the body is not valid JSON */ - public function put(string $uri, Payload|array $body = []): array; + public function put(string $uri, Payload|array $body = [], array $headers = []): array; /** * PATCH to `$uri` and return the decoded JSON body. Used for updating a payment. The `$body` is * normalized exactly as in {@see self::post()}. * * @param Payload|array $body + * @param array $headers extra request headers for this call * * @return array * * @throws TransportException if the request could not be sent / no response was received (wraps the PSR-18 exception) * @throws QuickpayException if the response is non-2xx, or the body is not valid JSON */ - public function patch(string $uri, Payload|array $body = []): array; + public function patch(string $uri, Payload|array $body = [], array $headers = []): array; /** * DELETE `$uri` and return the decoded JSON body — `[]` for a `204 No Content` response, which * is what Quickpay's DELETE endpoints (e.g. `DELETE /payments/{id}/link`) answer. * + * @param array $headers extra request headers for this call + * * @return array * * @throws TransportException if the request could not be sent / no response was received (wraps the PSR-18 exception) * @throws QuickpayException if the response is non-2xx, or a non-empty body is not valid JSON */ - public function delete(string $uri): array; + public function delete(string $uri, array $headers = []): array; /** * Health check — `GET /ping`. Returns `true` on a 2xx response (a non-2xx response throws). diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index 79fe483..ae3d97f 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -34,6 +34,12 @@ * Quickpay wait and return the completed transaction instead; `null` (the default) falls back to * the client-wide `synchronized` flag set on the `Client` constructor. * + * They also take a `$callbackUrl`: Quickpay POSTs the callback for an API-issued operation to the + * ACCOUNT-WIDE callback URL (manager → Settings → Integration) — not to the `callbackUrl` you set on + * the payment link, and the account-wide one is empty by default. Pass the URL you want notified + * (typically the same endpoint as the link's) and it is sent as the `QuickPay-Callback-Url` header + * for that one operation; the resulting operation's `callbackUrl` reflects it. Verified live. + * * @extends CollectionEndpoint */ final class PaymentsEndpoint extends CollectionEndpoint @@ -88,39 +94,41 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment * required, so a request-less authorize can never succeed. Note this puts you in PCI scope * (card data); most integrations authorize through the payment window ({@see self::createLink()}). * Async by default — see the class docblock for what the response does (and doesn't) tell you - * and for `$synchronized`. + * and for `$synchronized` / `$callbackUrl`. */ - public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null): Payment + public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'authorize', $request, $synchronized); + return $this->postOperation($id, 'authorize', $request, $synchronized, $callbackUrl); } /** * POST `/payments/{id}/capture` — capture (part of) an authorized amount; several partial * captures are possible. Async by default — see the class docblock for what the response does - * (and doesn't) tell you and for `$synchronized`. + * (and doesn't) tell you and for `$synchronized` / `$callbackUrl`. */ - public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null): Payment + public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'capture', $request, $synchronized); + return $this->postOperation($id, 'capture', $request, $synchronized, $callbackUrl); } /** * POST `/payments/{id}/refund` — refund (part of) the captured balance. Async by default — see - * the class docblock for what the response does (and doesn't) tell you and for `$synchronized`. + * the class docblock for what the response does (and doesn't) tell you and for `$synchronized` / + * `$callbackUrl`. */ - public function refund(int $id, RefundRequest $request, ?bool $synchronized = null): Payment + public function refund(int $id, RefundRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'refund', $request, $synchronized); + return $this->postOperation($id, 'refund', $request, $synchronized, $callbackUrl); } /** - * POST `/payments/{id}/cancel` — void the authorization (no body). Async by default — see the - * class docblock for what the response does (and doesn't) tell you and for `$synchronized`. + * POST `/payments/{id}/cancel` — void the authorization (no parameters; an empty `{}` body is + * sent). Async by default — see the class docblock for what the response does (and doesn't) + * tell you and for `$synchronized` / `$callbackUrl`. */ - public function cancel(int $id, ?bool $synchronized = null): Payment + public function cancel(int $id, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'cancel', [], $synchronized); + return $this->postOperation($id, 'cancel', [], $synchronized, $callbackUrl); } /** diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index 2949b52..bf6233f 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -4,6 +4,7 @@ namespace Setono\Quickpay\Client\Endpoint; +use Setono\Quickpay\Client\Client; use Setono\Quickpay\Request\Payload; use Setono\Quickpay\Response\Resource; @@ -85,18 +86,29 @@ protected function updateOne(int|string $id, Payload $request): Resource * (its final state) instead. When `$synchronized` is `null` the client-wide default * ({@see \Setono\Quickpay\Client\ClientInterface::isSynchronized()}) applies. * + * When `$callbackUrl` is given it is sent as the `QuickPay-Callback-Url` header + * ({@see Client::CALLBACK_URL_HEADER}), so Quickpay POSTs this operation's callback there + * instead of to the account-wide callback URL. + * * @param Payload|array $request * * @return T */ - protected function postOperation(int|string $id, string $action, Payload|array $request = [], ?bool $synchronized = null): Resource - { + protected function postOperation( + int|string $id, + string $action, + Payload|array $request = [], + ?bool $synchronized = null, + ?string $callbackUrl = null, + ): Resource { $path = sprintf('%s/%s/%s', static::getPath(), $id, $action); if ($synchronized ?? $this->client->isSynchronized()) { $path .= '?synchronized'; } - return $this->mapItem(static::getItemClass(), $this->client->post($path, $request)); + $headers = null === $callbackUrl ? [] : [Client::CALLBACK_URL_HEADER => $callbackUrl]; + + return $this->mapItem(static::getItemClass(), $this->client->post($path, $request, $headers)); } /** diff --git a/src/Response/Payment/Link.php b/src/Response/Payment/Link.php index e5b9699..98a681d 100644 --- a/src/Response/Payment/Link.php +++ b/src/Response/Payment/Link.php @@ -17,6 +17,10 @@ * (`amount`, `continueUrl`, `callbackUrl`, …) is available on the payment itself, e.g. via * `getById()` → `$payment->link`; fields the SDK does not model are in the payment's `$raw['link']` * (nested instances are not `$raw`-stamped). + * + * `$autoCapture` tells whether Quickpay captures the payment itself right after authorization (the + * link was created with `autoCapture: true`), `$autoCaptureAt` the ISO-8601 time of a scheduled + * capture — so an integration knows whether it must capture or Quickpay already did/will. */ final class Link extends Resource { @@ -27,6 +31,8 @@ public function __construct( public readonly ?string $continueUrl = null, public readonly ?string $cancelUrl = null, public readonly ?string $callbackUrl = null, + public readonly ?bool $autoCapture = null, + public readonly ?string $autoCaptureAt = null, ) { } } diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index 8420ad8..6a22db8 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -529,6 +529,39 @@ public function catching_quickpay_exception_nets_a_transport_failure_too(): void $client->ping(); } + + #[Test] + public function it_sends_extra_headers_on_every_helper(): void + { + $http = (new ScriptedHttpClient()) + ->on(self::BASE . '/a', '{}')->on(self::BASE . '/b', '{}')->on(self::BASE . '/c', '{}')->on(self::BASE . '/d', '{}')->on(self::BASE . '/e', '', 204) + ; + $client = $this->client($http); + + $client->get('a', [], ['X-Test' => 'get']); + $client->post('b', [], ['X-Test' => 'post']); + $client->put('c', [], ['X-Test' => 'put']); + $client->patch('d', [], ['X-Test' => 'patch']); + $client->delete('e', ['X-Test' => 'delete']); + + self::assertSame(['get', 'post', 'put', 'patch', 'delete'], array_map( + static fn (\Psr\Http\Message\RequestInterface $r): string => $r->getHeaderLine('X-Test'), + $http->sentRequests, + )); + } + + #[Test] + public function the_sdk_headers_win_over_extra_headers(): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/ping', self::fixture('ping.json')); + + $this->client($http)->get('ping', [], ['Authorization' => 'Bearer nope', 'Accept-Version' => 'v9', Client::CALLBACK_URL_HEADER => 'https://shop.example/cb']); + + $sent = $http->sentRequests[0]; + self::assertSame('Basic ' . base64_encode(':' . self::API_KEY), $sent->getHeaderLine('Authorization')); + self::assertSame('v10', $sent->getHeaderLine('Accept-Version')); + self::assertSame('https://shop.example/cb', $sent->getHeaderLine(Client::CALLBACK_URL_HEADER)); + } } /** diff --git a/tests/Client/Endpoint/PaymentsEndpointTest.php b/tests/Client/Endpoint/PaymentsEndpointTest.php index 87eb4ff..54e7bbd 100644 --- a/tests/Client/Endpoint/PaymentsEndpointTest.php +++ b/tests/Client/Endpoint/PaymentsEndpointTest.php @@ -5,7 +5,9 @@ namespace Setono\Quickpay\Client\Endpoint; use CuyZ\Valinor\Mapper\MappingError; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Setono\Quickpay\Client\Client; use Setono\Quickpay\Enum\PaymentState; use Setono\Quickpay\Exception\MappingException; use Setono\Quickpay\QuickpayTestCase; @@ -464,4 +466,85 @@ public function it_sends_the_shopsystem_on_create(): void (string) $http->sentRequests[0]->getBody(), ); } + + /** + * @param callable(PaymentsEndpoint): Payment $operation + */ + #[Test] + #[DataProvider('operationsWithCallbackUrl')] + public function it_sends_the_per_operation_callback_url_header(string $action, callable $operation): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/payments/1234/' . $action, self::fixture('payment.json')); + + $operation($this->client($http)->payments()); + + $sent = $http->sentRequests[0]; + // Asserted with the literal header name on purpose: it is what Quickpay's swagger documents. + self::assertSame('https://shop.example/notify?token=abc', $sent->getHeaderLine('QuickPay-Callback-Url')); + // The SDK's own headers are still there. + self::assertSame('v10', $sent->getHeaderLine('Accept-Version')); + } + + /** + * @return iterable + */ + public static function operationsWithCallbackUrl(): iterable + { + $url = 'https://shop.example/notify?token=abc'; + + yield 'authorize' => ['authorize', static fn (PaymentsEndpoint $p): Payment => $p->authorize(1234, new AuthorizePaymentRequest(amount: 1000), callbackUrl: $url)]; + yield 'capture' => ['capture', static fn (PaymentsEndpoint $p): Payment => $p->capture(1234, new CaptureRequest(1000), callbackUrl: $url)]; + yield 'refund' => ['refund', static fn (PaymentsEndpoint $p): Payment => $p->refund(1234, new RefundRequest(250), callbackUrl: $url)]; + yield 'cancel' => ['cancel', static fn (PaymentsEndpoint $p): Payment => $p->cancel(1234, callbackUrl: $url)]; + } + + #[Test] + public function it_sends_no_callback_url_header_by_default(): void + { + $http = (new ScriptedHttpClient()) + ->on(self::BASE . '/payments/1234/capture', self::fixture('payment.json')) + ->on(self::BASE . '/payments/1234/cancel?synchronized', self::fixture('payment.json')) + ; + $payments = $this->client($http)->payments(); + + $payments->capture(1234, new CaptureRequest(1000)); + $payments->cancel(1234, synchronized: true); + + self::assertFalse($http->sentRequests[0]->hasHeader(Client::CALLBACK_URL_HEADER)); + self::assertFalse($http->sentRequests[1]->hasHeader(Client::CALLBACK_URL_HEADER)); + } + + #[Test] + public function it_combines_the_callback_url_with_synchronized(): void + { + $http = (new ScriptedHttpClient())->on(self::BASE . '/payments/1234/refund?synchronized', self::fixture('payment.json')); + + $this->client($http)->payments()->refund(1234, new RefundRequest(250), synchronized: true, callbackUrl: 'https://shop.example/notify'); + + $sent = $http->sentRequests[0]; + self::assertSame(self::BASE . '/payments/1234/refund?synchronized', (string) $sent->getUri()); + self::assertSame('https://shop.example/notify', $sent->getHeaderLine(Client::CALLBACK_URL_HEADER)); + } + + #[Test] + public function it_maps_the_link_auto_capture_fields(): void + { + $http = (new ScriptedHttpClient())->on( + self::BASE . '/payments/1', + '{"id":1,"merchant_id":1,"order_id":"o-1","currency":"DKK","state":"new","operations":[],' + . '"link":{"url":"https://payment.quickpay.net/payments/x","amount":1000,"auto_capture":true,"auto_capture_at":"2026-09-01T00:00:00Z"}}', + ); + + $link = $this->client($http)->payments()->getById(1)->link; + + self::assertNotNull($link); + self::assertTrue($link->autoCapture); + self::assertSame('2026-09-01T00:00:00Z', $link->autoCaptureAt); + // And absent on the plain fixture link. + $http->on(self::BASE . '/payments/1234', self::fixture('payment.json')); + $plain = $this->client($http)->payments()->getById(1234)->link; + self::assertNotNull($plain); + self::assertNull($plain->autoCapture); + self::assertNull($plain->autoCaptureAt); + } } From 6361a7c962592b64bd9112cb6b97742f546a6e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 14:28:16 +0200 Subject: [PATCH 2/4] Keep ResourceEndpoint::postOperation() BC; baseline the ClientInterface header params The Roave check flagged the optional parameters added to ClientInterface's helpers and to the overridable ResourceEndpoint::postOperation(): - postOperation() keeps its signature and delegates to a new protected postOperationWithHeaders(); PaymentsEndpoint uses the latter (empty header values are skipped, so callers can pass the callback url unconditionally). - The ClientInterface additions stay: Client is its only implementation and consumers type-hint/mock it rather than implement it (now documented on the interface). .roave-backward-compatibility-check.xml baselines exactly those five parameter additions with the reasoning inline; export-ignored. --- .gitattributes | 1 + .roave-backward-compatibility-check.xml | 19 +++++++++++ src/Client/ClientInterface.php | 8 +++++ src/Client/Endpoint/PaymentsEndpoint.php | 9 +++--- src/Client/Endpoint/ResourceEndpoint.php | 41 ++++++++++++++++++------ 5 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 .roave-backward-compatibility-check.xml diff --git a/.gitattributes b/.gitattributes index e2e3c9b..70c2dc3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ /.gitattributes export-ignore /.github export-ignore /.gitignore export-ignore +/.roave-backward-compatibility-check.xml export-ignore /CLAUDE.md export-ignore /composer-dependency-analyser.php export-ignore /ecs.php export-ignore diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml new file mode 100644 index 0000000..22b4292 --- /dev/null +++ b/.roave-backward-compatibility-check.xml @@ -0,0 +1,19 @@ + + + + + + #Parameter headers was added to Method (get|post|put|patch|delete)\(\) of class Setono\\Quickpay\\Client\\ClientInterface# + + diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index 10e80b6..db4ed84 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -12,6 +12,14 @@ use Setono\Quickpay\Exception\TransportException; use Setono\Quickpay\Request\Payload; +/** + * The SDK's HTTP layer, as seen by the endpoints and by consumers who want to type-hint or mock it. + * + * `Client` is its only implementation. Type-hint against this interface and fake it in tests, but + * do not implement it in production code: it may gain optional parameters (and, in a major version, + * methods) as the SDK grows — the backwards-compatibility check baselines exactly such additions + * (`.roave-backward-compatibility-check.xml`). + */ interface ClientInterface { /** diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index ae3d97f..9413e9f 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -4,6 +4,7 @@ namespace Setono\Quickpay\Client\Endpoint; +use Setono\Quickpay\Client\Client; use Setono\Quickpay\Request\Payment\AuthorizePaymentRequest; use Setono\Quickpay\Request\Payment\CaptureRequest; use Setono\Quickpay\Request\Payment\CreateLinkRequest; @@ -98,7 +99,7 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment */ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'authorize', $request, $synchronized, $callbackUrl); + return $this->postOperationWithHeaders($id, 'authorize', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); } /** @@ -108,7 +109,7 @@ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $sync */ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'capture', $request, $synchronized, $callbackUrl); + return $this->postOperationWithHeaders($id, 'capture', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); } /** @@ -118,7 +119,7 @@ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = */ public function refund(int $id, RefundRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'refund', $request, $synchronized, $callbackUrl); + return $this->postOperationWithHeaders($id, 'refund', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); } /** @@ -128,7 +129,7 @@ public function refund(int $id, RefundRequest $request, ?bool $synchronized = nu */ public function cancel(int $id, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperation($id, 'cancel', [], $synchronized, $callbackUrl); + return $this->postOperationWithHeaders($id, 'cancel', [], $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); } /** diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index bf6233f..796aaad 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -17,7 +17,8 @@ * - {@see self::getOne()} — GET `"{getPath()}"` or `"{getPath()}/{$id}"`. * - {@see self::createOne()} — POST a typed body. * - {@see self::updateOne()} — PATCH a typed body to `"{getPath()}/{$id}"`. - * - {@see self::postOperation()} — POST (optionally a body) to `"{getPath()}/{$id}/{$action}"`. + * - {@see self::postOperation()} — POST (optionally a body) to `"{getPath()}/{$id}/{$action}"` + * ({@see self::postOperationWithHeaders()} to add request headers). * - {@see self::putSubResource()} — PUT a typed body to `"{getPath()}/{$id}/{$sub}"`, returning * the raw decoded array (for sub-resources mapped to a class * other than the endpoint's item class, e.g. the payment link). @@ -86,29 +87,49 @@ protected function updateOne(int|string $id, Payload $request): Resource * (its final state) instead. When `$synchronized` is `null` the client-wide default * ({@see \Setono\Quickpay\Client\ClientInterface::isSynchronized()}) applies. * - * When `$callbackUrl` is given it is sent as the `QuickPay-Callback-Url` header - * ({@see Client::CALLBACK_URL_HEADER}), so Quickpay POSTs this operation's callback there - * instead of to the account-wide callback URL. + * To route the operation's callback, use {@see self::postOperationWithHeaders()} with + * {@see Client::CALLBACK_URL_HEADER}. * * @param Payload|array $request * * @return T */ - protected function postOperation( + protected function postOperation(int|string $id, string $action, Payload|array $request = [], ?bool $synchronized = null): Resource + { + return $this->postOperationWithHeaders($id, $action, $request, $synchronized, []); + } + + /** + * {@see self::postOperation()} with extra request headers — e.g. + * `[Client::CALLBACK_URL_HEADER => $url]` so Quickpay POSTs this operation's callback there + * instead of to the account-wide callback URL. (Empty values are skipped, so callers can pass + * `[Client::CALLBACK_URL_HEADER => $maybeNull]` unconditionally.) + * + * @param Payload|array $request + * @param array $headers + * + * @return T + */ + protected function postOperationWithHeaders( int|string $id, string $action, - Payload|array $request = [], - ?bool $synchronized = null, - ?string $callbackUrl = null, + Payload|array $request, + ?bool $synchronized, + array $headers, ): Resource { $path = sprintf('%s/%s/%s', static::getPath(), $id, $action); if ($synchronized ?? $this->client->isSynchronized()) { $path .= '?synchronized'; } - $headers = null === $callbackUrl ? [] : [Client::CALLBACK_URL_HEADER => $callbackUrl]; + $sent = []; + foreach ($headers as $name => $value) { + if (null !== $value && '' !== $value) { + $sent[$name] = $value; + } + } - return $this->mapItem(static::getItemClass(), $this->client->post($path, $request, $headers)); + return $this->mapItem(static::getItemClass(), $this->client->post($path, $request, $sent)); } /** From bc6d1158c8a480b1cd3b2c3f634c7bcaa07ac596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 14:31:30 +0200 Subject: [PATCH 3/4] PaymentsEndpoint: use postOperation() when no callback URL is given Keeps postOperation() on the hot path (and covered); the with-headers variant is only used when there is a header to send. --- src/Client/Endpoint/PaymentsEndpoint.php | 24 ++++++++++++++++++++---- src/Client/Endpoint/ResourceEndpoint.php | 14 +++----------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index 9413e9f..2b35cba 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -5,6 +5,7 @@ namespace Setono\Quickpay\Client\Endpoint; use Setono\Quickpay\Client\Client; +use Setono\Quickpay\Request\Payload; use Setono\Quickpay\Request\Payment\AuthorizePaymentRequest; use Setono\Quickpay\Request\Payment\CaptureRequest; use Setono\Quickpay\Request\Payment\CreateLinkRequest; @@ -99,7 +100,7 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment */ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperationWithHeaders($id, 'authorize', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + return $this->operation($id, 'authorize', $request, $synchronized, $callbackUrl); } /** @@ -109,7 +110,7 @@ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $sync */ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperationWithHeaders($id, 'capture', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + return $this->operation($id, 'capture', $request, $synchronized, $callbackUrl); } /** @@ -119,7 +120,7 @@ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = */ public function refund(int $id, RefundRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperationWithHeaders($id, 'refund', $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + return $this->operation($id, 'refund', $request, $synchronized, $callbackUrl); } /** @@ -129,7 +130,7 @@ public function refund(int $id, RefundRequest $request, ?bool $synchronized = nu */ public function cancel(int $id, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->postOperationWithHeaders($id, 'cancel', [], $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + return $this->operation($id, 'cancel', [], $synchronized, $callbackUrl); } /** @@ -150,6 +151,21 @@ public function deleteLink(int $id): void $this->deleteSubResource($id, 'link'); } + /** + * The four operations share this: `postOperation()`, plus the `QuickPay-Callback-Url` header + * when a `$callbackUrl` is given. + * + * @param Payload|array $request + */ + private function operation(int $id, string $action, Payload|array $request, ?bool $synchronized, ?string $callbackUrl): Payment + { + if (null === $callbackUrl) { + return $this->postOperation($id, $action, $request, $synchronized); + } + + return $this->postOperationWithHeaders($id, $action, $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + } + protected static function getPath(): string { return 'payments'; diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index 796aaad..398ab8e 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -102,11 +102,10 @@ protected function postOperation(int|string $id, string $action, Payload|array $ /** * {@see self::postOperation()} with extra request headers — e.g. * `[Client::CALLBACK_URL_HEADER => $url]` so Quickpay POSTs this operation's callback there - * instead of to the account-wide callback URL. (Empty values are skipped, so callers can pass - * `[Client::CALLBACK_URL_HEADER => $maybeNull]` unconditionally.) + * instead of to the account-wide callback URL. * * @param Payload|array $request - * @param array $headers + * @param array $headers * * @return T */ @@ -122,14 +121,7 @@ protected function postOperationWithHeaders( $path .= '?synchronized'; } - $sent = []; - foreach ($headers as $name => $value) { - if (null !== $value && '' !== $value) { - $sent[$name] = $value; - } - } - - return $this->mapItem(static::getItemClass(), $this->client->post($path, $request, $sent)); + return $this->mapItem(static::getItemClass(), $this->client->post($path, $request, $headers)); } /** From 2881758365e92aaa159073f7635ecbe1640cd485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 14:36:25 +0200 Subject: [PATCH 4/4] Review: $headers directly on postOperation(); drop the BC baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: no separate postOperationWithHeaders() — postOperation() gains an optional array $headers. And no .roave-backward-compatibility-check.xml: the optional-parameter additions on ClientInterface / postOperation() are accepted as a one-time red BC check for this PR (they only affect implementors of the mock-only interface / subclassers of the internal base). --- .gitattributes | 1 - .roave-backward-compatibility-check.xml | 19 ---------------- src/Client/ClientInterface.php | 3 +-- src/Client/Endpoint/PaymentsEndpoint.php | 22 ++++++------------ src/Client/Endpoint/ResourceEndpoint.php | 29 ++++++------------------ 5 files changed, 15 insertions(+), 59 deletions(-) delete mode 100644 .roave-backward-compatibility-check.xml diff --git a/.gitattributes b/.gitattributes index 70c2dc3..e2e3c9b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,6 @@ /.gitattributes export-ignore /.github export-ignore /.gitignore export-ignore -/.roave-backward-compatibility-check.xml export-ignore /CLAUDE.md export-ignore /composer-dependency-analyser.php export-ignore /ecs.php export-ignore diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml deleted file mode 100644 index 22b4292..0000000 --- a/.roave-backward-compatibility-check.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - #Parameter headers was added to Method (get|post|put|patch|delete)\(\) of class Setono\\Quickpay\\Client\\ClientInterface# - - diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index db4ed84..aa24fa3 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -17,8 +17,7 @@ * * `Client` is its only implementation. Type-hint against this interface and fake it in tests, but * do not implement it in production code: it may gain optional parameters (and, in a major version, - * methods) as the SDK grows — the backwards-compatibility check baselines exactly such additions - * (`.roave-backward-compatibility-check.xml`). + * methods) as the SDK grows. */ interface ClientInterface { diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index 2b35cba..b809947 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -5,7 +5,6 @@ namespace Setono\Quickpay\Client\Endpoint; use Setono\Quickpay\Client\Client; -use Setono\Quickpay\Request\Payload; use Setono\Quickpay\Request\Payment\AuthorizePaymentRequest; use Setono\Quickpay\Request\Payment\CaptureRequest; use Setono\Quickpay\Request\Payment\CreateLinkRequest; @@ -100,7 +99,7 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment */ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->operation($id, 'authorize', $request, $synchronized, $callbackUrl); + return $this->postOperation($id, 'authorize', $request, $synchronized, self::callbackUrlHeader($callbackUrl)); } /** @@ -110,7 +109,7 @@ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $sync */ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->operation($id, 'capture', $request, $synchronized, $callbackUrl); + return $this->postOperation($id, 'capture', $request, $synchronized, self::callbackUrlHeader($callbackUrl)); } /** @@ -120,7 +119,7 @@ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = */ public function refund(int $id, RefundRequest $request, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->operation($id, 'refund', $request, $synchronized, $callbackUrl); + return $this->postOperation($id, 'refund', $request, $synchronized, self::callbackUrlHeader($callbackUrl)); } /** @@ -130,7 +129,7 @@ public function refund(int $id, RefundRequest $request, ?bool $synchronized = nu */ public function cancel(int $id, ?bool $synchronized = null, ?string $callbackUrl = null): Payment { - return $this->operation($id, 'cancel', [], $synchronized, $callbackUrl); + return $this->postOperation($id, 'cancel', [], $synchronized, self::callbackUrlHeader($callbackUrl)); } /** @@ -152,18 +151,11 @@ public function deleteLink(int $id): void } /** - * The four operations share this: `postOperation()`, plus the `QuickPay-Callback-Url` header - * when a `$callbackUrl` is given. - * - * @param Payload|array $request + * @return array */ - private function operation(int $id, string $action, Payload|array $request, ?bool $synchronized, ?string $callbackUrl): Payment + private static function callbackUrlHeader(?string $callbackUrl): array { - if (null === $callbackUrl) { - return $this->postOperation($id, $action, $request, $synchronized); - } - - return $this->postOperationWithHeaders($id, $action, $request, $synchronized, [Client::CALLBACK_URL_HEADER => $callbackUrl]); + return null === $callbackUrl ? [] : [Client::CALLBACK_URL_HEADER => $callbackUrl]; } protected static function getPath(): string diff --git a/src/Client/Endpoint/ResourceEndpoint.php b/src/Client/Endpoint/ResourceEndpoint.php index 398ab8e..e200c9b 100644 --- a/src/Client/Endpoint/ResourceEndpoint.php +++ b/src/Client/Endpoint/ResourceEndpoint.php @@ -17,8 +17,7 @@ * - {@see self::getOne()} — GET `"{getPath()}"` or `"{getPath()}/{$id}"`. * - {@see self::createOne()} — POST a typed body. * - {@see self::updateOne()} — PATCH a typed body to `"{getPath()}/{$id}"`. - * - {@see self::postOperation()} — POST (optionally a body) to `"{getPath()}/{$id}/{$action}"` - * ({@see self::postOperationWithHeaders()} to add request headers). + * - {@see self::postOperation()} — POST (optionally a body, extra headers) to `"{getPath()}/{$id}/{$action}"`. * - {@see self::putSubResource()} — PUT a typed body to `"{getPath()}/{$id}/{$sub}"`, returning * the raw decoded array (for sub-resources mapped to a class * other than the endpoint's item class, e.g. the payment link). @@ -87,34 +86,20 @@ protected function updateOne(int|string $id, Payload $request): Resource * (its final state) instead. When `$synchronized` is `null` the client-wide default * ({@see \Setono\Quickpay\Client\ClientInterface::isSynchronized()}) applies. * - * To route the operation's callback, use {@see self::postOperationWithHeaders()} with - * {@see Client::CALLBACK_URL_HEADER}. - * - * @param Payload|array $request - * - * @return T - */ - protected function postOperation(int|string $id, string $action, Payload|array $request = [], ?bool $synchronized = null): Resource - { - return $this->postOperationWithHeaders($id, $action, $request, $synchronized, []); - } - - /** - * {@see self::postOperation()} with extra request headers — e.g. - * `[Client::CALLBACK_URL_HEADER => $url]` so Quickpay POSTs this operation's callback there - * instead of to the account-wide callback URL. + * `$headers` are extra request headers for this call — e.g. `[Client::CALLBACK_URL_HEADER => $url]` + * so Quickpay POSTs this operation's callback there instead of to the account-wide callback URL. * * @param Payload|array $request * @param array $headers * * @return T */ - protected function postOperationWithHeaders( + protected function postOperation( int|string $id, string $action, - Payload|array $request, - ?bool $synchronized, - array $headers, + Payload|array $request = [], + ?bool $synchronized = null, + array $headers = [], ): Resource { $path = sprintf('%s/%s/%s', static::getPath(), $id, $action); if ($synchronized ?? $this->client->isSynchronized()) {