diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cda1d4..80358b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.0] - 2024-07-18 + +### Added + +- Support for `attempt_failed` payment status + ## [2.2.0] - 2024-07-15 ### Added diff --git a/README.md b/README.md index 1a40352b..bf4486fe 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ 4. [Executed Status](#status-executed) 5. [Settled Status](#status-settled) 6. [Failed Status](#status-failed) - 7. [Authorization flow config](#auth-flow-config) - 8. [Source of funds](#source-of-funds) + 7. [Attempt Failed Status](#status-attempt-failed) + 8. [Authorization flow config](#auth-flow-config) + 9. [Source of funds](#source-of-funds) 7. [Authorizing a payment](#authorizing-payment) 8. [Refunds](#refunds) 9. [Payouts](#payouts) @@ -418,7 +419,8 @@ $payment->isAuthorizing(); $payment->isAuthorized(); // Will also return false when the payment has progressed to executed, failed or settled states. $payment->isExecuted(); // Will also return false when the payment has progressed to failed or settled states. $payment->isSettled(); -$payment->isFailed(); +$payment->isFailed(); // Payment has failed +$payment->isAttemptFailed(); // Payment attempt has failed, only available if payment retries are enabled. ``` Or you can get the status as a string and compare it to the provided constants in `PaymentStatus`: @@ -587,6 +589,23 @@ if ($payment instanceof PaymentFailedInterface) { } ``` + + +### Attempt Failed Status + +> Status only available when you enable payment retries. + +```php +use TrueLayer\Interfaces\Payment\PaymentAttemptFailedInterface; + +if ($payment instanceof PaymentAttemptFailedInterface) { + $payment->getFailedAt(); // The date and time the payment failed at + $payment->getFailureStage(); // The status the payment was when it failed, one of `authorization_required`, `authorizing` or `authorized` + $payment->getFailureReason(); // The reason the payment failed. Handle unexpected values gracefully as an unknown failure. + $payment->getAuthorizationFlowConfig(); // see authorization flow config +} +``` + ### Authorization flow config diff --git a/config/bindings.php b/config/bindings.php index 3aa2f15a..0f52051b 100644 --- a/config/bindings.php +++ b/config/bindings.php @@ -23,11 +23,13 @@ Interfaces\Payment\PaymentExecutedInterface::class => Entities\Payment\PaymentRetrieved\PaymentExecuted::class, Interfaces\Payment\PaymentSettledInterface::class => Entities\Payment\PaymentRetrieved\PaymentSettled::class, Interfaces\Payment\PaymentFailedInterface::class => Entities\Payment\PaymentRetrieved\PaymentFailed::class, + Interfaces\Payment\PaymentAttemptFailedInterface::class => Entities\Payment\PaymentRetrieved\PaymentAttemptFailed::class, Interfaces\Payment\PaymentSourceInterface::class => Entities\Payment\PaymentRetrieved\PaymentSource::class, TrueLayer\Interfaces\Payment\StartAuthorizationFlowRequestInterface::class => Entities\Payment\AuthorizationFlow\StartAuthorizationFlowRequest::class, Interfaces\Payment\AuthorizationFlow\AuthorizationFlowInterface::class => Entities\Payment\AuthorizationFlow\AuthorizationFlow::class, Interfaces\Payment\AuthorizationFlow\ConfigurationInterface::class => Entities\Payment\AuthorizationFlow\Configuration::class, + Interfaces\Payment\AuthorizationFlow\ActionInterface::class => Entities\Payment\AuthorizationFlow\Action::class, Interfaces\Payment\AuthorizationFlow\Action\ProviderSelectionActionInterface::class => Entities\Payment\AuthorizationFlow\Action\ProviderSelectionAction::class, Interfaces\Payment\AuthorizationFlow\Action\RedirectActionInterface::class => Entities\Payment\AuthorizationFlow\Action\RedirectAction::class, Interfaces\Payment\AuthorizationFlow\Action\WaitActionInterface::class => Entities\Payment\AuthorizationFlow\Action\WaitAction::class, diff --git a/config/discriminations.php b/config/discriminations.php index 21b6472f..8343e278 100644 --- a/config/discriminations.php +++ b/config/discriminations.php @@ -23,6 +23,7 @@ PaymentStatus::EXECUTED => Interfaces\Payment\PaymentExecutedInterface::class, PaymentStatus::SETTLED => Interfaces\Payment\PaymentSettledInterface::class, PaymentStatus::FAILED => Interfaces\Payment\PaymentFailedInterface::class, + PaymentStatus::ATTEMPT_FAILED => Interfaces\Payment\PaymentAttemptFailedInterface::class, ], Interfaces\Payment\AuthorizationFlow\AuthorizationFlowResponseInterface::class => [ 'discriminate_on' => 'status', diff --git a/src/Constants/AuthorizationFlowActionTypes.php b/src/Constants/AuthorizationFlowActionTypes.php index 2b417ad3..a2b68a6c 100644 --- a/src/Constants/AuthorizationFlowActionTypes.php +++ b/src/Constants/AuthorizationFlowActionTypes.php @@ -9,4 +9,5 @@ class AuthorizationFlowActionTypes public const PROVIDER_SELECTION = 'provider_selection'; public const REDIRECT = 'redirect'; public const WAIT = 'wait'; + public const RETRY = 'retry'; } diff --git a/src/Constants/PaymentStatus.php b/src/Constants/PaymentStatus.php index 5e3726db..a42f5045 100644 --- a/src/Constants/PaymentStatus.php +++ b/src/Constants/PaymentStatus.php @@ -11,5 +11,6 @@ class PaymentStatus public const AUTHORIZED = 'authorized'; public const EXECUTED = 'executed'; public const FAILED = 'failed'; + public const ATTEMPT_FAILED = 'attempt_failed'; public const SETTLED = 'settled'; } diff --git a/src/Entities/Payment/AuthorizationFlow/Action.php b/src/Entities/Payment/AuthorizationFlow/Action.php index 21068665..96434a07 100644 --- a/src/Entities/Payment/AuthorizationFlow/Action.php +++ b/src/Entities/Payment/AuthorizationFlow/Action.php @@ -5,12 +5,27 @@ namespace TrueLayer\Entities\Payment\AuthorizationFlow; use TrueLayer\Entities\Entity; -use TrueLayer\Interfaces\Payment\AuthorizationFlow\ActionInterface; +use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\ActionInterface; -abstract class Action extends Entity implements ActionInterface +class Action extends Entity implements ActionInterface { + /** + * @var string + */ + protected string $type; + + /** + * @var string[] + */ + protected array $arrayFields = [ + 'type', + ]; + /** * @return string */ - abstract public function getType(): string; + public function getType(): string + { + return $this->type; + } } diff --git a/src/Entities/Payment/AuthorizationFlow/Action/ProviderSelectionAction.php b/src/Entities/Payment/AuthorizationFlow/Action/ProviderSelectionAction.php index 5c875d2e..1c9312c7 100644 --- a/src/Entities/Payment/AuthorizationFlow/Action/ProviderSelectionAction.php +++ b/src/Entities/Payment/AuthorizationFlow/Action/ProviderSelectionAction.php @@ -4,7 +4,6 @@ namespace TrueLayer\Entities\Payment\AuthorizationFlow\Action; -use TrueLayer\Constants\AuthorizationFlowActionTypes; use TrueLayer\Entities\Payment\AuthorizationFlow\Action; use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\ProviderSelectionActionInterface; use TrueLayer\Interfaces\Provider\ProviderInterface; @@ -38,12 +37,4 @@ public function getProviders(): array { return $this->providers; } - - /** - * @return string - */ - public function getType(): string - { - return AuthorizationFlowActionTypes::PROVIDER_SELECTION; - } } diff --git a/src/Entities/Payment/AuthorizationFlow/Action/RedirectAction.php b/src/Entities/Payment/AuthorizationFlow/Action/RedirectAction.php index d71e57fd..d360271c 100644 --- a/src/Entities/Payment/AuthorizationFlow/Action/RedirectAction.php +++ b/src/Entities/Payment/AuthorizationFlow/Action/RedirectAction.php @@ -4,7 +4,6 @@ namespace TrueLayer\Entities\Payment\AuthorizationFlow\Action; -use TrueLayer\Constants\AuthorizationFlowActionTypes; use TrueLayer\Entities\Payment\AuthorizationFlow\Action; use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\RedirectActionInterface; use TrueLayer\Interfaces\Provider\ProviderInterface; @@ -45,14 +44,6 @@ public function getUri(): string return $this->uri; } - /** - * @return string - */ - public function getType(): string - { - return AuthorizationFlowActionTypes::REDIRECT; - } - /** * @return string */ diff --git a/src/Entities/Payment/AuthorizationFlow/Action/WaitAction.php b/src/Entities/Payment/AuthorizationFlow/Action/WaitAction.php index 6483ecc4..ab0b2236 100644 --- a/src/Entities/Payment/AuthorizationFlow/Action/WaitAction.php +++ b/src/Entities/Payment/AuthorizationFlow/Action/WaitAction.php @@ -4,24 +4,10 @@ namespace TrueLayer\Entities\Payment\AuthorizationFlow\Action; -use TrueLayer\Constants\AuthorizationFlowActionTypes; use TrueLayer\Entities\Payment\AuthorizationFlow\Action; use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\WaitActionInterface; class WaitAction extends Action implements WaitActionInterface { - /** - * @var string[] - */ - protected array $arrayFields = [ - 'type', - ]; - /** - * @return string - */ - public function getType(): string - { - return AuthorizationFlowActionTypes::WAIT; - } } diff --git a/src/Entities/Payment/PaymentRetrieved.php b/src/Entities/Payment/PaymentRetrieved.php index 6784463c..b7bc2a13 100644 --- a/src/Entities/Payment/PaymentRetrieved.php +++ b/src/Entities/Payment/PaymentRetrieved.php @@ -177,6 +177,14 @@ public function isFailed(): bool return $this->getStatus() === PaymentStatus::FAILED; } + /** + * @return bool + */ + public function isAttemptFailed(): bool + { + return $this->getStatus() === PaymentStatus::ATTEMPT_FAILED; + } + /** * @return bool */ diff --git a/src/Entities/Payment/PaymentRetrieved/PaymentAttemptFailed.php b/src/Entities/Payment/PaymentRetrieved/PaymentAttemptFailed.php new file mode 100644 index 00000000..418a281c --- /dev/null +++ b/src/Entities/Payment/PaymentRetrieved/PaymentAttemptFailed.php @@ -0,0 +1,11 @@ + \DateTimeInterface::class, - ]); - } - - /** - * @return mixed[] - */ - protected function arrayFields(): array - { - return \array_merge(parent::arrayFields(), [ - 'failed_at', - 'failure_stage', - 'failure_reason', - ]); - } - - /** - * @return \DateTimeInterface - */ - public function getFailedAt(): \DateTimeInterface - { - return $this->failedAt; - } - - /** - * @return string - */ - public function getFailureStage(): string - { - return $this->failureStage; - } - - /** - * @return string|null - */ - public function getFailureReason(): ?string - { - return $this->failureReason ?? null; - } } diff --git a/src/Entities/Payment/PaymentRetrieved/PaymentFailure.php b/src/Entities/Payment/PaymentRetrieved/PaymentFailure.php new file mode 100644 index 00000000..f7731a09 --- /dev/null +++ b/src/Entities/Payment/PaymentRetrieved/PaymentFailure.php @@ -0,0 +1,69 @@ + \DateTimeInterface::class, + ]); + } + + /** + * @return mixed[] + */ + protected function arrayFields(): array + { + return \array_merge(parent::arrayFields(), [ + 'failed_at', + 'failure_stage', + 'failure_reason', + ]); + } + + /** + * @return \DateTimeInterface + */ + public function getFailedAt(): \DateTimeInterface + { + return $this->failedAt; + } + + /** + * @return string + */ + public function getFailureStage(): string + { + return $this->failureStage; + } + + /** + * @return string|null + */ + public function getFailureReason(): ?string + { + return $this->failureReason ?? null; + } +} diff --git a/src/Interfaces/Payment/AuthorizationFlow/Action/ActionInterface.php b/src/Interfaces/Payment/AuthorizationFlow/Action/ActionInterface.php index 89b07010..b20afa3f 100644 --- a/src/Interfaces/Payment/AuthorizationFlow/Action/ActionInterface.php +++ b/src/Interfaces/Payment/AuthorizationFlow/Action/ActionInterface.php @@ -4,12 +4,7 @@ namespace TrueLayer\Interfaces\Payment\AuthorizationFlow\Action; -use TrueLayer\Interfaces\ArrayableInterface; - -interface ActionInterface extends ArrayableInterface +interface ActionInterface extends \TrueLayer\Interfaces\Payment\AuthorizationFlow\ActionInterface { - /** - * @return string - */ - public function getType(): string; + } diff --git a/src/Interfaces/Payment/AuthorizationFlow/ActionInterface.php b/src/Interfaces/Payment/AuthorizationFlow/ActionInterface.php index d837947d..82ce88a6 100644 --- a/src/Interfaces/Payment/AuthorizationFlow/ActionInterface.php +++ b/src/Interfaces/Payment/AuthorizationFlow/ActionInterface.php @@ -8,4 +8,8 @@ interface ActionInterface extends ArrayableInterface { + /** + * @return string + */ + public function getType(): string; } diff --git a/src/Interfaces/Payment/PaymentAttemptFailedInterface.php b/src/Interfaces/Payment/PaymentAttemptFailedInterface.php new file mode 100644 index 00000000..ff252f11 --- /dev/null +++ b/src/Interfaces/Payment/PaymentAttemptFailedInterface.php @@ -0,0 +1,9 @@ +client()->getMerchantAccounts(), - fn(MerchantAccountInterface $account) => $account->getCurrency() === 'GBP' - ); - - $merchantBeneficiary = $helper->merchantBeneficiary($account); - - $created = $helper->create( - $helper->bankTransferMethod($merchantBeneficiary)->enablePaymentRetry(), $helper->user(), $account->getCurrency() - ); - - \expect($created)->toBeInstanceOf(PaymentCreatedInterface::class); - - /** @var BankTransferPaymentMethodInterface $paymentMethod */ - $paymentMethod = $created->getDetails()->getPaymentMethod(); - \expect($paymentMethod->isPaymentRetryEnabled())->toBe(true); -}); - \it('starts payment authorization', function (PaymentCreatedInterface $created) { $response = \client()->startPaymentAuthorization($created, 'https://console.truelayer.com/redirect-page'); @@ -267,3 +248,46 @@ return $created; }); + +\it('handles a merchant payment with retry enabled', function () { + $helper = \paymentHelper(); + $client = $helper->client(); + + $account = Arr::first( + $helper->client()->getMerchantAccounts(), + fn(MerchantAccountInterface $account) => $account->getCurrency() === 'GBP' + ); + + $merchantBeneficiary = $helper->merchantBeneficiary($account); + $paymentMethod = $helper->bankTransferMethod($merchantBeneficiary)->enablePaymentRetry(); + $created = $helper->create($paymentMethod, $helper->user(), $account->getCurrency()); + + \expect($created)->toBeInstanceOf(PaymentCreatedInterface::class); + + /** @var BankTransferPaymentMethodInterface $paymentMethod */ + $paymentMethod = $created->getDetails()->getPaymentMethod(); + \expect($paymentMethod->isPaymentRetryEnabled())->toBe(true); + + $payload = $client->paymentAuthorizationFlow($created) + ->returnUri('https://console.truelayer.com/redirect-page') + ->enableProviderSelection() + ->toArray(); + $payload['retry'] = new stdClass(); + + $client->getApiClient()->request() + ->payload($payload) + ->uri(\str_replace('{id}', $created->getId(), Endpoints::PAYMENTS_START_AUTH_FLOW)) + ->post(); + + $client->submitPaymentProvider($created, 'mock-payments-gb-redirect'); + + /** @var RedirectActionInterface $next */ + $next = $created->getDetails()->getAuthorizationFlowNextAction(); + \bankAction($next->getUri(), 'RejectExecution'); + \sleep(20); + + /** @var PaymentAttemptFailedInterface $retrievedPayment */ + $retrievedPayment = $created->getDetails(); + expect($retrievedPayment)->toBeInstanceOf(PaymentAttemptFailedInterface::class); + expect($retrievedPayment->getPaymentMethod()->isPaymentRetryEnabled())->toBe(true); +}); diff --git a/tests/integration/Mocks/PaymentResponse.php b/tests/integration/Mocks/PaymentResponse.php index 7affc47a..9e31c2cc 100644 --- a/tests/integration/Mocks/PaymentResponse.php +++ b/tests/integration/Mocks/PaymentResponse.php @@ -94,4 +94,8 @@ public static function failed(): Response return new Response(200, [], '{"id":"401cfaa1-8d44-4306-a2f9-a0a6e365f570","amount_in_minor":1,"metadata":{"metadata_key_1":"metadata_value_1","metadata_key_2":"metadata_value_2","metadata_key_3":"metadata_value_3"},"currency":"GBP","user":{"id":"7ed73602-c8bc-4b2f-8a96-9490a6ea5983"},"payment_method":{"type":"bank_transfer","beneficiary":{"type":"external_account","account_identifier":{"type":"sort_code_account_number","sort_code":"010203","account_number":"12345678"},"account_holder_name":"Bob","reference":"TEST"},"provider_selection":{"type":"user_selected"}},"created_at":"2022-02-06T22:25:43.899669Z","status":"failed","authorization_flow":{"configuration":{"provider_selection":{},"redirect":{"return_uri":"https://penny.t7r.dev/redirect/v3"}}},"failed_at":"2022-02-06T22:26:48.849469Z","failure_stage":"authorizing","failure_reason":"authorization_failed"}'); } + public static function attemptFailed(): Response + { + return new Response(200, [], '{"id":"401cfaa1-8d44-4306-a2f9-a0a6e365f570","amount_in_minor":1,"metadata":{"metadata_key_1":"metadata_value_1","metadata_key_2":"metadata_value_2","metadata_key_3":"metadata_value_3"},"currency":"GBP","user":{"id":"7ed73602-c8bc-4b2f-8a96-9490a6ea5983"},"payment_method":{"retry": {},"type":"bank_transfer","beneficiary":{"type":"external_account","account_identifier":{"type":"sort_code_account_number","sort_code":"010203","account_number":"12345678"},"account_holder_name":"Bob","reference":"TEST"},"provider_selection":{"type":"user_selected"}},"created_at":"2022-02-06T22:25:43.899669Z","status":"attempt_failed","authorization_flow":{"configuration":{"retry": {},"provider_selection":{},"redirect":{"return_uri":"https://penny.t7r.dev/redirect/v3"}}},"failed_at":"2022-02-06T22:26:48.849469Z","failure_stage":"authorizing","failure_reason":"authorization_failed"}'); + } } diff --git a/tests/integration/PaymentRetrievalTest.php b/tests/integration/PaymentRetrievalTest.php index 874f3504..0fc89ef4 100644 --- a/tests/integration/PaymentRetrievalTest.php +++ b/tests/integration/PaymentRetrievalTest.php @@ -13,6 +13,7 @@ use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\RedirectActionInterface; use TrueLayer\Interfaces\Payment\AuthorizationFlow\Action\WaitActionInterface; use TrueLayer\Interfaces\Payment\AuthorizationFlow\ConfigurationInterface; +use TrueLayer\Interfaces\Payment\PaymentAttemptFailedInterface; use TrueLayer\Interfaces\Payment\PaymentAuthorizationRequiredInterface; use TrueLayer\Interfaces\Payment\PaymentAuthorizedInterface; use TrueLayer\Interfaces\Payment\PaymentAuthorizingInterface; @@ -254,6 +255,7 @@ function assertPaymentCommon(PaymentRetrievedInterface $payment) \expect($payment->isExecuted())->toBe(false); \expect($payment->isSettled())->toBe(false); \expect($payment->isFailed())->toBe(true); + \expect($payment->isAttemptFailed())->toBe(false); \expect($payment->getFailedAt()->format(DateTime::FORMAT))->toBe('2022-02-06T22:26:48.849469Z'); \expect($payment->getFailureStage())->toBe('authorizing'); \expect($payment->getFailureReason())->toBe('authorization_failed'); @@ -266,6 +268,26 @@ function assertPaymentCommon(PaymentRetrievedInterface $payment) \assertPaymentCommon($payment); }); +\it('handles payment attempt failed', function () { + /** @var PaymentAttemptFailedInterface $payment */ + $payment = \client(PaymentResponse::attemptFailed())->getPayment('1'); + + \expect($payment)->toBeInstanceOf(PaymentAttemptFailedInterface::class); + \expect($payment->getAuthorizationFlowConfig())->toBeInstanceOf(ConfigurationInterface::class); + \expect($payment->isAuthorizationRequired())->toBe(false); + \expect($payment->isAuthorizing())->toBe(false); + \expect($payment->isAuthorized())->toBe(false); + \expect($payment->isExecuted())->toBe(false); + \expect($payment->isSettled())->toBe(false); + \expect($payment->isFailed())->toBe(false); + \expect($payment->isAttemptFailed())->toBe(true); + \expect($payment->getFailedAt()->format(DateTime::FORMAT))->toBe('2022-02-06T22:26:48.849469Z'); + \expect($payment->getFailureStage())->toBe('authorizing'); + \expect($payment->getFailureReason())->toBe('authorization_failed'); + + \assertPaymentCommon($payment); +}); + \it('handles payment with no auth flow config', function () { /** @var PaymentExecutedInterface $payment */ $payment = \client(PaymentResponse::executedNoAuthFlow())->getPayment('1');