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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,16 @@ use Setono\Quickpay\Request\Payment\UpdatePaymentRequest;
$client->payments()->updatePayment($payment->id, new UpdatePaymentRequest(
variables: ['internal_ref' => 'abc-123'],
));

// Read them back later — keys and value types exactly as you sent them
$client->payments()->getById($payment->id)->variables()['internal_ref']; // "abc-123"
```

If you ship a plugin/module, identify it on the payments it creates with
`new CreatePaymentRequest(..., shopsystem: new Shopsystem(name: 'acme/shop-plugin', version: '2.3.4'))`
— Quickpay shows it on the payment (`metadata.shopsystem_name` / `shopsystem_version`), which helps
telling integrations apart in the manager and when talking to support.

> Authorizing directly via the API — `$client->payments()->authorize($id, new AuthorizePaymentRequest(...))` —
> requires you to handle card data and puts you in PCI scope. Most integrations authorize through the
> payment window instead (see the link flow above).
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"require": {
"php": ">=8.1",
"composer-runtime-api": "^2.0",
"cuyz/valinor": "^2.2.2",
"php-http/discovery": "^1.19",
"psr/http-client": "^1.0",
Expand Down
11 changes: 9 additions & 2 deletions src/Callback/Callback.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,26 @@
) {
}

private ?Payment $payment = null;

public function isPayment(): bool
{
return ResourceType::Payment === $this->type;
}

/**
* Deserialize the callback body into a {@see Payment}.
* Deserialize the callback body into a {@see Payment}. The result is memoized, so calling this
* more than once (a guard, then a handler) does not decode and map the body again.
*
* @throws InvalidCallbackException if this callback is not a payment, or the body is not valid JSON
* / does not fit the Payment DTO
*/
public function payment(): Payment
{
if (null !== $this->payment) {
return $this->payment;
}

if (ResourceType::Payment !== $this->type) {
throw new InvalidCallbackException(sprintf(
'This callback is a "%s" resource, not a payment — check the type before calling payment().',
Expand All @@ -73,12 +80,12 @@
try {
$payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys());
} catch (MappingError $e) {
throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e);

Check warning on line 83 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ try { $payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys()); } catch (MappingError $e) { - throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 1, $e); } $payment->raw = $decoded; return $this->payment = $payment;

Check warning on line 83 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ try { $payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys()); } catch (MappingError $e) { - throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), -1, $e); } $payment->raw = $decoded; return $this->payment = $payment;

Check warning on line 83 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ try { $payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys()); } catch (MappingError $e) { - throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body does not match the expected payment shape: ', 0, $e); } $payment->raw = $decoded; return $this->payment = $payment;

Check warning on line 83 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ try { $payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys()); } catch (MappingError $e) { - throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException($e->getMessage(), 0, $e); } $payment->raw = $decoded; return $this->payment = $payment;

Check warning on line 83 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ try { $payment = $this->mapperBuilder()->mapper()->map(Payment::class, Source::array($decoded)->camelCaseKeys()); } catch (MappingError $e) { - throw new InvalidCallbackException('The callback body does not match the expected payment shape: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException($e->getMessage() . 'The callback body does not match the expected payment shape: ', 0, $e); } $payment->raw = $decoded; return $this->payment = $payment;
}

$payment->raw = $decoded;

return $payment;
return $this->payment = $payment;
}

/**
Expand All @@ -94,7 +101,7 @@
/** @var mixed $decoded */
$decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e);

Check warning on line 104 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "IncrementInteger": --- Original +++ New @@ @@ /** @var mixed $decoded */ $decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 1, $e); } if (!is_array($decoded)) { throw new InvalidCallbackException(sprintf('Expected the callback body to decode to an object but got %s.', get_debug_type($decoded)));

Check warning on line 104 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "DecrementInteger": --- Original +++ New @@ @@ /** @var mixed $decoded */ $decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), -1, $e); } if (!is_array($decoded)) { throw new InvalidCallbackException(sprintf('Expected the callback body to decode to an object but got %s.', get_debug_type($decoded)));

Check warning on line 104 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ /** @var mixed $decoded */ $decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException('The callback body is not valid JSON: ', 0, $e); } if (!is_array($decoded)) { throw new InvalidCallbackException(sprintf('Expected the callback body to decode to an object but got %s.', get_debug_type($decoded)));

Check warning on line 104 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "ConcatOperandRemoval": --- Original +++ New @@ @@ /** @var mixed $decoded */ $decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException($e->getMessage(), 0, $e); } if (!is_array($decoded)) { throw new InvalidCallbackException(sprintf('Expected the callback body to decode to an object but got %s.', get_debug_type($decoded)));

Check warning on line 104 in src/Callback/Callback.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "Concat": --- Original +++ New @@ @@ /** @var mixed $decoded */ $decoded = json_decode($this->body, true, flags: \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new InvalidCallbackException('The callback body is not valid JSON: ' . $e->getMessage(), 0, $e); + throw new InvalidCallbackException($e->getMessage() . 'The callback body is not valid JSON: ', 0, $e); } if (!is_array($decoded)) { throw new InvalidCallbackException(sprintf('Expected the callback body to decode to an object but got %s.', get_debug_type($decoded)));
}

if (!is_array($decoded)) {
Expand Down
18 changes: 17 additions & 1 deletion src/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Setono\Quickpay\Client;

use Composer\InstalledVersions;
use CuyZ\Valinor\Cache\Cache;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Normalizer\Format;
Expand Down Expand Up @@ -361,9 +362,24 @@ private static function assertAllowedUrl(string $url): void
}
}

/**
* `Setono-Quickpay-PHP/1.2.3 (+https://github.com/Setono/quickpay-php-sdk)` — the installed
* package version helps Quickpay support (and you) tell integrations apart in request logs.
*/
private function userAgent(): string
{
return 'Setono-Quickpay-PHP (+https://github.com/Setono/quickpay-php-sdk)';
return sprintf('Setono-Quickpay-PHP/%s (+https://github.com/Setono/quickpay-php-sdk)', self::version());
}

/**
* The installed version of this package (`dev-1.x` etc. for a source checkout), or `unknown`
* when Composer does not know about it (a vendored copy, a phar).
*/
public static function version(): string
{
$package = 'setono/quickpay-php-sdk';

return InstalledVersions::isInstalled($package) ? (InstalledVersions::getPrettyVersion($package) ?? 'unknown') : 'unknown';
}

private static function camelToSnake(string $key): string
Expand Down
4 changes: 3 additions & 1 deletion src/Request/Payment/CreatePaymentRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*
* `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.
* other fields are optional. `shopsystem` lets an integration identify itself (name/version) on the
* payment.
*/
final class CreatePaymentRequest extends Payload
{
Expand All @@ -29,6 +30,7 @@ public function __construct(
public ?Address $shippingAddress = null,
public array $basket = [],
public ?Shipping $shipping = null,
public ?Shopsystem $shopsystem = null,
) {
}
}
22 changes: 22 additions & 0 deletions src/Request/Payment/Shopsystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Setono\Quickpay\Request\Payment;

use Setono\Quickpay\Request\Payload;

/**
* Identifies the shop system / integration creating the payment (`shopsystem[name]` /
* `shopsystem[version]` on `POST /payments`). Quickpay stores it on the payment's metadata
* (`shopsystem_name` / `shopsystem_version`) — useful for support and for telling integrations
* apart in the Quickpay manager, e.g. a plugin sending its own name and version.
*/
final class Shopsystem extends Payload
{
public function __construct(
public ?string $name = null,
public ?string $version = null,
) {
}
}
26 changes: 25 additions & 1 deletion src/Response/Payment/Payment.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
* {@see Resource::$raw} using the original snake_case keys (e.g. `$payment->raw['text_on_statement']`).
*
* Amounts (`$balance`, `$fee`, operation amounts) are integers in the smallest unit of the
* payment's currency.
* payment's currency. `$deadlineAt` is the authorize deadline (if one was set), `$acquirer` the
* acquirer that processed the transaction (`null` until it was authorized). Your own `variables`
* are available via {@see self::variables()}.
*
* What happened to a payment is recorded in its `$operations` (authorize, capture, refund, cancel,
* …), each with a `pending` flag and Quickpay status code. The helpers below answer the usual
Expand Down Expand Up @@ -47,9 +49,31 @@ public function __construct(
public readonly ?Metadata $metadata = null,
public readonly ?\DateTimeImmutable $createdAt = null,
public readonly ?\DateTimeImmutable $updatedAt = null,
public readonly ?\DateTimeImmutable $deadlineAt = null,
public readonly ?string $acquirer = null,
) {
}

/**
* The payment's custom `variables` — the free-form key/value map you stored with
* `CreatePaymentRequest::$variables` / `UpdatePaymentRequest::$variables` — with your keys and
* value types exactly as you sent them (`[]` if none).
*
* This is deliberately a method reading {@see Resource::$raw} rather than a typed property:
* the mapper camelCases keys at every depth, so a mapped property would silently rename your
* `internal_ref` to `internalRef`. (Consequently it is empty on hand-constructed instances
* whose `$raw` was not set.)
*
* @return array<string, mixed>
*/
public function variables(): array
{
/** @var array<string, mixed> $variables */
$variables = is_array($this->raw['variables'] ?? null) ? $this->raw['variables'] : [];

return $variables;
}

/**
* The payment state as a {@see PaymentState}, or `null` if Quickpay returned a value not
* modeled by the enum.
Expand Down
10 changes: 10 additions & 0 deletions tests/Callback/CallbackTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,14 @@ public function handle_globals_defaults_to_the_real_superglobals(): void
$_SERVER = $backup;
}
}

#[Test]
public function payment_is_memoized(): void
{
$handler = new CallbackHandler(self::PRIVATE_KEY);
$raw = self::fixture('callback_payment.json');
$callback = $handler->handleRaw($raw, $handler->validator()->sign($raw), ResourceType::Payment->value);

self::assertSame($callback->payment(), $callback->payment());
}
}
16 changes: 16 additions & 0 deletions tests/Client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ public function it_sends_basic_auth_with_empty_user_and_required_headers(): void
self::assertStringStartsWith('Setono-Quickpay-PHP', $request->getHeaderLine('User-Agent'));
}

#[Test]
public function it_sends_the_sdk_version_in_the_user_agent(): void
{
$http = (new ScriptedHttpClient())->on(self::BASE . '/ping', self::fixture('ping.json'));

$this->client($http)->ping();

$version = Client::version();
self::assertNotSame('', $version);
self::assertNotSame('unknown', $version, 'in a Composer-installed checkout the version must be known');
self::assertSame(
sprintf('Setono-Quickpay-PHP/%s (+https://github.com/Setono/quickpay-php-sdk)', $version),
$http->sentRequests[0]->getHeaderLine('User-Agent'),
);
}

#[Test]
public function it_uses_the_quickpay_api_host(): void
{
Expand Down
55 changes: 55 additions & 0 deletions tests/Client/Endpoint/PaymentsEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Setono\Quickpay\Request\Payment\CreatePaymentRequest;
use Setono\Quickpay\Request\Payment\PaymentsQuery;
use Setono\Quickpay\Request\Payment\RefundRequest;
use Setono\Quickpay\Request\Payment\Shopsystem;
use Setono\Quickpay\Request\Payment\UpdatePaymentRequest;
use Setono\Quickpay\Response\Payment\Payment;
use Setono\Quickpay\TestDouble\ScriptedHttpClient;
Expand Down Expand Up @@ -409,4 +410,58 @@ public function it_deletes_the_payment_link(): void
self::assertSame('DELETE', $http->sentRequests[0]->getMethod());
self::assertSame(self::BASE . '/payments/1234/link', (string) $http->sentRequests[0]->getUri());
}

#[Test]
public function it_maps_deadline_and_acquirer_and_exposes_variables_verbatim(): void
{
$http = (new ScriptedHttpClient())->on(
self::BASE . '/payments/1',
'{"id":1,"merchant_id":1,"order_id":"o-1","accepted":true,"currency":"DKK","state":"new","operations":[],'
. '"deadline_at":"2026-12-31T22:59:59Z","acquirer":"clearhaus",'
. '"variables":{"internal_ref":"abc-123","n":42,"flag":true,"nested_map":{"deep_key":1}}}',
);

$payment = $this->client($http)->payments()->getById(1);

self::assertSame('2026-12-31T22:59:59+00:00', $payment->deadlineAt?->format(\DATE_ATOM));
self::assertSame('clearhaus', $payment->acquirer);
// Keys and value types exactly as sent — NOT camelCased like the typed properties.
self::assertSame(
['internal_ref' => 'abc-123', 'n' => 42, 'flag' => true, 'nested_map' => ['deep_key' => 1]],
$payment->variables(),
);
}

#[Test]
public function variables_are_empty_when_absent_or_empty(): void
{
$http = (new ScriptedHttpClient())
->on(self::BASE . '/payments/1', '{"id":1,"merchant_id":1,"order_id":"o-1","currency":"DKK","state":"new","operations":[],"variables":{}}')
->on(self::BASE . '/payments/2', '{"id":2,"merchant_id":1,"order_id":"o-2","currency":"DKK","state":"new","operations":[]}')
;
$payments = $this->client($http)->payments();

self::assertSame([], $payments->getById(1)->variables());
self::assertSame([], $payments->getById(2)->variables());
self::assertNull($payments->getById(2)->deadlineAt);
self::assertNull($payments->getById(2)->acquirer);
self::assertSame([], (new Payment(id: 3, orderId: 'o', currency: 'DKK', state: 'new', merchantId: 1))->variables());
}

#[Test]
public function it_sends_the_shopsystem_on_create(): void
{
$http = (new ScriptedHttpClient())->on(self::BASE . '/payments', self::fixture('payment.json'));

$this->client($http)->payments()->create(new CreatePaymentRequest(
orderId: 'order-0001',
currency: 'DKK',
shopsystem: new Shopsystem(name: 'acme/shop-plugin', version: '2.3.4'),
));

self::assertSame(
'{"order_id":"order-0001","currency":"DKK","shopsystem":{"name":"acme\/shop-plugin","version":"2.3.4"}}',
(string) $http->sentRequests[0]->getBody(),
);
}
}
Loading