diff --git a/CHANGELOG.md b/CHANGELOG.md index 359da05..f00e5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/README.md b/README.md index 8782455..ec0adbe 100644 --- a/README.md +++ b/README.md @@ -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". @@ -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) @@ -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)`. @@ -186,6 +198,18 @@ $latest?->type(); // OperationType enum (or null for an un $payment->operation(3); // ?Operation by id $payment->operationsOfType(OperationType::Capture); // list + +// 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 @@ -193,6 +217,17 @@ operation was run asynchronously (the default), poll `getById()` or wait for the `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 diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..4b3e77e --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,18 @@ +|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 $brandingConfig */ public function __construct( @@ -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, @@ -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|null $paymentMethods + */ + private static function joinPaymentMethods(string|array|null $paymentMethods): ?string + { + if (is_array($paymentMethods)) { + return [] === $paymentMethods ? null : implode(',', $paymentMethods); + } + + return $paymentMethods; } } diff --git a/src/Request/Payment/CreatePaymentRequest.php b/src/Request/Payment/CreatePaymentRequest.php index 7e39bd9..9ee3a89 100644 --- a/src/Request/Payment/CreatePaymentRequest.php +++ b/src/Request/Payment/CreatePaymentRequest.php @@ -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 $variables a free-form key/value map stored with the payment * @param list $basket + * + * @throws InvalidArgumentException if `$orderId` does not match {@see self::ORDER_ID_PATTERN} */ public function __construct( public string $orderId, @@ -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', + )); + } } } diff --git a/src/Response/Payment/Operation.php b/src/Response/Payment/Operation.php index 67a726c..82484d9 100644 --- a/src/Response/Payment/Operation.php +++ b/src/Response/Payment/Operation.php @@ -57,6 +57,16 @@ 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 @@ -64,6 +74,21 @@ public function isOfType(OperationType|string $type): bool */ 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(); } } diff --git a/src/Response/Payment/Payment.php b/src/Response/Payment/Payment.php index 1d6c718..98fddd8 100644 --- a/src/Response/Payment/Payment.php +++ b/src/Response/Payment/Payment.php @@ -23,9 +23,13 @@ * …), each with a `pending` flag and Quickpay status code. The helpers below answer the usual * questions without hand-rolling that inspection: {@see self::authorizedAmount()}, * {@see self::capturedAmount()}, {@see self::refundedAmount()}, {@see self::isCancelled()}, - * {@see self::hasPendingOperation()}, {@see self::latestOperation()}, {@see self::operation()} and - * {@see self::operationsOfType()}. Note `$accepted` is Quickpay's own "authorization accepted by - * the acquirer" flag and `$balance` its captured-minus-refunded balance. + * {@see self::hasApprovedOperation()}, {@see self::hasPendingOperation()}, + * {@see self::latestApprovedOperation()}, {@see self::latestOperationOfType()}, + * {@see self::latestOperation()}, {@see self::operation()} and {@see self::operationsOfType()}. + * "Latest" always means the highest operation id. Note `$accepted` is Quickpay's own + * "authorization accepted by the acquirer" flag and `$balance` its captured-minus-refunded balance + * — and while ANY operation is in flight `$state` reads `pending` and `$balance` still holds its + * pre-operation value. */ final class Payment extends Resource { @@ -104,14 +108,7 @@ public function operation(int $id): ?Operation */ public function latestOperation(): ?Operation { - $latest = null; - foreach ($this->operations as $operation) { - if (null === $latest || $operation->id > $latest->id) { - $latest = $operation; - } - } - - return $latest; + return self::latestOf($this->operations); } /** @@ -129,15 +126,58 @@ public function operationsOfType(OperationType|string $type): array } /** - * Whether any operation is still being processed. When Quickpay runs an operation - * asynchronously (the default) the payment returned by capture/refund/cancel is only a snapshot - * taken when the operation was queued — poll `getById()` or wait for the callback until this - * is `false` before reading the outcome. + * The most recent operation of the given type (highest id, whatever its outcome), or `null`. + * This is the one to ask "did my capture go through?": e.g. + * `$payment->latestOperationOfType(OperationType::Capture)?->isApproved()` — and + * `->isDeclined()` for the synchronized-decline case, which is a `2xx` with the decline on the + * operation. */ - public function hasPendingOperation(): bool + public function latestOperationOfType(OperationType|string $type): ?Operation + { + return self::latestOf($this->operationsOfType($type)); + } + + /** + * The most recent APPROVED operation (highest id) of any type, or `null` if nothing was approved + * yet. This is what decides where the money is: a trailing rejected or still-pending attempt + * must not mask what actually happened, so read this rather than {@see self::latestOperation()} + * when mapping a payment to a status. + */ + public function latestApprovedOperation(): ?Operation + { + return self::latestOf(array_values(array_filter( + $this->operations, + static fn (Operation $operation): bool => $operation->isApproved(), + ))); + } + + /** + * Whether an APPROVED operation exists — of the given type, or of any type when `$type` is + * `null` (e.g. `hasApprovedOperation(OperationType::Capture)`: "was anything ever captured?"). + */ + public function hasApprovedOperation(OperationType|string|null $type = null): bool { foreach ($this->operations as $operation) { - if ($operation->pending) { + if ($operation->isApproved() && (null === $type || $operation->isOfType($type))) { + return true; + } + } + + return false; + } + + /** + * Whether an operation is still being processed — of the given type, or of any type when + * `$type` is `null` (e.g. `hasPendingOperation(OperationType::Refund)`: "is a refund in flight?", + * the guard before issuing another one). When Quickpay runs an operation asynchronously (the + * default) the payment returned by capture/refund/cancel is only a snapshot taken when the + * operation was queued — poll `getById()` or wait for the callback until this is `false` before + * reading the outcome. + */ + public function hasPendingOperation(OperationType|string|null $type = null): bool + { + foreach ($this->operations as $operation) { + if ($operation->pending && (null === $type || $operation->isOfType($type))) { return true; } } @@ -186,6 +226,23 @@ public function isCancelled(): bool return false; } + /** + * The operation with the highest id — the most recent one, regardless of array order. + * + * @param list $operations + */ + private static function latestOf(array $operations): ?Operation + { + $latest = null; + foreach ($operations as $operation) { + if (null === $latest || $operation->id > $latest->id) { + $latest = $operation; + } + } + + return $latest; + } + private function approvedAmount(OperationType $type): int { $sum = 0; diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index 06b6b29..8420ad8 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -460,8 +460,8 @@ public function the_default_builders_are_fully_configured_with_or_without_a_cach self::assertSame('2026-08-17T10:00:00+00:00', $date->format(\DATE_ATOM)); $json = Client::defaultNormalizerBuilder($cache)->normalizer(\CuyZ\Valinor\Normalizer\Format::json()) - ->normalize(new CreatePaymentRequest(orderId: 'o', currency: 'DKK', textOnStatement: null)); - self::assertSame('{"order_id":"o","currency":"DKK"}', $json); + ->normalize(new CreatePaymentRequest(orderId: 'o-01', currency: 'DKK', textOnStatement: null)); + self::assertSame('{"order_id":"o-01","currency":"DKK"}', $json); } self::removeDir($dir); } diff --git a/tests/Exception/ExceptionHierarchyTest.php b/tests/Exception/ExceptionHierarchyTest.php index 474b3b4..b2dc1c5 100644 --- a/tests/Exception/ExceptionHierarchyTest.php +++ b/tests/Exception/ExceptionHierarchyTest.php @@ -49,6 +49,8 @@ public static function hierarchy(): \Generator yield 'invalid checksum is a quickpay exception' => [InvalidChecksumException::class, QuickpayException::class]; yield 'invalid callback is a quickpay exception' => [InvalidCallbackException::class, QuickpayException::class]; yield 'transport is a quickpay exception' => [TransportException::class, QuickpayException::class]; + yield 'invalid argument is a quickpay exception' => [InvalidArgumentException::class, QuickpayException::class]; + yield 'invalid argument is an SPL invalid argument' => [InvalidArgumentException::class, \InvalidArgumentException::class]; yield 'transport is still a PSR-18 client exception' => [TransportException::class, \Psr\Http\Client\ClientExceptionInterface::class]; } diff --git a/tests/Request/CollectionRequestOptionsTest.php b/tests/Request/CollectionRequestOptionsTest.php index 783df15..f414b86 100644 --- a/tests/Request/CollectionRequestOptionsTest.php +++ b/tests/Request/CollectionRequestOptionsTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Setono\Quickpay\Exception\InvalidArgumentException; final class CollectionRequestOptionsTest extends TestCase { @@ -69,4 +70,16 @@ public function the_withers_validate_too(): void $this->expectExceptionMessage('Expected $pageSize to be at least 1, got -5.'); $opts->withPageSize(-5); } + + #[Test] + public function the_validation_error_is_an_sdk_exception_and_still_an_spl_invalid_argument(): void + { + try { + new CollectionRequestOptions(0); + self::fail('Expected an exception.'); + } catch (InvalidArgumentException $e) { + // (Its hierarchy — QuickpayException + SPL InvalidArgumentException — is pinned in ExceptionHierarchyTest.) + self::assertStringContainsString('at least 1', $e->getMessage()); + } + } } diff --git a/tests/Request/Payment/CreateLinkRequestTest.php b/tests/Request/Payment/CreateLinkRequestTest.php new file mode 100644 index 0000000..3627980 --- /dev/null +++ b/tests/Request/Payment/CreateLinkRequestTest.php @@ -0,0 +1,42 @@ +paymentMethods); + } + + #[Test] + public function it_joins_a_payment_methods_list_the_way_quickpay_expects(): void + { + // A `(string)` cast of a list would send the literal "Array" — an allowlist naming one unknown + // method, which rejects every payment with nothing in the config that looks wrong. + self::assertSame('creditcard,!amex,mobilepay', (new CreateLinkRequest(amount: 1000, paymentMethods: ['creditcard', '!amex', 'mobilepay']))->paymentMethods); + self::assertSame('visa', (new CreateLinkRequest(amount: 1000, paymentMethods: ['visa']))->paymentMethods); + } + + #[Test] + public function an_empty_list_or_null_means_not_set(): void + { + self::assertNull((new CreateLinkRequest(amount: 1000, paymentMethods: []))->paymentMethods); + self::assertNull((new CreateLinkRequest(amount: 1000))->paymentMethods); + } + + #[Test] + public function the_property_stays_a_plain_string_that_can_be_reassigned(): void + { + $request = new CreateLinkRequest(amount: 1000, paymentMethods: ['visa']); + $request->paymentMethods = 'visa,mastercard'; + + self::assertSame('visa,mastercard', $request->paymentMethods); + } +} diff --git a/tests/Request/Payment/CreatePaymentRequestTest.php b/tests/Request/Payment/CreatePaymentRequestTest.php new file mode 100644 index 0000000..de8c684 --- /dev/null +++ b/tests/Request/Payment/CreatePaymentRequestTest.php @@ -0,0 +1,81 @@ +orderId); + } + + /** + * @return iterable + */ + public static function validOrderIds(): iterable + { + // All accepted by the live API (2026-08-17). + yield '4 chars' => ['ab12']; + yield '20 chars' => [str_repeat('z', 20)]; + yield 'upper case' => ['AB-CD716b']; + yield 'space' => ['ab cd2a64']; + yield 'dot' => ['ab.cd2a64']; + yield 'underscore' => ['ab_cd2a64']; + yield 'dash' => ['ab-cd2a64']; + yield 'typical' => ['order-0001']; + } + + #[Test] + #[DataProvider('invalidOrderIds')] + public function it_rejects_order_ids_the_api_rejects(string $orderId, string $expectedMessagePart): void + { + try { + new CreatePaymentRequest(orderId: $orderId, currency: 'DKK'); + self::fail('Expected an InvalidArgumentException.'); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('letters, digits, space, ".", "_" and "-"', $e->getMessage()); + self::assertStringContainsString($expectedMessagePart, $e->getMessage()); + } + } + + /** + * @return iterable + */ + public static function invalidOrderIds(): iterable + { + // All rejected by the live API (2026-08-17) — every one of them under the message + // "must have length between 4 and 20", which is why the SDK names the actual rule. + yield 'too short' => ['abc', 'got 3 character(s)']; + yield 'too long' => [str_repeat('z', 21), 'got 21 character(s)']; + yield 'empty' => ['', 'got 0 character(s)']; + yield 'slash' => ['ab/cd2a64', 'outside that set']; + yield 'hash' => ['ab#cd2a64', 'outside that set']; + yield 'colon' => ['ab:cd2a64', 'outside that set']; + yield 'at' => ['ab@cd2a64', 'outside that set']; + yield 'plus' => ['ab+cd2a64', 'outside that set']; + yield 'percent' => ['ab%cd2a64', 'outside that set']; + yield 'comma' => ['ab,cd2a64', 'outside that set']; + yield 'tab' => ["ab\tcd2a64", 'outside that set']; + yield 'non-ascii, 20 chars' => [str_repeat('a', 15) . 'ø2a64', 'got 20 character(s), including characters outside that set']; + yield 'non-ascii, 7 chars' => ['abcdø64', 'outside that set']; + } + + #[Test] + public function the_pattern_is_public_so_consumers_can_pre_validate(): void + { + self::assertSame(1, preg_match(CreatePaymentRequest::ORDER_ID_PATTERN, 'order-0001')); + self::assertSame(0, preg_match(CreatePaymentRequest::ORDER_ID_PATTERN, 'order/0001')); + + $this->expectException(InvalidArgumentException::class); + CreatePaymentRequest::assertValidOrderId('no'); + } +} diff --git a/tests/Response/Payment/PaymentTest.php b/tests/Response/Payment/PaymentTest.php index a207ad0..11c9a47 100644 --- a/tests/Response/Payment/PaymentTest.php +++ b/tests/Response/Payment/PaymentTest.php @@ -163,6 +163,95 @@ public function the_amount_helpers_are_zero_without_operations(): void self::assertSame(0, $payment->refundedAmount()); } + #[Test] + public function operation_has_an_outcome_once_it_is_no_longer_pending(): void + { + self::assertFalse((new Operation(id: 1, type: 'capture', pending: true))->hasOutcome()); + self::assertTrue((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: '20000'))->hasOutcome()); + self::assertTrue((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: '40000'))->hasOutcome()); + } + + #[Test] + public function operation_is_declined_only_when_completed_and_not_approved(): void + { + self::assertFalse((new Operation(id: 1, type: 'capture', pending: true))->isDeclined(), 'pending: nothing is known yet'); + self::assertFalse((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: '20000'))->isDeclined(), 'approved is not declined'); + self::assertTrue((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: '40000'))->isDeclined(), 'rejected by acquirer'); + self::assertTrue((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: '50300'))->isDeclined(), 'communication error'); + self::assertTrue((new Operation(id: 1, type: 'authorize', pending: false, qpStatusCode: '30100'))->isDeclined(), '3-D Secure required: not approved (yet)'); + self::assertTrue((new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: null))->isDeclined(), 'completed without a code is not approved either'); + // Approved and declined are mutually exclusive once there is an outcome. + foreach (['20000', '40000', '30100', null] as $code) { + $op = new Operation(id: 1, type: 'capture', pending: false, qpStatusCode: $code); + self::assertTrue($op->isApproved() xor $op->isDeclined()); + } + } + + #[Test] + public function it_finds_the_latest_operation_of_a_type_regardless_of_outcome(): void + { + $payment = self::payment( + self::operation(1, 'authorize', 1000), + $capture1 = self::operation(2, 'capture', 400), + $capture2 = self::operation(3, 'capture', 600, qpStatusCode: '40000'), // declined, still the latest capture + self::operation(4, 'refund', 100), + ); + + self::assertSame($capture2, $payment->latestOperationOfType(OperationType::Capture)); + self::assertSame($capture2, $payment->latestOperationOfType('capture')); + self::assertTrue($capture2->isDeclined()); + self::assertNull($payment->latestOperationOfType(OperationType::Cancel)); + self::assertSame($capture1, $payment->operationsOfType(OperationType::Capture)[0]); + } + + #[Test] + public function the_latest_approved_operation_ignores_trailing_rejected_or_pending_attempts(): void + { + $payment = self::payment( + self::operation(1, 'authorize', 1000), + $capture = self::operation(2, 'capture', 1000), + self::operation(3, 'refund', 300, qpStatusCode: '40000'), // rejected refund + self::operation(4, 'refund', 300, pending: true), // retry in flight + ); + + self::assertSame($capture, $payment->latestApprovedOperation(), 'money is still fully captured'); + self::assertSame(4, $payment->latestOperation()?->id, 'whereas latestOperation() is the pending retry'); + self::assertNull(self::payment(self::operation(1, 'authorize', 1000, qpStatusCode: '40000'))->latestApprovedOperation()); + self::assertNull(self::payment()->latestApprovedOperation()); + } + + #[Test] + public function it_tells_whether_an_approved_operation_exists_by_type_or_at_all(): void + { + $payment = self::payment( + self::operation(1, 'authorize', 1000), + self::operation(2, 'capture', 1000, qpStatusCode: '40000'), + self::operation(3, 'capture', 1000, pending: true), + ); + + self::assertTrue($payment->hasApprovedOperation()); + self::assertTrue($payment->hasApprovedOperation(OperationType::Authorize)); + self::assertTrue($payment->hasApprovedOperation('authorize')); + self::assertFalse($payment->hasApprovedOperation(OperationType::Capture), 'one rejected, one pending — none approved'); + self::assertFalse(self::payment()->hasApprovedOperation()); + } + + #[Test] + public function it_tells_whether_an_operation_of_a_type_is_pending(): void + { + $payment = self::payment( + self::operation(1, 'authorize', 1000), + self::operation(2, 'capture', 1000), + self::operation(3, 'refund', 300, pending: true), + ); + + self::assertTrue($payment->hasPendingOperation()); + self::assertTrue($payment->hasPendingOperation(OperationType::Refund)); + self::assertTrue($payment->hasPendingOperation('refund')); + self::assertFalse($payment->hasPendingOperation(OperationType::Capture)); + self::assertFalse(self::payment(self::operation(1, 'authorize', 1000))->hasPendingOperation(OperationType::Authorize)); + } + private static function payment(Operation ...$operations): Payment { return new Payment(id: 1, orderId: 'o', currency: 'DKK', state: 'processed', merchantId: 1, operations: array_values($operations));