diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c621b9..12ed4347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Support for `hosted_page` on payment creation (`return_uri`, `country_code`, `language_code`, `max_wait_for_result`), exposing `getHostedPageUri()` on the created payment +- Support for the `payout_attempt_failed` webhook ## [3.3.0] - 2025-09-17 diff --git a/README.md b/README.md index d9355a75..c6af7ba8 100644 --- a/README.md +++ b/README.md @@ -1184,6 +1184,7 @@ This library supports handlers for the following event types: - refund_failed - payout_executed - payout_failed +- payout_attempt_failed You can also handle other event types by typehinting `TrueLayer\Interfaces\Webhook\EventInterface` in your handler. You can then get the payload data by calling the `getBody()` method on your variable. @@ -1301,6 +1302,20 @@ $client->webhook() $event->getFailedAt(); $event->getFailureReason(); }) + ->handler(function (Webhook\PayoutAttemptFailedEventInterface $event) { + // handle payout attempt failed + // Sent when a payout attempt fails but will be retried. Only available if payout retries are enabled. + // Inherits from PayoutEventInterface so provides same methods plus: + $event->getFailedAt(); + $event->getFailureReason(); + $event->getSchemeId(); + + $retry = $event->getRetry(); + $retry->getAttemptsMade(); + $retry->getAttemptsRemaining(); + $retry->isScheduledForRerouting(); + $retry->getNextAttemptAt(); + }) ->execute(); ``` diff --git a/config/bindings.php b/config/bindings.php index 11e577ab..8f487004 100644 --- a/config/bindings.php +++ b/config/bindings.php @@ -104,6 +104,8 @@ Interfaces\Webhook\RefundFailedEventInterface::class => Entities\Webhook\RefundFailedEvent::class, Interfaces\Webhook\PayoutExecutedEventInterface::class => Entities\Webhook\PayoutExecutedEvent::class, Interfaces\Webhook\PayoutFailedEventInterface::class => Entities\Webhook\PayoutFailedEvent::class, + Interfaces\Webhook\PayoutAttemptFailedEventInterface::class => Entities\Webhook\PayoutAttemptFailedEvent::class, + Interfaces\Webhook\PayoutAttemptFailedRetryInterface::class => Entities\Webhook\PayoutAttemptFailedRetry::class, Interfaces\Webhook\PaymentMethod\BankTransferPaymentMethodInterface::class => Entities\Webhook\PaymentMethod\BankTransferPaymentMethod::class, Interfaces\Webhook\PaymentMethod\MandatePaymentMethodInterface::class => Entities\Webhook\PaymentMethod\MandatePaymentMethod::class, Interfaces\Webhook\Beneficiary\BusinessAccountBeneficiaryInterface::class => Entities\Webhook\Beneficiary\BusinessAccountBeneficiary::class, diff --git a/config/discriminations.php b/config/discriminations.php index f5cc23ce..9de103cd 100644 --- a/config/discriminations.php +++ b/config/discriminations.php @@ -103,6 +103,7 @@ WebhookEventTypes::REFUND_FAILED => Interfaces\Webhook\RefundFailedEventInterface::class, WebhookEventTypes::PAYOUT_EXECUTED => Interfaces\Webhook\PayoutExecutedEventInterface::class, WebhookEventTypes::PAYOUT_FAILED => Interfaces\Webhook\PayoutFailedEventInterface::class, + WebhookEventTypes::PAYOUT_ATTEMPT_FAILED => Interfaces\Webhook\PayoutAttemptFailedEventInterface::class, ], Interfaces\Webhook\PaymentMethod\PaymentMethodInterface::class => [ 'discriminate_on' => 'type', diff --git a/src/Constants/WebhookEventTypes.php b/src/Constants/WebhookEventTypes.php index ccef4b24..5dbd6128 100644 --- a/src/Constants/WebhookEventTypes.php +++ b/src/Constants/WebhookEventTypes.php @@ -19,4 +19,5 @@ class WebhookEventTypes public const PAYOUT_EXECUTED = 'payout_executed'; public const PAYOUT_FAILED = 'payout_failed'; + public const PAYOUT_ATTEMPT_FAILED = 'payout_attempt_failed'; } diff --git a/src/Entities/Webhook/PayoutAttemptFailedEvent.php b/src/Entities/Webhook/PayoutAttemptFailedEvent.php new file mode 100644 index 00000000..f459a458 --- /dev/null +++ b/src/Entities/Webhook/PayoutAttemptFailedEvent.php @@ -0,0 +1,87 @@ + \DateTimeInterface::class, + 'retry' => PayoutAttemptFailedRetryInterface::class, + ]); + } + + /** + * @return mixed[] + */ + protected function arrayFields(): array + { + return \array_merge(parent::arrayFields(), [ + 'failed_at', + 'failure_reason', + 'scheme_id', + 'retry', + ]); + } + + /** + * @return \DateTimeInterface + */ + public function getFailedAt(): \DateTimeInterface + { + return $this->failedAt; + } + + /** + * @return string|null + */ + public function getFailureReason(): ?string + { + return $this->failureReason ?? null; + } + + /** + * @return string|null + */ + public function getSchemeId(): ?string + { + return $this->schemeId ?? null; + } + + /** + * @return PayoutAttemptFailedRetryInterface|null + */ + public function getRetry(): ?PayoutAttemptFailedRetryInterface + { + return $this->retry ?? null; + } +} diff --git a/src/Entities/Webhook/PayoutAttemptFailedRetry.php b/src/Entities/Webhook/PayoutAttemptFailedRetry.php new file mode 100644 index 00000000..e2a844e2 --- /dev/null +++ b/src/Entities/Webhook/PayoutAttemptFailedRetry.php @@ -0,0 +1,80 @@ + \DateTimeInterface::class, + ]; + + /** + * @var array|string[] + */ + protected array $arrayFields = [ + 'attempts_made', + 'attempts_remaining', + 'scheduled_for_rerouting', + 'next_attempt_at', + ]; + + /** + * @return int + */ + public function getAttemptsMade(): int + { + return $this->attemptsMade; + } + + /** + * @return int + */ + public function getAttemptsRemaining(): int + { + return $this->attemptsRemaining; + } + + /** + * @return bool + */ + public function isScheduledForRerouting(): bool + { + return $this->scheduledForRerouting; + } + + /** + * @return \DateTimeInterface + */ + public function getNextAttemptAt(): \DateTimeInterface + { + return $this->nextAttemptAt; + } +} diff --git a/src/Interfaces/Webhook/PayoutAttemptFailedEventInterface.php b/src/Interfaces/Webhook/PayoutAttemptFailedEventInterface.php new file mode 100644 index 00000000..5b51a625 --- /dev/null +++ b/src/Interfaces/Webhook/PayoutAttemptFailedEventInterface.php @@ -0,0 +1,36 @@ +with([ 'executed' => WebhookPayload::payoutExecuted(), 'failed' => WebhookPayload::payoutFailed(), + 'attempt failed' => WebhookPayload::payoutAttemptFailed(), ]); \it('handles payout executed', function () { @@ -61,6 +63,28 @@ \expect($event->getType())->toBe('payout_failed'); }); +\it('handles payout attempt failed', function () { + /** @var PayoutAttemptFailedEventInterface $event */ + $event = null; + + \webhook(WebhookPayload::payoutAttemptFailed())->handler(function (PayoutAttemptFailedEventInterface $evt) use (&$event) { + $event = $evt; + })->execute(); + + \expect($event)->toBeInstanceOf(PayoutAttemptFailedEventInterface::class); + \expect($event->getType())->toBe('payout_attempt_failed'); + \expect($event->getFailedAt()->format(DateTime::FORMAT))->toBe('2021-12-25T15:00:00.000000Z'); + \expect($event->getFailureReason())->toBe('scheme_error'); + \expect($event->getSchemeId())->toBe('faster_payments_service'); + + $retry = $event->getRetry(); + \expect($retry)->not->toBeNull(); + \expect($retry->getAttemptsMade())->toBe(2); + \expect($retry->getAttemptsRemaining())->toBe(3); + \expect($retry->isScheduledForRerouting())->toBeFalse(); + \expect($retry->getNextAttemptAt()->format(DateTime::FORMAT))->toBe('2021-12-25T16:00:00.000000Z'); +}); + \it('handles closed loop payouts', function (string $body) { /** @var PayoutEventInterface $event */ $event = null; @@ -114,7 +138,6 @@ 'failed, with metadata' => [WebhookPayload::payoutFailedWithMetadata(), ['foo' => 'bar']], ]); - \it('handles external account payouts', function (string $body) { /** @var PayoutEventInterface $event */ $event = null;