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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to `vatly-api-php` will be documented in this file.

## Unreleased

## v0.1.0-alpha.34

### Added

- **`lines` on the `OrderPaymentFailed` webhook DTO**: an `OrderLineData[]` mapped from the payload exactly as on `OrderPaid`, so `$event->lines[0]->relatedResources?->subscriptionId` names the subscription whose payment failed without a follow-up `GetOrder`. The `order.payment_failed` payload already carried the lines; the DTO dropped them. Additive: an optional last constructor argument defaulting to `[]`, and a payload without a `lines` key still yields `[]`.

### Changed

- The `OrderPaymentFailed` docblock no longer says the event is enriched with a `GetOrder` call. Like `OrderPaid`, it is built from the signed payload.

## v0.1.0-alpha.33

### Added
Expand Down
41 changes: 36 additions & 5 deletions src/API/Webhooks/Events/OrderPaymentFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Vatly\API\Resources\Order as ApiOrder;
use Vatly\API\Types\Money;
use Vatly\API\Types\OrderLineData;
use Vatly\API\Types\TaxSummaryCollection;
use Vatly\API\Types\WebhookEventName;
use Vatly\API\Webhooks\Concerns\NormalizesWebhookMetadata;
Expand All @@ -14,13 +15,15 @@
* Event representing a failed payment attempt against an order at Vatly —
* typically the start of dunning.
*
* The webhook is order-scoped; consumers that need to identify the affected
* subscription should resolve it via their own customer/subscription map
* (the same pattern as for renewal `order.paid`).
* The webhook is order-scoped. To find the affected subscription, read
* `lines[].relatedResources->subscriptionId`; it is null on orders from
* before Vatly linked lines to subscriptions, so keep a fallback for those.
*
* Carries the full order shape (mirroring {@see OrderPaid}) so consumers can
* surface failure details without a follow-up API call. The webhook payload
* itself is sparse; the webhook event factory enriches via `GetOrder`.
* surface failure details without a follow-up API call. The signed webhook
* payload carries the same body as `GET /orders/{id}`, and
* {@see \Vatly\API\Webhooks\WebhookEventFactory} hydrates this event straight
* from it, with no follow-up API GET.
*
* @immutable
*/
Expand All @@ -42,6 +45,13 @@ public function __construct(
public bool $testmode,
/** @var array<string, mixed>|null */
public ?array $metadata = null,
/**
* The order's lines, mapped the same way as on {@see OrderPaid}.
* Empty when the order carries none.
*
* @var OrderLineData[]
*/
public array $lines = [],
) {
//
}
Expand All @@ -59,6 +69,27 @@ public static function fromApiOrder(ApiOrder $order): self
paymentMethod: $order->paymentMethod,
testmode: $order->testmode,
metadata: self::normalizeMetadata($order->metadata),
lines: self::mapLines($order),
);
}

/**
* Map the order's lines into {@see OrderLineData}. A payload without a
* `lines` key yields no lines, as this event carried none before.
*
* @return OrderLineData[]
*/
private static function mapLines(ApiOrder $order): array
{
if (! isset($order->lines)) {
return [];
}

$lines = [];
foreach ($order->lines() as $line) {
$lines[] = OrderLineData::fromApiOrderLine($line);
}

return $lines;
}
}
48 changes: 48 additions & 0 deletions tests/Webhooks/Events/OrderPaymentFailedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Vatly\API\Resources\Order;
use Vatly\API\Types\Money;
use Vatly\API\Types\OrderLineData;
use Vatly\API\Types\OrderLineRelatedResources;
use Vatly\API\Types\TaxSummaryCollection;
use Vatly\API\Webhooks\Events\OrderPaymentFailed;
use Vatly\Tests\BaseTestCase;
Expand Down Expand Up @@ -69,6 +71,52 @@ public function test_it_passes_through_the_api_tax_summary_collection(): void
$this->assertSame($order->taxSummary, $event->taxSummary);
}

public function test_it_maps_lines_with_their_subscription_link(): void
{
$order = $this->makeApiOrder();
$order->lines = json_decode((string) json_encode([
[
'id' => 'order_item_renewal',
'resource' => 'orderline',
'description' => 'Pro plan',
'quantity' => 1,
'productType' => 'subscription',
'productId' => 'subscription_abc',
'relatedResources' => ['subscriptionId' => 'subscription_abc'],
'basePrice' => ['currency' => 'EUR', 'value' => '81.82'],
'total' => ['currency' => 'EUR', 'value' => '99.00'],
'subtotal' => ['currency' => 'EUR', 'value' => '81.82'],
'taxes' => [],
],
[
'id' => 'order_item_legacy',
'resource' => 'orderline',
'description' => 'Legacy line',
'quantity' => 1,
'productType' => 'subscription',
'productId' => 'subscription_old',
'basePrice' => ['currency' => 'EUR', 'value' => '0.00'],
'total' => ['currency' => 'EUR', 'value' => '0.00'],
'subtotal' => ['currency' => 'EUR', 'value' => '0.00'],
'taxes' => [],
],
]));

$event = OrderPaymentFailed::fromApiOrder($order);

$this->assertCount(2, $event->lines);
$this->assertContainsOnlyInstancesOf(OrderLineData::class, $event->lines);
$this->assertSame('order_item_renewal', $event->lines[0]->vatlyId);
$this->assertInstanceOf(OrderLineRelatedResources::class, $event->lines[0]->relatedResources);
$this->assertSame('subscription_abc', $event->lines[0]->relatedResources->subscriptionId);
$this->assertNull($event->lines[1]->relatedResources);
}

public function test_it_has_no_lines_when_the_payload_carries_none(): void
{
$this->assertSame([], OrderPaymentFailed::fromApiOrder($this->makeApiOrder())->lines);
}

public function test_it_normalizes_object_metadata(): void
{
$order = $this->makeApiOrder();
Expand Down
Loading