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..aa24fa3 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -12,6 +12,13 @@ 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. + */ interface ClientInterface { /** @@ -48,13 +55,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 +75,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..b809947 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; @@ -34,6 +35,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 +95,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, self::callbackUrlHeader($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, self::callbackUrlHeader($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, self::callbackUrlHeader($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, self::callbackUrlHeader($callbackUrl)); } /** @@ -141,6 +150,14 @@ public function deleteLink(int $id): void $this->deleteSubResource($id, 'link'); } + /** + * @return array + */ + private static function callbackUrlHeader(?string $callbackUrl): array + { + return null === $callbackUrl ? [] : [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 2949b52..e200c9b 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; @@ -16,7 +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::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). @@ -85,18 +86,27 @@ 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. * + * `$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 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, + array $headers = [], + ): 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)); + 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); + } }