diff --git a/README.md b/README.md index 419c212..8782455 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/composer.json b/composer.json index 7833db7..248312c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Callback/Callback.php b/src/Callback/Callback.php index 431e86f..6b628a9 100644 --- a/src/Callback/Callback.php +++ b/src/Callback/Callback.php @@ -48,19 +48,26 @@ public function __construct( ) { } + 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().', @@ -78,7 +85,7 @@ public function payment(): Payment $payment->raw = $decoded; - return $payment; + return $this->payment = $payment; } /** diff --git a/src/Client/Client.php b/src/Client/Client.php index e22e8a6..fb0a791 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -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; @@ -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 diff --git a/src/Request/Payment/CreatePaymentRequest.php b/src/Request/Payment/CreatePaymentRequest.php index 3f176e7..7e39bd9 100644 --- a/src/Request/Payment/CreatePaymentRequest.php +++ b/src/Request/Payment/CreatePaymentRequest.php @@ -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 { @@ -29,6 +30,7 @@ public function __construct( public ?Address $shippingAddress = null, public array $basket = [], public ?Shipping $shipping = null, + public ?Shopsystem $shopsystem = null, ) { } } diff --git a/src/Request/Payment/Shopsystem.php b/src/Request/Payment/Shopsystem.php new file mode 100644 index 0000000..4b5faf8 --- /dev/null +++ b/src/Request/Payment/Shopsystem.php @@ -0,0 +1,22 @@ +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 @@ -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 + */ + public function variables(): array + { + /** @var array $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. diff --git a/tests/Callback/CallbackTest.php b/tests/Callback/CallbackTest.php index a9ec64f..04252a5 100644 --- a/tests/Callback/CallbackTest.php +++ b/tests/Callback/CallbackTest.php @@ -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()); + } } diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index 57f068f..06b6b29 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -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 { diff --git a/tests/Client/Endpoint/PaymentsEndpointTest.php b/tests/Client/Endpoint/PaymentsEndpointTest.php index d9f928b..87eb4ff 100644 --- a/tests/Client/Endpoint/PaymentsEndpointTest.php +++ b/tests/Client/Endpoint/PaymentsEndpointTest.php @@ -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; @@ -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(), + ); + } }