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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ 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)).
- 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).
- `CreatePaymentRequest` validates `orderId` at construction (`ORDER_ID_PATTERN`: 4–20 characters
of letters, digits, space, `.`, `_`, `-` — verified live) and throws the new
`Setono\Quickpay\Exception\InvalidArgumentException` (an SPL `InvalidArgumentException` that is
also a `QuickpayException`; `CollectionRequestOptions` now throws it too);
`CreateLinkRequest::$paymentMethods` accepts a list and joins it ([#27](https://github.com/Setono/quickpay-php-sdk/pull/27)).
- README: table of contents, "Concepts", callback best practices, framework snippets, recipes, and
sections on the escape hatch and error handling
([#12](https://github.com/Setono/quickpay-php-sdk/pull/12)–[#20](https://github.com/Setono/quickpay-php-sdk/pull/20)).
Expand Down
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,17 @@ present before you deploy.
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.
authorized, or `rejected`; `processed` after capture/cancel/refund activity — and `pending` again
for the moment *any* asynchronous operation is in flight, with `balance` still showing its
pre-operation value), 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. A declined
operation is not retried by Quickpay: e.g. a declined auto-capture leaves the payment `new` /
authorized, and it is up to you to capture again.
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.
`synchronized: true` (per call or as a client default) to wait for the result instead — and note
that a *declined* synchronized operation is still a `2xx`: the decline is on the operation
(`latestOperationOfType(...)?->isDeclined()`), not an exception.
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".
Expand Down Expand Up @@ -104,7 +109,11 @@ echo $payment->state()?->name; // PaymentState enum (or null for an unknown valu

Fields the Quickpay API unconditionally requires (verified against the live API) are required
constructor arguments — `orderId` and `currency` here, `amount` on links and operations. Every other
field is optional and simply omitted from the request JSON when unset.
field is optional and simply omitted from the request JSON when unset. The one format rule the SDK
enforces locally is `orderId`: 4–20 characters of letters, digits, space, `.`, `_` and `-`
(`CreatePaymentRequest::ORDER_ID_PATTERN`) — Quickpay rejects anything else, but under a message
that only mentions the length, so the SDK throws a `Setono\Quickpay\Exception\InvalidArgumentException`
naming the actual rule before any request is made.

### Payment link flow (redirect the customer to the payment window)

Expand All @@ -128,6 +137,9 @@ header('Location: ' . $link->url);

`continueUrl` / `cancelUrl` are where the customer is sent after a successful / cancelled payment;
`callbackUrl` is the server-to-server URL Quickpay POSTs the result to (see [Callbacks](#callbacks)).
To restrict the payment methods the window offers, pass `paymentMethods:` either as Quickpay's
comma-separated string (`'creditcard,!amex,mobilepay'` — `!` excludes) or as a list
(`['creditcard', '!amex', 'mobilepay']`), which the SDK joins for you.
If the order is cancelled before the customer pays, invalidate the link with
`$client->payments()->deleteLink($payment->id)`.

Expand Down Expand Up @@ -186,13 +198,36 @@ $latest?->type(); // OperationType enum (or null for an un

$payment->operation(3); // ?Operation by id
$payment->operationsOfType(OperationType::Capture); // list<Operation>

// The questions that decide an order's status:
$payment->latestApprovedOperation(); // newest APPROVED op of any type — where the money actually is;
// a trailing rejected/pending attempt does not mask it
$payment->latestOperationOfType(OperationType::Capture); // newest capture, whatever its outcome
$payment->hasApprovedOperation(OperationType::Capture); // was anything ever captured?
$payment->hasPendingOperation(OperationType::Refund); // is a refund in flight? (guard before issuing another)

// Reading a single operation's outcome:
$op->hasOutcome(); // no longer pending — the status codes mean something now
$op->isApproved(); // qp_status_code 20000
$op->isDeclined(); // completed but NOT approved: rejected (4xxxx), error (5xxxx), auth required (3xxxx)
```

Only **approved** operations count towards the amounts — pending or rejected ones don't. When an
operation was run asynchronously (the default), poll `getById()` or wait for the callback until
`hasPendingOperation()` is `false` before trusting the amounts. Operation ids are numbered per
payment (`1`, `2`, …), which makes them a good idempotency key when handling callbacks.

A **declined synchronized operation is not an exception**: `capture(..., synchronized: true)` on a
card that declines returns a `2xx` payment whose new operation `isDeclined()`. Check it:

```php
$payment = $client->payments()->capture($id, new CaptureRequest($amount), synchronized: true);
$capture = $payment->latestOperationOfType(OperationType::Capture);
if (null === $capture || !$capture->isApproved()) {
// declined — $capture?->qpStatusMsg / ->aqStatusMsg say why; Quickpay will not retry it for you
}
```

### Updating a payment

Before a payment is authorized you can update some of its fields (`PATCH /payments/{id}`). Note the
Expand Down
18 changes: 18 additions & 0 deletions src/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Setono\Quickpay\Exception;

/**
* Thrown when a request object is built with a value the Quickpay API is known to reject — e.g. an
* `order_id` outside 4–20 characters, or a page number below 1 — so the mistake fails fast at the
* call site with a message naming the rule, instead of after a network round-trip (sometimes with
* an API message that blames the wrong thing).
*
* Extends the SPL {@see \InvalidArgumentException} so existing catch sites keep working, and
* implements {@see QuickpayException} so `catch (QuickpayException $e)` nets it too.
*/
final class InvalidArgumentException extends \InvalidArgumentException implements QuickpayException
{
}
4 changes: 3 additions & 1 deletion src/Request/CollectionRequestOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Setono\Quickpay\Request;

use Setono\Quickpay\Exception\InvalidArgumentException;

/**
* Immutable options for a paginated list request: which page and how many entries per page.
*
Expand Down Expand Up @@ -74,7 +76,7 @@ public function toArray(): array
private static function assertAtLeastOne(string $name, int $value): void
{
if ($value < 1) {
throw new \InvalidArgumentException(sprintf('Expected %s to be at least 1, got %d.', $name, $value));
throw new InvalidArgumentException(sprintf('Expected %s to be at least 1, got %d.', $name, $value));
}
}
}
30 changes: 29 additions & 1 deletion src/Request/Payment/CreateLinkRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
final class CreateLinkRequest extends Payload
{
/**
* The payment methods / groups the window may offer, as the comma-separated string Quickpay
* expects (e.g. `"creditcard,mobilepay"`, or `"creditcard,!amex"` to exclude one). Built from
* the constructor's `$paymentMethods`, which also accepts a list — see there.
*/
public ?string $paymentMethods;

/**
* @param string|list<string>|null $paymentMethods the methods/groups to offer — either
* Quickpay's comma-separated string (`"creditcard,!amex,mobilepay"`) or a list of them
* (`['creditcard', '!amex', 'mobilepay']`), which is joined for you (a `(string)` cast of
* a list would send the literal `Array` and reject every payment); an empty list means
* "not set"
* @param array<string, mixed> $brandingConfig
*/
public function __construct(
Expand All @@ -31,7 +43,7 @@ public function __construct(
public ?string $cancelUrl = null,
public ?string $callbackUrl = null,
public ?string $refererUrl = null,
public ?string $paymentMethods = null,
string|array|null $paymentMethods = null,
public ?bool $autoFee = null,
public ?bool $autoCapture = null,
public ?string $autoCaptureAt = null,
Expand All @@ -48,5 +60,21 @@ public function __construct(
public ?bool $invoiceAddressSelection = null,
public ?bool $shippingAddressSelection = null,
) {
$this->paymentMethods = self::joinPaymentMethods($paymentMethods);
}

/**
* Normalize a list of payment methods/groups to the comma-separated string Quickpay expects;
* a string passes through and an empty list becomes `null`.
*
* @param string|list<string>|null $paymentMethods
*/
private static function joinPaymentMethods(string|array|null $paymentMethods): ?string
{
if (is_array($paymentMethods)) {
return [] === $paymentMethods ? null : implode(',', $paymentMethods);
}

return $paymentMethods;
}
}
39 changes: 35 additions & 4 deletions src/Request/Payment/CreatePaymentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,36 @@

namespace Setono\Quickpay\Request\Payment;

use Setono\Quickpay\Exception\InvalidArgumentException;
use Setono\Quickpay\Request\Payload;

/**
* Body for `POST /payments`.
*
* `orderId` (4–20 characters) and `currency` are required — verified against the live API, which
* rejects a create missing either (`order_id` length validation / `currency: "is missing"`). All
* other fields are optional. `shopsystem` lets an integration identify itself (name/version) on the
* payment.
* `orderId` and `currency` are required — verified against the live API, which rejects a create
* missing either. All other fields are optional. `shopsystem` lets an integration identify itself
* (name/version) on the payment.
*
* `orderId` is validated at construction (see {@see self::ORDER_ID_PATTERN}): the live API accepts
* 4–20 characters from letters, digits, space, `.`, `_` and `-` — and rejects everything else
* (`/`, `#`, `:`, `@`, `+`, `,`, `(`, `&`, `=`, `!`, `*`, `'`, `%`, `~`, tab, and any non-ASCII
* character such as `ø`) under the SAME, misleading message ("must have length between 4 and 20"),
* so a local check that names the actual rule saves a confusing round-trip. Verified live 2026-08-17.
* (The property stays a plain public string; only the constructor validates.)
*/
final class CreatePaymentRequest extends Payload
{
/**
* What the live API accepts as an `order_id`: 4–20 characters, each a letter, digit, space, `.`,
* `_` or `-`.
*/
public const ORDER_ID_PATTERN = '/^[A-Za-z0-9 ._-]{4,20}$/';

/**
* @param array<string, mixed> $variables a free-form key/value map stored with the payment
* @param list<BasketItem> $basket
*
* @throws InvalidArgumentException if `$orderId` does not match {@see self::ORDER_ID_PATTERN}
*/
public function __construct(
public string $orderId,
Expand All @@ -32,5 +47,21 @@ public function __construct(
public ?Shipping $shipping = null,
public ?Shopsystem $shopsystem = null,
) {
self::assertValidOrderId($orderId);
}

/**
* @throws InvalidArgumentException
*/
public static function assertValidOrderId(string $orderId): void
{
if (1 !== preg_match(self::ORDER_ID_PATTERN, $orderId)) {
throw new InvalidArgumentException(sprintf(
'Invalid order_id "%s": Quickpay accepts 4–20 characters consisting of letters, digits, space, ".", "_" and "-" (got %d character(s)%s).',
$orderId,
(int) preg_match_all('/./su', $orderId), // character count without requiring ext-mbstring
1 === preg_match('/^[A-Za-z0-9 ._-]*$/', $orderId) ? '' : ', including characters outside that set',
));
}
}
}
27 changes: 26 additions & 1 deletion src/Response/Payment/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,38 @@ public function isOfType(OperationType|string $type): bool
return $this->type === ($type instanceof OperationType ? $type->value : $type);
}

/**
* Whether Quickpay has finished processing the operation, i.e. it is no longer `pending` and the
* status codes describe the outcome. Until then {@see self::isApproved()} and
* {@see self::isDeclined()} are both `false` — nothing is known yet.
*/
public function hasOutcome(): bool
{
return !$this->pending;
}

/**
* Whether the operation has completed successfully: it is no longer pending AND Quickpay's
* status code is `20000` (approved). `false` for a pending operation and for every failed or
* inconclusive outcome (rejected, 3-D Secure required, gateway error, …).
*/
public function isApproved(): bool
{
return !$this->pending && self::QP_STATUS_APPROVED === $this->qpStatusCode;
return $this->hasOutcome() && self::QP_STATUS_APPROVED === $this->qpStatusCode;
}

/**
* Whether the operation completed WITHOUT being approved: rejected by the acquirer (`4xxxx`),
* a gateway/acquirer error (`5xxxx`) or — for an authorize — an authentication step still
* required (`3xxxx`, 3-D Secure / SCA); read `qpStatusCode` / `qpStatusMsg` (and the acquirer's
* `aqStatusCode` / `aqStatusMsg`) for the reason. `false` while the operation is still pending.
*
* Note that a synchronized capture/refund/cancel that is declined is still a `2xx` response —
* the decline lives on the operation, and this is how you read it:
* `$payment->latestOperationOfType(OperationType::Capture)?->isDeclined()`.
*/
public function isDeclined(): bool
{
return $this->hasOutcome() && !$this->isApproved();
}
}
Loading
Loading