From 73289acff975061e8668890cb2221caa79e97c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 11:33:34 +0200 Subject: [PATCH 1/2] =?UTF-8?q?README:=20teach=20the=20Quickpay=20model=20?= =?UTF-8?q?=E2=80=94=20install=20text,=20TOC,=20Concepts,=20callback=20bes?= =?UTF-8?q?t=20practices,=20recipes;=20docblock=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Installation: what php-http/discovery's Composer plugin does (auto-installs a PSR-18/17 implementation), what to do when it's disabled, and that a missing implementation only fails at runtime (NotFoundException). - Table of contents. - "Concepts — five things to know about Quickpay": the two keys / no sandbox, amounts in minor units, a payment as a ledger of operations (states, accepted, qp_status_code families), async operations, and that the continue_url redirect is not proof of payment. - Link flow: the continue_url warning as a call-out (promoted from the e2e README). - "Handling callbacks robustly": body = whole payment, retries (24×) → idempotency on operation ids, respond fast, per-payment ordering, 403/400 semantics, accountId/body — sourced from Quickpay's callback docs. - Recipes: "Checkout, end to end" (find-or-create → link → callback → capture) and "Wiring in Symfony" (services.yaml with env keys + cache). - Link docblock: createLink() returns only {url}; the full link lives on the payment. PaymentsEndpoint: the async-202 explanation lives once on the class, the four operation methods point to it. Refs #10 (findings 11–14). --- README.md | 163 ++++++++++++++++++++++- src/Client/Endpoint/PaymentsEndpoint.php | 61 +++------ src/Response/Payment/Link.php | 15 ++- 3 files changed, 191 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index ba1ebee..a8d672a 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,68 @@ Built on PSR-18 (HTTP client), PSR-17 (factories) and PSR-7 (messages), discover [`php-http/discovery`](https://github.com/php-http/discovery), so it works with any compliant HTTP client. +- [Installation](#installation) +- [Concepts — five things to know about Quickpay](#concepts--five-things-to-know-about-quickpay) +- [Usage](#usage) + - [Payment link flow](#payment-link-flow-redirect-the-customer-to-the-payment-window) · + [Capturing, refunding, cancelling](#capturing-refunding-cancelling) · + [Reading what happened to a payment](#reading-what-happened-to-a-payment) · + [Updating a payment](#updating-a-payment) · + [Reading and listing payments](#reading-and-listing-payments) + - [Callbacks](#callbacks) — verifying, framework snippets, [handling them robustly](#handling-callbacks-robustly), testing your endpoint + - [Testing code that uses the SDK](#testing-code-that-uses-the-sdk) · + [Accessing fields the SDK doesn't model](#accessing-fields-the-sdk-doesnt-model) · + [Calling endpoints the SDK doesn't model](#calling-endpoints-the-sdk-doesnt-model) · + [Error handling](#error-handling) +- [Recipes](#recipes) — checkout end to end, wiring in Symfony +- [Production usage](#production-usage) · [Contributing](#contributing) · [End-to-end testing](#end-to-end-testing) + ## Installation ```bash composer require setono/quickpay-php-sdk ``` -You also need a PSR-18 client and a PSR-17 factory if your project doesn't already provide them, e.g.: +The SDK needs a PSR-18 HTTP client and PSR-17 factories, found at runtime via `php-http/discovery`. +If your project has none yet, Composer's `php-http/discovery` plugin offers to install one for you +during `composer require` (it picks e.g. `symfony/http-client` + `nyholm/psr7`). If you have +disabled that plugin (`allow-plugins`), or prefer to choose, require an implementation yourself: ```bash -composer require kriswallsmith/buzz nyholm/psr7 +composer require symfony/http-client nyholm/psr7 # or guzzlehttp/guzzle, kriswallsmith/buzz, ... ``` +Without one, `new Client(...)` throws `Http\Discovery\Exception\NotFoundException` ("No PSR-18 +clients found") the first time it runs — the install itself succeeds, so make sure a client is +present before you deploy. + +## Concepts — five things to know about Quickpay + +1. **Two keys.** The **API key** (manager → Settings → API user) authenticates API calls (`Client`). + The **private key** (Settings → Integration) signs callbacks (`CallbackHandler`). They are not + interchangeable, and neither is a "test" key: there is no sandbox. A payment is a *test* payment + (`test_mode: true`) purely because it was paid with a + [test card](https://learn.quickpay.net/tech-talk/appendixes/test/); test callbacks are real and + signed exactly like production. +2. **Amounts are integers in the smallest currency unit** — `1000` is 10.00 DKK / EUR / …, on + requests and responses alike. The SDK does no currency math. +3. **A payment is a ledger of operations.** `POST /payments` creates an empty payment (state + `initial`). Everything that happens afterwards — authorize, capture, refund, cancel — is an + *operation* appended to `payment.operations`, each with `pending` (still being processed) and a + `qp_status_code` (`"20000"` = approved; `3xxxx` = 3-D Secure / SCA needed, `4xxxx` = rejected or + invalid, `5xxxx` = gateway/acquirer error — see [Errors and codes](https://learn.quickpay.net/tech-talk/appendixes/errors/)). + The payment's own fields summarize the ledger: `accepted` (an authorization was approved by the + acquirer), `state` (`initial` → `pending` while an authorization is in flight → `new` once + authorized, or `rejected`; `processed` after capture/cancel/refund activity), and `balance` + (captured minus refunded). Read the operations, not just the state — the SDK's + [helpers](#reading-what-happened-to-a-payment) do that for you. +4. **Operations are asynchronous by default.** `capture()` etc. return `202 Accepted` with the new + operation still `pending: true`; the outcome arrives via the callback (or by re-fetching). Pass + `synchronized: true` (per call or as a client default) to wait for the result instead. +5. **The redirect back to your shop proves nothing.** When the customer returns to `continue_url` + the request carries no payment data and can arrive *before* the callback. Treat the signed + [callback](#callbacks) — or a `getById()` on your side — as the source of truth for "paid". + ## Usage Authenticate with your Quickpay **API key** (Quickpay manager → Settings → API user). The SDK uses @@ -82,6 +132,11 @@ header('Location: ' . $link->url); If the order is cancelled before the customer pays, invalidate the link with `$client->payments()->deleteLink($payment->id)`. +> **The `continueUrl` redirect is not proof of payment.** It carries no data and can arrive before the +> callback. On that page, either wait for the verified callback to have marked the order paid, or +> re-fetch the payment (`getById()`) and check `accepted` / the operations yourself — never mark an +> order paid just because the customer landed there. + ### Capturing, refunding, cancelling ```php @@ -286,6 +341,29 @@ $callback = $handler->handleRaw($body, $checksum, ResourceType::Payment->value); $handler->handleRaw($body . 'tampered', $checksum, ResourceType::Payment->value); // throws InvalidChecksumException ``` +#### Handling callbacks robustly + +A few facts about Quickpay's callback service shape how your endpoint should behave (all from +[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 + 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`). + So make the endpoint **idempotent**: keep the operation ids you have processed per payment + (`Operation::$id` is a per-payment sequence number) and skip ones you've seen. Answer `200` even + when the payment is already in the state the callback describes. +- **Answer fast, then work.** Verify, persist, respond — and do the slow parts (emails, ERP sync) + asynchronously. A slow endpoint looks like a failure and gets retried. +- **Order is only guaranteed per payment.** Callbacks for the same payment arrive in operation order; + callbacks for different payments arrive in any order. +- **Respond `403` on a bad checksum and `400` on an unknown resource type**, as in the example above. + A `2xx` on a bad checksum tells an attacker their forgery was accepted; a `5xx` on a genuinely bad + request just earns you 24 retries of the same bad request. +- The verified `Callback` carries `accountId` — useful if one endpoint serves several Quickpay + accounts — and the raw `body` you can store for auditing. + ### Accessing fields the SDK doesn't model The SDK types the most commonly used fields; every response object also exposes the full decoded @@ -371,6 +449,87 @@ try { } ``` +## Recipes + +### Checkout, end to end + +Putting the pieces together — create (idempotently), send the customer to pay, learn the outcome from +the callback, capture on shipment: + +```php +use Setono\Quickpay\Request\Payment\CaptureRequest; +use Setono\Quickpay\Request\Payment\CreateLinkRequest; +use Setono\Quickpay\Request\Payment\CreatePaymentRequest; +use Setono\Quickpay\Request\Payment\Shopsystem; + +// 1. Checkout: one Quickpay payment per order, safe to re-run +$payment = $client->payments()->findByOrderId($order->number) + ?? $client->payments()->create(new CreatePaymentRequest( + orderId: $order->number, // 4–20 chars, unique per account + currency: $order->currency, + variables: ['order_uuid' => $order->uuid], // anything you want back on the callback + shopsystem: new Shopsystem('acme/shop', '2.3.4'), + )); + +$link = $client->payments()->createLink($payment->id, new CreateLinkRequest( + amount: $order->total, // smallest unit + continueUrl: $urls->thankYou($order), + cancelUrl: $urls->checkout($order), + callbackUrl: $urls->quickpayCallback(), +)); +// → redirect the customer to $link->url + +// 2. Callback endpoint: the source of truth (see "Callbacks") +$callback = $handler->handleRaw($request->getContent(), $checksum, $resourceType); +if ($callback->isPayment()) { + $payment = $callback->payment(); + $order = $orders->findByQuickpayVariables($payment->variables()); // or by $payment->orderId + foreach ($payment->operations as $operation) { + if ($order->hasProcessedOperation($operation->id)) { + continue; // retried delivery — idempotent + } + if ($operation->isOfType(OperationType::Authorize) && $operation->isApproved()) { + $order->markAuthorized($payment->id, $operation->amount); + } + // ... capture / refund / cancel likewise, or simply store $payment->capturedAmount() etc. + $order->recordOperation($operation->id); + } +} +// respond 200 + +// 3. Shipping: capture (synchronously here, so a failure surfaces right away) +$payment = $client->payments()->capture($order->quickpayId, new CaptureRequest($order->total), synchronized: true); +if (!$payment->latestOperation()?->isApproved()) { + // rejected — see qpStatusMsg / aqStatusMsg +} +``` + +### Wiring in Symfony + +The client and handler are plain, immutable services; give them their keys from the environment and +a Valinor cache from the app's cache directory: + +```yaml +# config/services.yaml +services: + CuyZ\Valinor\Cache\FileSystemCache: + arguments: ['%kernel.cache_dir%/valinor'] + + Setono\Quickpay\Client\ClientInterface: + class: Setono\Quickpay\Client\Client + arguments: + $apiKey: '%env(QUICKPAY_API_KEY)%' + $cache: '@CuyZ\Valinor\Cache\FileSystemCache' + + Setono\Quickpay\Callback\CallbackHandler: + arguments: + $privateKey: '%env(QUICKPAY_PRIVATE_KEY)%' + $cache: '@CuyZ\Valinor\Cache\FileSystemCache' +``` + +The PSR-18 client and PSR-17 factories are discovered automatically; to use the app's own (e.g. +Symfony's `Psr18Client`), pass them explicitly via `$httpClient`, `$requestFactory`, `$streamFactory`. + ## Production usage Valinor's mapping/normalization is fast but benefits from a cache in production. Hand the client diff --git a/src/Client/Endpoint/PaymentsEndpoint.php b/src/Client/Endpoint/PaymentsEndpoint.php index 5f39de6..79fe483 100644 --- a/src/Client/Endpoint/PaymentsEndpoint.php +++ b/src/Client/Endpoint/PaymentsEndpoint.php @@ -24,6 +24,16 @@ * state, acceptance, creation time, etc. — see {@see self::findByOrderId()} for the common * "look up the payment for an order" case. * + * The operation methods (`authorize`, `capture`, `refund`, `cancel`) share one contract: Quickpay + * processes them asynchronously by default and answers `202 Accepted`. The returned {@see Payment} + * is then only a snapshot taken when the operation was QUEUED — the new operation has + * `pending: true` and no `qpStatusCode` yet, and fields such as `state` and `balance` still hold + * their pre-operation values; they say nothing about the outcome. Confirm the result via the + * callback, or by re-fetching with {@see self::getById()} until the operation's `pending` is + * `false` (a `qpStatusCode` of `"20000"` then means approved). Pass `$synchronized = true` to make + * Quickpay wait and return the completed transaction instead; `null` (the default) falls back to + * the client-wide `synchronized` flag set on the `Client` constructor. + * * @extends CollectionEndpoint */ final class PaymentsEndpoint extends CollectionEndpoint @@ -75,16 +85,10 @@ public function updatePayment(int $id, UpdatePaymentRequest $request): Payment /** * POST `/payments/{id}/authorize`. The body is required — the live API validates `amount` as - * required, so a request-less authorize can never succeed. Pass `$synchronized = true` to wait for and return the - * completed transaction instead of the default asynchronous (pending) response; `null` (the - * default) falls back to the client-wide `synchronized` flag set on the `Client` constructor. - * - * When run asynchronously the API answers `202 Accepted` and the returned {@see Payment} is only - * a snapshot taken when the operation was QUEUED: the new operation has `pending: true` and no - * `qpStatusCode` yet, and fields such as `state` and `balance` still hold their pre-operation - * values — they say nothing about the outcome. Confirm the result via the callback, or by - * re-fetching with {@see self::getById()} until the operation's `pending` is `false` (then a - * `qpStatusCode` of `"20000"` means approved). + * 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`. */ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $synchronized = null): Payment { @@ -92,16 +96,9 @@ public function authorize(int $id, AuthorizePaymentRequest $request, ?bool $sync } /** - * POST `/payments/{id}/capture`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response; `null` (the default) falls - * back to the client-wide `synchronized` flag set on the `Client` constructor. - * - * When run asynchronously the API answers `202 Accepted` and the returned {@see Payment} is only - * a snapshot taken when the operation was QUEUED: the new operation has `pending: true` and no - * `qpStatusCode` yet, and fields such as `state` and `balance` still hold their pre-operation - * values — they say nothing about the outcome. Confirm the result via the callback, or by - * re-fetching with {@see self::getById()} until the operation's `pending` is `false` (then a - * `qpStatusCode` of `"20000"` means approved). + * 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`. */ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = null): Payment { @@ -109,16 +106,8 @@ public function capture(int $id, CaptureRequest $request, ?bool $synchronized = } /** - * POST `/payments/{id}/refund`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response; `null` (the default) falls - * back to the client-wide `synchronized` flag set on the `Client` constructor. - * - * When run asynchronously the API answers `202 Accepted` and the returned {@see Payment} is only - * a snapshot taken when the operation was QUEUED: the new operation has `pending: true` and no - * `qpStatusCode` yet, and fields such as `state` and `balance` still hold their pre-operation - * values — they say nothing about the outcome. Confirm the result via the callback, or by - * re-fetching with {@see self::getById()} until the operation's `pending` is `false` (then a - * `qpStatusCode` of `"20000"` means approved). + * 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`. */ public function refund(int $id, RefundRequest $request, ?bool $synchronized = null): Payment { @@ -126,16 +115,8 @@ public function refund(int $id, RefundRequest $request, ?bool $synchronized = nu } /** - * POST `/payments/{id}/cancel`. Pass `$synchronized = true` to wait for and return the completed - * transaction instead of the default asynchronous (pending) response; `null` (the default) falls - * back to the client-wide `synchronized` flag set on the `Client` constructor. - * - * When run asynchronously the API answers `202 Accepted` and the returned {@see Payment} is only - * a snapshot taken when the operation was QUEUED: the new operation has `pending: true` and no - * `qpStatusCode` yet, and fields such as `state` still hold their pre-operation values — they - * say nothing about the outcome. Confirm the result via the callback, or by re-fetching with - * {@see self::getById()} until the operation's `pending` is `false` (then a `qpStatusCode` of - * `"20000"` means approved). + * 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`. */ public function cancel(int $id, ?bool $synchronized = null): Payment { diff --git a/src/Response/Payment/Link.php b/src/Response/Payment/Link.php index be5c03c..e5b9699 100644 --- a/src/Response/Payment/Link.php +++ b/src/Response/Payment/Link.php @@ -7,13 +7,16 @@ use Setono\Quickpay\Response\Resource; /** - * The payment window link returned by `PUT /payments/{id}/link`, and the `link` object nested on a - * {@see Payment}. + * The payment window link: the `link` object nested on a {@see Payment}, and what + * `PUT /payments/{id}/link` ({@see \Setono\Quickpay\Client\Endpoint\PaymentsEndpoint::createLink()}) + * returns. * - * `$url` is the URL the customer should be redirected to in order to complete the payment. Any field - * the SDK does not model is reachable via {@see Resource::$raw} (only populated when the `Link` is - * returned directly from `createLink()`; when nested on a `Payment`, reach it via the payment's - * `$raw['link']`). + * `$url` is the URL the customer should be redirected to in order to complete the payment — and it + * is the ONLY field the `createLink()` response carries (the API answers `{"url": "..."}`), so on + * that instance every other property is `null` and `$raw` is just `['url' => ...]`. The full link + * (`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). */ final class Link extends Resource { From 410f7e00475c60bc5c48903913d485b8dc475c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 17 Aug 2026 11:59:13 +0200 Subject: [PATCH 2/2] README: drop the shipped-fake reference (#15 closed); add a short 'Testing your integration' recipe pointing at the PSR-18 seam --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a8d672a..a821e87 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,10 @@ client. [Updating a payment](#updating-a-payment) · [Reading and listing payments](#reading-and-listing-payments) - [Callbacks](#callbacks) — verifying, framework snippets, [handling them robustly](#handling-callbacks-robustly), testing your endpoint - - [Testing code that uses the SDK](#testing-code-that-uses-the-sdk) · - [Accessing fields the SDK doesn't model](#accessing-fields-the-sdk-doesnt-model) · + - [Accessing fields the SDK doesn't model](#accessing-fields-the-sdk-doesnt-model) · [Calling endpoints the SDK doesn't model](#calling-endpoints-the-sdk-doesnt-model) · [Error handling](#error-handling) -- [Recipes](#recipes) — checkout end to end, wiring in Symfony +- [Recipes](#recipes) — checkout end to end, testing your integration, wiring in Symfony - [Production usage](#production-usage) · [Contributing](#contributing) · [End-to-end testing](#end-to-end-testing) ## Installation @@ -504,6 +503,16 @@ if (!$payment->latestOperation()?->isApproved()) { } ``` +### Testing your integration + +`Client` and the endpoints are `final` on purpose: the seam for tests is the **HTTP client**. Inject +whatever PSR-18 fake your stack already has — `php-http/mock-client`, Symfony's `MockHttpClient` +behind `Psr18Client`, Guzzle's `MockHandler` — via `new Client('test-key', httpClient: $fake)` and +feed it captured JSON bodies (`$client->getLastResponse()` gives you real ones). Your tests then +exercise the SDK's real request building, mapping and error handling. Response DTOs have public +constructors, so code that merely *consumes* a `Payment` can be tested with hand-built objects; for +callbacks see [Testing your callback endpoint](#testing-your-callback-endpoint). + ### Wiring in Symfony The client and handler are plain, immutable services; give them their keys from the environment and