Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
```

Expand Down
2 changes: 2 additions & 0 deletions config/bindings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions config/discriminations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/Constants/WebhookEventTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
87 changes: 87 additions & 0 deletions src/Entities/Webhook/PayoutAttemptFailedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace TrueLayer\Entities\Webhook;

use TrueLayer\Interfaces\Webhook\PayoutAttemptFailedEventInterface;
use TrueLayer\Interfaces\Webhook\PayoutAttemptFailedRetryInterface;

class PayoutAttemptFailedEvent extends PayoutEvent implements PayoutAttemptFailedEventInterface
{
/**
* @var \DateTimeInterface
*/
protected \DateTimeInterface $failedAt;

/**
* @var string
*/
protected string $failureReason;

/**
* @var string
*/
protected string $schemeId;

/**
* @var PayoutAttemptFailedRetryInterface
*/
protected PayoutAttemptFailedRetryInterface $retry;

/**
* @return mixed[]
*/
protected function casts(): array
{
return \array_merge(parent::casts(), [
'failed_at' => \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;
}
}
80 changes: 80 additions & 0 deletions src/Entities/Webhook/PayoutAttemptFailedRetry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace TrueLayer\Entities\Webhook;

use TrueLayer\Entities\Entity;
use TrueLayer\Interfaces\Webhook\PayoutAttemptFailedRetryInterface;

final class PayoutAttemptFailedRetry extends Entity implements PayoutAttemptFailedRetryInterface
{
/**
* @var int
*/
protected int $attemptsMade;

/**
* @var int
*/
protected int $attemptsRemaining;

/**
* @var bool
*/
protected bool $scheduledForRerouting;

/**
* @var \DateTimeInterface
*/
protected \DateTimeInterface $nextAttemptAt;

/**
* @var string[]
*/
protected array $casts = [
'next_attempt_at' => \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;
}
}
36 changes: 36 additions & 0 deletions src/Interfaces/Webhook/PayoutAttemptFailedEventInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace TrueLayer\Interfaces\Webhook;

interface PayoutAttemptFailedEventInterface extends PayoutEventInterface
{
/**
* Get the date and time the payout attempt failed.
*
* @return \DateTimeInterface
*/
public function getFailedAt(): \DateTimeInterface;

/**
* Get the reason the payout attempt failed.
*
* @return string|null
*/
public function getFailureReason(): ?string;

/**
* Get the unique identifier for the scheme.
*
* @return string|null
*/
public function getSchemeId(): ?string;

/**
* Get details about the retry attempt.
*
* @return PayoutAttemptFailedRetryInterface|null
*/
public function getRetry(): ?PayoutAttemptFailedRetryInterface;
}
38 changes: 38 additions & 0 deletions src/Interfaces/Webhook/PayoutAttemptFailedRetryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace TrueLayer\Interfaces\Webhook;

use TrueLayer\Interfaces\ArrayableInterface;

interface PayoutAttemptFailedRetryInterface extends ArrayableInterface
{
/**
* Get the number of retry attempts that have been made so far.
*
* @return int
*/
public function getAttemptsMade(): int;

/**
* Get the number of retry attempts remaining.
*
* @return int
*/
public function getAttemptsRemaining(): int;

/**
* Whether the payout is scheduled for rerouting to a different scheme.
*
* @return bool
*/
public function isScheduledForRerouting(): bool;

/**
* Get the date and time of the next retry attempt.
*
* @return \DateTimeInterface
*/
public function getNextAttemptAt(): \DateTimeInterface;
}
19 changes: 19 additions & 0 deletions tests/integration/Mocks/WebhookPayload.php
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,25 @@ public static function payoutFailedWithMetadata(): string
}';
}

public static function payoutAttemptFailed(): string
{
return '{
"type": "payout_attempt_failed",
"event_version": 1,
"event_id": "b8d4dda0-ff2c-4d77-a6da-4615e4bad941",
"payout_id": "0cd1b0f7-71bc-4d24-b209-95259dadcc20",
"failed_at": "2021-12-25T15:00:00.000Z",
"failure_reason": "scheme_error",
"scheme_id": "faster_payments_service",
"retry": {
"attempts_made": 2,
"attempts_remaining": 3,
"scheduled_for_rerouting": false,
"next_attempt_at": "2021-12-25T16:00:00.000Z"
}
}';
}

public static function payoutExecutedClosedLoop(): string
{
return '{
Expand Down
25 changes: 24 additions & 1 deletion tests/integration/WebhookPayoutHandlersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use TrueLayer\Interfaces\Webhook\Beneficiary\BusinessAccountBeneficiaryInterface;
use TrueLayer\Interfaces\Webhook\Beneficiary\ExternalAccountBeneficiaryInterface;
use TrueLayer\Interfaces\Webhook\Beneficiary\PaymentSourceBeneficiaryInterface;
use TrueLayer\Interfaces\Webhook\PayoutAttemptFailedEventInterface;
use TrueLayer\Interfaces\Webhook\PayoutEventInterface;
use TrueLayer\Interfaces\Webhook\PayoutExecutedEventInterface;
use TrueLayer\Interfaces\Webhook\PayoutFailedEventInterface;
Expand All @@ -31,6 +32,7 @@
})->with([
'executed' => WebhookPayload::payoutExecuted(),
'failed' => WebhookPayload::payoutFailed(),
'attempt failed' => WebhookPayload::payoutAttemptFailed(),
]);

\it('handles payout executed', function () {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -114,7 +138,6 @@
'failed, with metadata' => [WebhookPayload::payoutFailedWithMetadata(), ['foo' => 'bar']],
]);


\it('handles external account payouts', function (string $body) {
/** @var PayoutEventInterface $event */
$event = null;
Expand Down
Loading