diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb1db5..eb7024b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/API/Webhooks/Events/OrderPaymentFailed.php b/src/API/Webhooks/Events/OrderPaymentFailed.php index 07124ab..fc75692 100644 --- a/src/API/Webhooks/Events/OrderPaymentFailed.php +++ b/src/API/Webhooks/Events/OrderPaymentFailed.php @@ -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; @@ -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 */ @@ -42,6 +45,13 @@ public function __construct( public bool $testmode, /** @var array|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 = [], ) { // } @@ -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; + } } diff --git a/tests/Webhooks/Events/OrderPaymentFailedTest.php b/tests/Webhooks/Events/OrderPaymentFailedTest.php index d7e39e4..223a4f7 100644 --- a/tests/Webhooks/Events/OrderPaymentFailedTest.php +++ b/tests/Webhooks/Events/OrderPaymentFailedTest.php @@ -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; @@ -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();