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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`).
Expand Down
42 changes: 35 additions & 7 deletions examples/e2e/operate.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* php examples/e2e/operate.php <get|capture|refund|cancel> <paymentId> [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;
Expand All @@ -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 <get|capture|refund|cancel> <paymentId> [amount]');
Expand All @@ -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<string> $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) {
Expand Down
48 changes: 36 additions & 12 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -248,12 +259,13 @@ static function (Payload $payload, callable $next): array {

/**
* @param Payload|array<string, mixed> $body
* @param array<string, string> $headers
*
* @return array<array-key, mixed>
*/
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
Expand Down Expand Up @@ -326,6 +338,18 @@ private function resolveUrl(string $uri, array $query = []): string
return $url;
}

/**
* @param array<string, string> $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
Expand Down
26 changes: 21 additions & 5 deletions src/Client/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/**
Expand Down Expand Up @@ -48,13 +55,15 @@ public function request(RequestInterface $request): ResponseInterface;
* GET the given URI and return the decoded JSON body.
*
* @param array<string, scalar|null> $query
* @param array<string, string> $headers extra request headers for this call (the SDK's own —
* `Authorization`, `Accept-Version`, `Accept`, `User-Agent` — always win)
*
* @return array<array-key, mixed>
*
* @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.
Expand All @@ -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<string, mixed> $body
* @param array<string, string> $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<array-key, mixed>
*
* @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<string, mixed> $body
* @param array<string, string> $headers extra request headers for this call
*
* @return array<array-key, mixed>
*
* @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<string, mixed> $body
* @param array<string, string> $headers extra request headers for this call
*
* @return array<array-key, mixed>
*
* @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<string, string> $headers extra request headers for this call
*
* @return array<array-key, mixed>
*
* @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).
Expand Down
Loading
Loading