From d92fcefd7ec3b1a87b99dcff93ae29ed218d99ba Mon Sep 17 00:00:00 2001 From: Sander van Hooft <7265703+sandervanhooft@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:57:50 +0000 Subject: [PATCH] Expose relatedResources.subscriptionId on order lines and sync the OpenAPI spec OrderLine and OrderLineData gain a nullable relatedResources with a nullable subscriptionId: the subscription a signup, renewal or immediate-update line belongs to. It is null on historical and unrelated lines and when the key is absent; an unresolved signup line keeps the object with a null subscriptionId. openapi.yaml is synced with the published production bundle, which also adds links.customerInvoice to the Refund and Chargeback schemas. The productType/productId docblocks now describe the plan reference on initial signup lines. Release v0.1.0-alpha.33 in the changelog. Amp-Thread-ID: https://ampcode.com/threads/T-01a1d5d1-d2d3-71cb-987c-ca801d16b2ba Co-authored-by: Amp --- CHANGELOG.md | 16 +++ docs/Orders.md | 28 +++++ openapi.yaml | 119 ++++++++++++++++++-- src/API/Resources/OrderLine.php | 20 +++- src/API/Resources/ResourceFactory.php | 8 ++ src/API/Types/OrderLineData.php | 16 ++- src/API/Types/OrderLineRelatedResources.php | 45 ++++++++ tests/Endpoints/OrderEndpointTest.php | 5 + tests/Webhooks/Events/OrderPaidTest.php | 31 +++++ 9 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 src/API/Types/OrderLineRelatedResources.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c71afb..3bb1db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to `vatly-api-php` will be documented in this file. ## Unreleased +## v0.1.0-alpha.33 + +### Added + +- **`relatedResources` on order lines**: `OrderLine->relatedResources` and `OrderLineData->relatedResources` (on the `OrderPaid` webhook DTO's `lines`) are a nullable `Vatly\API\Types\OrderLineRelatedResources` with a `?string $subscriptionId`: the subscription the line belongs to, on initial signup, renewal and immediate-update lines alike. To find the subscription a paid checkout created, follow `Checkout->orderId` and read its lines. + - `relatedResources` is `null` on historical lines, on lines unrelated to a subscription (such as one-off product lines), and when a payload leaves the key out. + - On a signup line, `subscriptionId` is `null` while the subscription is not available yet. Retrieve the order again later to read it. + - A subscription ID does not mean the subscription is active. Check its status before granting access. + - Additive: `OrderLineData` gains an optional last constructor argument that defaults to `null`. + +### Changed + +- **Synced `openapi.yaml`** with the published spec. It adds `lines[].relatedResources` to orders, and `links.customerInvoice` (the credit note, `Link` or `null`) to the Refund and Chargeback schemas, which the SDK already supported since v0.1.0-alpha.32. +- `productType` and `productId` keep their meaning. The docs now say it correctly: an initial signup line references the plan (`subscription_plan`, a `subscription_plan_…` ID); renewal and immediate-update lines reference the subscription (`subscription`). Earlier docblocks said a subscription line always carried the subscription ID. +- `docs/Orders.md` documents the order line properties. + ## v0.1.0-alpha.32 ### Added diff --git a/docs/Orders.md b/docs/Orders.md index 3f20c50..853b433 100644 --- a/docs/Orders.md +++ b/docs/Orders.md @@ -29,6 +29,34 @@ Below you'll find all properties for the Vatly Order resource. | `createdAt` | `string` | Creation timestamp (ISO 8601). | | `links` | `OrderLinks` | Links to this order, the customer (`null` for orders with no associated customer) and the customer invoice. The `customerInvoice` URL is signed and expires after 72 hours (the customer then sees a page to request a new link). | +### Order line properties + +| Name | Type | Description | +| --- | --- | --- | +| `id` | `string` | Unique identifier for the order line. | +| `description` | `string` | Line description. | +| `quantity` | `int` | Quantity. On a subscription line this is the quantity of one subscription, not a count of subscriptions. | +| `basePrice` | `Money` | Price per unit before taxes. | +| `total` | `Money` | Line total including taxes. | +| `subtotal` | `Money` | Line subtotal before taxes. | +| `taxes` | `TaxSummaryCollection` | Tax breakdown for this line. | +| `productType` | `string \| null` | The linked resource type: `subscription_plan` on an initial subscription signup line, `subscription` on a renewal or immediate-update line, `one_off_product` for a one-off product. `null` when the line has no product link. | +| `productId` | `string \| null` | The linked resource's ID: a plan ID for `subscription_plan`, a subscription ID for `subscription`. | +| `relatedResources` | `OrderLineRelatedResources \| null` | Additional relationships for this line. `null` on historical lines and on lines unrelated to a subscription. | +| `relatedResources->subscriptionId` | `string \| null` | The subscription this line belongs to, on signup, renewal and immediate-update lines alike. `null` on a signup line while the subscription is not available yet; retrieve the order again later to read it. | + +To find the subscription a paid checkout created, follow the checkout's `orderId` and read the lines' `relatedResources`: + +```php +$order = $vatly->orders->get($checkout->orderId); + +foreach ($order->lines() as $line) { + $subscriptionId = $line->relatedResources?->subscriptionId; +} +``` + +A subscription ID alone does not mean the subscription is active. Check the subscription's status before granting access. + --- ## Retrieve an order diff --git a/openapi.yaml b/openapi.yaml index 3c61dca..7c32bda 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1965,6 +1965,10 @@ paths: description: | Retrieves a specific order by its ID. Includes full order details with line items, totals, and billing information. + For a paid checkout, follow `Checkout.orderId` and inspect `lines[].relatedResources`. + Signup, renewal and immediate-update lines expose the same subscriptionId. + Product references are unchanged. Neither + creation, a paid order nor an ID alone proves activation or entitlement. tags: - Orders parameters: @@ -2009,7 +2013,7 @@ paths: lines: - id: order_item_Jk4pQrSvWm8NjLhYbUcP resource: orderline - description: Pro Monthly Subscription + description: Pro Monthly Subscription — initial checkout quantity: 1 basePrice: value: '29.00' @@ -2028,8 +2032,10 @@ paths: amount: value: '6.09' currency: EUR - productType: subscription - productId: subscription_Hn5xWqVfKm8RjTgYbUcP + productType: subscription_plan + productId: subscription_plan_Hn5xWqVfKm8RjTgYbUcP + relatedResources: + subscriptionId: subscription_Hn5xWqVfKm8RjTgYbUcP customerDetails: fullName: John Doe companyName: Acme Corp @@ -2191,6 +2197,7 @@ paths: href: https://api.vatly.com/v1/orders/order_Fp2kQrSvWm8NjLhYbUcP type: application/json order: null + customerInvoice: null count: 1 links: self: @@ -2322,6 +2329,7 @@ paths: href: https://api.vatly.com/v1/orders/order_Fp2kQrSvWm8NjLhYbUcP type: application/json order: null + customerInvoice: null '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2450,6 +2458,7 @@ paths: href: https://api.vatly.com/v1/orders/order_Fp2kQrSvWm8NjLhYbUcP type: application/json order: null + customerInvoice: null '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2537,6 +2546,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Bm7xNvPwKr3YjTgHcZaE type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Bm7xNvPwKr3YjTgHcZaE + type: text/html '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2668,6 +2680,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Rk5pQrSvWm8NjLhYbUcP type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Rk5pQrSvWm8NjLhYbUcP + type: text/html count: 1 links: self: @@ -2745,6 +2760,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Rk5pQrSvWm8NjLhYbUcP type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Rk5pQrSvWm8NjLhYbUcP + type: text/html '401': $ref: '#/components/responses/Unauthorized' '403': @@ -2845,6 +2863,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Bm7xNvPwKr3YjTgHcZaE type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Bm7xNvPwKr3YjTgHcZaE + type: text/html count: 1 links: self: @@ -2931,6 +2952,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Bm7xNvPwKr3YjTgHcZaE type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Bm7xNvPwKr3YjTgHcZaE + type: text/html '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3020,6 +3044,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Rk5pQrSvWm8NjLhYbUcP type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Rk5pQrSvWm8NjLhYbUcP + type: text/html count: 1 links: self: @@ -3091,6 +3118,9 @@ paths: order: href: https://api.vatly.com/v1/orders/order_Rk5pQrSvWm8NjLhYbUcP type: application/json + customerInvoice: + href: https://vatly.com/invoices/order_Rk5pQrSvWm8NjLhYbUcP + type: text/html '401': $ref: '#/components/responses/Unauthorized' '403': @@ -6737,10 +6767,20 @@ components: customerInvoice: allOf: - $ref: '#/components/schemas/Link' - description: Link to the customer-facing invoice page in the customer portal (text/html) + description: | + Link to the customer-facing invoice page in the customer portal (text/html). + On a refund or chargeback order this page is the credit note. The link is signed + and expires after 72 hours; an expired link lets the customer request a fresh one by email. OrderLine: type: object - description: A line item in an order + description: | + A line item in an order. `productType`/`productId` retain their existing meaning: + initial subscription lines reference the plan; renewal and immediate-update + lines reference the existing subscription. `relatedResources` adds the exact + per-line subscription relationship without repeating the product reference. + Discounts remain components of their owning line. + Follow a paid Checkout's `orderId` to its order lines; Checkout is unchanged. + No historical backfill is performed. required: - id - resource @@ -6795,14 +6835,55 @@ components: - billing_details_verification - auth_request_product - null - description: The product type this line links to; null when the line has no product link. - example: subscription + description: | + Linked resource type. Initial subscription orders use + `subscription_plan`; renewals and immediate updates use `subscription`. + Existing values and new-write behavior are unchanged. + example: subscription_plan productId: type: - string - 'null' - description: The linked product's id; for a `subscription` line this is the public `subscription_…` id. - example: subscription_Hn5xWqVfKm8RjTgYbUcP + description: | + Linked resource ID: a plan ID when productType is subscription_plan, + a subscription ID when productType is subscription. Existing values and + new-write behavior are unchanged. + example: subscription_plan_Hn5xWqVfKm8RjTgYbUcP + relatedResources: + description: | + Additional per-line relationships. Null on historical lines without recorded + correlation and on unrelated lines (including one-off, billing-verification + and authorization-minimum lines). Older payloads may omit this property. + Signup, renewal and immediate-update lines use the same subscriptionId: + the subscription associated with this line, not a creation or entitlement + signal. On ordinary sale lines productType distinguishes initial signup + (subscription_plan) from renewal/update (subscription). It does not + distinguish a renewal from an immediate update. Reversal lines copy the + original relationship and product reference, not a new creation claim. + oneOf: + - type: object + additionalProperties: false + required: + - subscriptionId + properties: + subscriptionId: + type: + - string + - 'null' + description: | + Subscription associated with this line. On initial signup lines, + null while the exact subscription projection is unavailable, + including pending or skipped creation; null does not distinguish + these cases and is not proof that creation failed or was skipped. + The deterministic per-line identity is retained durably, so GET + reveals the ID after projection recovery without recreating the + paid order. Frozen webhook deliveries retain their original snapshot; + GET and newly generated webhooks use the current availability. + Separate same-plan checkout lines have distinct subscription IDs; + quantity is the quantity of one subscription, not a subscription count. + Creation does not imply activation, entitlement or immediate readability. + Validate ownership and status before granting access. + - type: 'null' InvoiceUpdateLink: type: object description: A signed URL for the customer to update invoice details (billing address, VAT number, company name) @@ -6920,6 +7001,7 @@ components: - self - originalOrder - order + - customerInvoice additionalProperties: false properties: self: @@ -6935,6 +7017,15 @@ components: - $ref: '#/components/schemas/Link' - type: 'null' description: Link to the credit note order. Always present; `null` until the credit-note order is created. + customerInvoice: + oneOf: + - $ref: '#/components/schemas/Link' + - type: 'null' + description: | + Link to this refund's credit note in the customer portal (text/html). This is the + same page as the credit-note order's `customerInvoice` link, not the original order's invoice. + Always present; `null` until the refund is processed. The link is signed and + expires after 72 hours; an expired link lets the customer request a fresh one by email. RefundLine: type: object description: A line item in a refund @@ -7124,6 +7215,7 @@ components: - self - originalOrder - order + - customerInvoice additionalProperties: false properties: self: @@ -7139,6 +7231,15 @@ components: - $ref: '#/components/schemas/Link' - type: 'null' description: Link to the credit note order. Always present; `null` until the credit-note order is created. + customerInvoice: + oneOf: + - $ref: '#/components/schemas/Link' + - type: 'null' + description: | + Link to this chargeback's credit note in the customer portal (text/html). This is the + same page as the credit-note order's `customerInvoice` link, not the original order's invoice. + Always present; `null` until the lost chargeback is processed. The link is signed and + expires after 72 hours; an expired link lets the customer request a fresh one by email. WebhookEvent: type: object description: | diff --git a/src/API/Resources/OrderLine.php b/src/API/Resources/OrderLine.php index 0f24ea4..6d22b52 100644 --- a/src/API/Resources/OrderLine.php +++ b/src/API/Resources/OrderLine.php @@ -3,6 +3,7 @@ namespace Vatly\API\Resources; use Vatly\API\Types\Money; +use Vatly\API\Types\OrderLineRelatedResources; use Vatly\API\Types\TaxSummaryCollection; class OrderLine extends BaseResource @@ -25,20 +26,29 @@ class OrderLine extends BaseResource public int $quantity; /** - * The product type this line links to. Null until the backend ships it. + * The resource type this line links to. An initial subscription signup + * line uses `subscription_plan`; renewal and immediate-update lines use + * `subscription`. Null when the line has no product link. * - * @example subscription + * @example subscription_plan */ public ?string $productType = null; /** - * The linked product's id. For a subscription line this is the public - * `subscription_…` id. Null until the backend ships it. + * The linked resource's id: a plan id when `productType` is + * `subscription_plan`, a subscription id when it is `subscription`. * - * @example subscription_2a46f4c01d3b47979f4d7b3f58c98be7 + * @example subscription_plan_2a46f4c01d3b47979f4d7b3f58c98be7 */ public ?string $productId = null; + /** + * Additional per-line relationships, such as the subscription this line + * belongs to. Null on historical lines and on lines unrelated to a + * subscription, and when an older response leaves the key out. + */ + public ?OrderLineRelatedResources $relatedResources = null; + public Money $basePrice; public Money $total; diff --git a/src/API/Resources/ResourceFactory.php b/src/API/Resources/ResourceFactory.php index 851c4d2..4e3ee8f 100644 --- a/src/API/Resources/ResourceFactory.php +++ b/src/API/Resources/ResourceFactory.php @@ -11,6 +11,7 @@ use Vatly\API\Types\Address; use Vatly\API\Types\Mandate; use Vatly\API\Types\Money; +use Vatly\API\Types\OrderLineRelatedResources; use Vatly\API\Types\ScheduledSubscriptionUpdate; use Vatly\API\Types\TaxSummaryCollection; use Vatly\API\VatlyApiClient; @@ -87,6 +88,13 @@ public static function createResourceFromApiResult(object $apiResult, BaseResour break; + case 'relatedResources': + $resource->{$property} = null === $value + ? null + : OrderLineRelatedResources::createResourceFromApiResult($value); + + break; + default: $resource->{$property} = $value; diff --git a/src/API/Types/OrderLineData.php b/src/API/Types/OrderLineData.php index bd3595b..f86bc07 100644 --- a/src/API/Types/OrderLineData.php +++ b/src/API/Types/OrderLineData.php @@ -10,9 +10,11 @@ * Data for a single order line from Vatly. * * Carries the per-line breakdown so drivers can persist first-class order - * lines and traverse subscription↔orders via the generic - * (`productType`, `productId`) pair. A line links to a subscription when - * `productType === 'subscription'` → `productId` is the `subscription_…` id. + * lines. `productType`/`productId` name the linked resource: an initial + * signup line references the plan (`subscription_plan`), a renewal or + * immediate-update line references the subscription (`subscription`). + * To find the subscription a line belongs to, whichever kind of line it is, + * read `relatedResources->subscriptionId`. * * `productType` is carried as the raw API string (no enum) so new backend * product types flow through without an api-php release. @@ -35,6 +37,13 @@ public function __construct( public ?TaxSummaryCollection $taxSummary = null, public ?string $productType = null, public ?string $productId = null, + /** + * The line's `relatedResources`. Null on historical lines, on lines + * unrelated to a subscription, and when the payload leaves the key + * out. On an initial signup line its `subscriptionId` is null until + * the subscription is available. + */ + public ?OrderLineRelatedResources $relatedResources = null, ) { } @@ -50,6 +59,7 @@ public static function fromApiOrderLine(ApiOrderLine $line): self taxSummary: $line->taxes, productType: $line->productType, productId: $line->productId, + relatedResources: $line->relatedResources, ); } } diff --git a/src/API/Types/OrderLineRelatedResources.php b/src/API/Types/OrderLineRelatedResources.php new file mode 100644 index 0000000..6d9fced --- /dev/null +++ b/src/API/Types/OrderLineRelatedResources.php @@ -0,0 +1,45 @@ + $value + */ + public static function createResourceFromApiResult($value): self + { + $value = (array) $value; + + return new self($value['subscriptionId'] ?? null); + } +} diff --git a/tests/Endpoints/OrderEndpointTest.php b/tests/Endpoints/OrderEndpointTest.php index 4f2aef4..c377152 100644 --- a/tests/Endpoints/OrderEndpointTest.php +++ b/tests/Endpoints/OrderEndpointTest.php @@ -61,6 +61,9 @@ public function can_get_order(): void "quantity" => 1, "productType" => "subscription", "productId" => "subscription_2a46f4c01d3b47979f4d7b3f58c98be7", + "relatedResources" => [ + "subscriptionId" => "subscription_2a46f4c01d3b47979f4d7b3f58c98be7", + ], "basePrice" => [ "value" => "80.00", "currency" => "EUR", @@ -165,6 +168,7 @@ public function can_get_order(): void $this->assertEquals('PDF Book', $orderLine->description); $this->assertEquals('subscription', $orderLine->productType); $this->assertEquals('subscription_2a46f4c01d3b47979f4d7b3f58c98be7', $orderLine->productId); + $this->assertSame('subscription_2a46f4c01d3b47979f4d7b3f58c98be7', $orderLine->relatedResources->subscriptionId); $this->assertEquals("96.00", $orderLine->total->value); $this->assertEquals("80.00", $orderLine->subtotal->value); $this->assertEquals("80.00", $orderLine->basePrice->value); @@ -224,6 +228,7 @@ public function order_line_product_link_defaults_to_null_when_absent(): void $this->assertEquals('order_item_2a46f4c01d3b47979f4d7b3f58c98be7', $orderLine->id); $this->assertNull($orderLine->productType); $this->assertNull($orderLine->productId); + $this->assertNull($orderLine->relatedResources); } /** @test */ diff --git a/tests/Webhooks/Events/OrderPaidTest.php b/tests/Webhooks/Events/OrderPaidTest.php index 9e15fb9..48ab3d5 100644 --- a/tests/Webhooks/Events/OrderPaidTest.php +++ b/tests/Webhooks/Events/OrderPaidTest.php @@ -7,6 +7,7 @@ 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\Types\WebhookEventName; use Vatly\API\Webhooks\Events\OrderPaid; @@ -209,6 +210,36 @@ public function test_it_maps_a_line_with_null_product_attribution(): void $this->assertNull($event->lines[0]->productId); } + public function test_it_maps_related_resources_and_keeps_null_and_unresolved_apart(): void + { + $signup = $this->makeApiLine('order_item_signup', 'Pro plan — initial checkout', 1, '20.00', '24.20', '20.00', 'subscription_plan', 'subscription_plan_pro'); + $signup->relatedResources = json_decode('{"subscriptionId":"subscription_abc"}'); + + $unresolvedSignup = $this->makeApiLine('order_item_pending', 'Pro plan — initial checkout', 1, '20.00', '24.20', '20.00', 'subscription_plan', 'subscription_plan_pro'); + $unresolvedSignup->relatedResources = json_decode('{"subscriptionId":null}'); + + $oneOff = $this->makeApiLine('order_item_book', 'PDF Book', 1, '10.00', '12.10', '10.00', 'one_off_product', 'product_book'); + $oneOff->relatedResources = null; + + $legacy = $this->makeApiLine('order_item_legacy', 'Legacy line', 1, '10.00', '10.00', '10.00', 'subscription', 'subscription_abc'); + + $order = $this->makeApiOrder(); + $order->lines = [$signup, $unresolvedSignup, $oneOff, $legacy]; + + $event = OrderPaid::fromApiOrder($order); + + $this->assertInstanceOf(OrderLineRelatedResources::class, $event->lines[0]->relatedResources); + $this->assertSame('subscription_abc', $event->lines[0]->relatedResources->subscriptionId); + $this->assertSame('subscription_plan', $event->lines[0]->productType); + $this->assertSame('subscription_plan_pro', $event->lines[0]->productId); + + $this->assertInstanceOf(OrderLineRelatedResources::class, $event->lines[1]->relatedResources); + $this->assertNull($event->lines[1]->relatedResources->subscriptionId); + + $this->assertNull($event->lines[2]->relatedResources); + $this->assertNull($event->lines[3]->relatedResources); + } + public function test_it_defaults_to_empty_lines(): void { $event = OrderPaid::fromApiOrder($this->makeApiOrder());