From 1d1b60bf91f99adf0576c7b73ba1868ab88e5686 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 29 May 2026 00:13:42 +0200 Subject: [PATCH 1/5] feat: adopt latest OpenAPI spec from vatlify and add new webhook event constants Sync openapi.yaml with vatlify main's bundled spec (idempotency support, currency enum, additionalProperties, webhook createdAt/testmode, new event types) and add the matching webhook event-type constants to the SDK. Co-Authored-By: Claude Opus 4.7 --- openapi.yaml | 417 +++++++++++++++++++++++++++++---- src/API/Types/WebhookEvent.php | 5 + 2 files changed, 376 insertions(+), 46 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index c1e5d11..d5e59df 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1,7 +1,8 @@ -openapi: 3.2.0 +openapi: 3.1.0 info: title: Vatly API version: '1.0' + x-generated-at: '2026-05-28' description: | Vatly is a Merchant of Record billing platform for European SaaS companies. This API enables you to manage checkouts, customers, orders, subscriptions, refunds, and more. @@ -83,6 +84,28 @@ info: Local-part Unicode (EAI / SMTPUTF8) is not supported. + ## Idempotency + + All mutating requests (`POST`, `PATCH`, `PUT`, `DELETE`) accept an + optional `Idempotency-Key` header. When present, Vatly records the + first response keyed by `(token, endpoint, key)` and returns it + verbatim on subsequent requests with the same key, marking the + replay with an `Idempotent-Replayed: true` response header. + + - **Use a fresh key per logical operation.** Generate one client-side + (UUIDv4 is recommended) and retry transparently with the same key + on network errors. + - **Keys are bound to the exact parameters of the first request.** + Replaying with a different body or different query parameters + returns `409 Conflict`. Either retry with the original parameters + or generate a new key. + - **Keys expire after 24 hours.** After that the key is reusable as + a fresh idempotency boundary. + - **Scope is per endpoint.** The same key value on a different + endpoint is treated as a new key. + + Sending an `Idempotency-Key` on a `GET` or `HEAD` request is ignored. + ## Errors The API returns standard HTTP status codes and JSON error responses: @@ -93,6 +116,7 @@ info: | 401 | Unauthorized - Missing or invalid token | | 403 | Forbidden - Token invalid or insufficient permissions | | 404 | Not Found - Resource doesn't exist | + | 409 | Conflict - Idempotency-Key reused with different request parameters | | 422 | Unprocessable Entity - Validation failed | | 429 | Too Many Requests - Rate limit exceeded | | 500 | Internal Server Error | @@ -176,6 +200,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -240,6 +265,8 @@ paths: - If not provided, a new customer is created based on checkout form input tags: - Checkouts + parameters: + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: true content: @@ -280,6 +307,9 @@ paths: responses: '201': description: Checkout created successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -308,6 +338,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationFailed' /checkouts/{checkoutId}: @@ -380,6 +412,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -441,6 +474,8 @@ paths: - Link existing users from your system to Vatly tags: - Customers + parameters: + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: true content: @@ -454,6 +489,9 @@ paths: responses: '201': description: Customer created successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -474,6 +512,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationFailed' /customers/{customerId}: @@ -536,6 +576,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -687,6 +728,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -790,6 +832,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -911,6 +954,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -1107,9 +1151,13 @@ paths: - Orders parameters: - $ref: '#/components/parameters/orderId' + - $ref: '#/components/parameters/idempotencyKey' responses: '200': description: Invoice update link generated + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -1123,6 +1171,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' /orders/{orderId}/refunds: get: operationId: listOrderRefunds @@ -1149,6 +1199,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -1249,6 +1300,7 @@ paths: - Refunds parameters: - $ref: '#/components/parameters/orderId' + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: true content: @@ -1282,6 +1334,9 @@ paths: responses: '201': description: Refund created successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -1345,6 +1400,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': description: | Validation failed. Common errors: @@ -1390,6 +1447,7 @@ paths: - Refunds parameters: - $ref: '#/components/parameters/orderId' + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: false content: @@ -1402,6 +1460,9 @@ paths: responses: '201': description: Full refund created successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -1465,6 +1526,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationFailed' /orders/{orderId}/refunds/{refundId}: @@ -1567,15 +1630,21 @@ paths: parameters: - $ref: '#/components/parameters/orderId' - $ref: '#/components/parameters/refundId' + - $ref: '#/components/parameters/idempotencyKey' responses: '204': description: Refund canceled successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': description: Cannot cancel refund (not in pending status) content: @@ -1610,6 +1679,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -1735,6 +1805,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -1920,6 +1991,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -2050,6 +2122,8 @@ paths: subtotal: value: '24.79' currency: EUR + createdAt: '2026-05-21T14:32:08+00:00' + testmode: false links: self: href: https://api.vatly.com/v1/webhook-events/webhook_event_Qk8pRtSvWm2NjLhYcZaE @@ -2085,6 +2159,7 @@ paths: - data - count - links + additionalProperties: false properties: data: type: array @@ -2271,6 +2346,7 @@ paths: - Subscriptions parameters: - $ref: '#/components/parameters/subscriptionId' + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: true content: @@ -2306,6 +2382,9 @@ paths: responses: '200': description: Subscription updated successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -2352,6 +2431,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': description: | Validation failed. Common errors: @@ -2373,14 +2454,16 @@ paths: - **Immediate:** Subscription ends immediately when `immediately=true` is specified **Notes:** - - Subscriptions on grace period can be reactivated via `POST /subscriptions/{subscriptionId}/resume` - - Subscriptions that have fully ended cannot be reactivated + - A subscription that was canceled with a grace period (the default) can be resumed via + `POST /subscriptions/{subscriptionId}/resume` while `ends_at` is still in the future + - A subscription that was canceled immediately cannot be reactivated; create a new subscription instead - Customers retain access until `renewedUntil` date (unless canceled immediately) - No refunds are issued automatically; use the Refunds API if needed tags: - Subscriptions parameters: - $ref: '#/components/parameters/subscriptionId' + - $ref: '#/components/parameters/idempotencyKey' - name: immediately in: query description: | @@ -2394,53 +2477,23 @@ paths: responses: '204': description: Subscription canceled successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': description: Subscription cannot be canceled (already canceled or ended) content: application/json: schema: $ref: '#/components/schemas/Error' - /subscriptions/{subscriptionId}/resume: - post: - operationId: resumeSubscription - summary: Resume a subscription - description: | - Reverses a pending cancellation while the subscription is still on its grace - period (i.e. before `renewedUntil`). The subscription returns to `active` status - and will continue renewing on its existing schedule. - - **Notes:** - - Only subscriptions in the `on_grace_period` status can be resumed - - Subscriptions that have fully ended cannot be resumed - tags: - - Subscriptions - parameters: - - $ref: '#/components/parameters/subscriptionId' - responses: - '200': - description: Subscription resumed - content: - application/json: - schema: - $ref: '#/components/schemas/Subscription' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '422': - description: Subscription cannot be resumed (not on grace period, already ended, or canceled) - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /subscriptions/{subscriptionId}/billing-update-link: post: operationId: createSubscriptionBillingUpdateLink @@ -2463,6 +2516,7 @@ paths: - Subscriptions parameters: - $ref: '#/components/parameters/subscriptionId' + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: true content: @@ -2478,6 +2532,9 @@ paths: responses: '200': description: Billing update link generated + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -2485,6 +2542,7 @@ paths: required: - href - type + additionalProperties: false properties: href: type: string @@ -2501,8 +2559,103 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationFailed' + /subscriptions/{subscriptionId}/resume: + post: + operationId: resumeSubscription + summary: Resume a canceled subscription + description: | + Resumes a subscription that was previously canceled with a grace period, while it is still + within that grace period. + + **When this works:** + - The subscription's `status` is `on_grace_period` + - The subscription's `endsAt` is still in the future + + **Result:** + - Status returns to `active` + - The existing billing cycle and renewal schedule are preserved (no new charge fires immediately) + - The original payment mandate remains in effect + - A `subscription.resumed` webhook is delivered to your configured endpoint + - `canceledAt` and `endsAt` are cleared; `resumedAt` is set on the subscription + - The refreshed subscription is returned in the response body + + **When this does NOT work:** + - Subscription was canceled immediately (`status` is `canceled`) — create a new subscription instead + - Grace period has already lapsed (`endsAt` is in the past) — create a new subscription instead + - Subscription is already active (no-op rejected for clarity, not silently ignored) + tags: + - Subscriptions + parameters: + - $ref: '#/components/parameters/subscriptionId' + - $ref: '#/components/parameters/idempotencyKey' + responses: + '200': + description: Subscription resumed successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + example: + id: subscription_Lp3mNvBxKw7RjTgYcZaE + resource: subscription + customerId: customer_Lp3mNvBxKw7RjTgYcZaE + subscriptionPlanId: subscription_plan_Rk5pQrSvWm8NjLhYbUcP + testmode: false + name: Pro Monthly + description: Full access to all Pro features + billingAddress: + fullName: John Doe + companyName: Acme Corp + streetAndNumber: 123 Main Street + city: Berlin + postalCode: '10115' + country: DE + basePrice: + value: '29.00' + currency: EUR + quantity: 1 + interval: month + intervalCount: 1 + status: active + startedAt: '2024-01-15T10:30:00Z' + endedAt: null + cancelledAt: null + renewedAt: '2024-02-15T10:30:00Z' + renewedUntil: '2024-03-15T10:30:00Z' + nextRenewalAt: '2024-03-15T10:30:00Z' + trialUntil: null + links: + self: + href: https://api.vatly.com/v1/subscriptions/subscription_Lp3mNvBxKw7RjTgYcZaE + type: application/json + customer: + href: https://api.vatly.com/v1/customers/customer_Lp3mNvBxKw7RjTgYcZaE + type: application/json + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + description: | + Subscription is not within an active grace period. Common causes: + - Subscription was canceled immediately (status `canceled`) + - Grace period has already lapsed (`endsAt` in the past) + - Subscription is already active (was not canceled, or already resumed) + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' /test-helpers/subscriptions/{subscriptionId}/fast-forward-renewal: post: operationId: fastForwardSubscriptionRenewal @@ -2521,9 +2674,13 @@ paths: - Test Helpers parameters: - $ref: '#/components/parameters/subscriptionId' + - $ref: '#/components/parameters/idempotencyKey' responses: '200': description: Subscription renewed successfully + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -2570,6 +2727,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' /test-helpers/mandated-payments/{transactionId}/simulate-failure: post: operationId: simulatePaymentFailure @@ -2595,6 +2754,7 @@ paths: schema: type: string example: mollie_mandated_payment_Xk9pQrSvWm4NjLhYbUcP + - $ref: '#/components/parameters/idempotencyKey' requestBody: required: false content: @@ -2620,6 +2780,9 @@ paths: responses: '200': description: Payment failure simulated + headers: + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: @@ -2628,6 +2791,7 @@ paths: - id - status - failureReason + additionalProperties: false properties: id: type: string @@ -2651,6 +2815,8 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' components: securitySchemes: BearerAuth: @@ -2707,6 +2873,28 @@ components: schema: type: string example: checkout_Rk5pQrSvWm8NjLhYbUcP + idempotencyKey: + name: Idempotency-Key + in: header + description: | + A unique key the client generates per logical mutating operation to + make retries safe. Vatly stores the first response keyed by + `(token, endpoint, key)` and returns it verbatim on subsequent + requests with the same key, setting `Idempotent-Replayed: true` on + the replayed response. + + Replaying with a different request body or different query + parameters returns `409 Conflict`. Keys are scoped per endpoint and + retained for 24 hours. + + Recommended format: UUIDv4. Send on every mutating request + (POST, PATCH, PUT, DELETE) where retry safety matters. + required: false + schema: + type: string + minLength: 1 + maxLength: 255 + example: 9f6a3b1c-7e2d-4f7b-9c5a-2c1d5b7e9f3a checkoutId: name: checkoutId in: path @@ -2781,6 +2969,15 @@ components: schema: type: string example: webhook_event_Qk8pRtSvWm2NjLhYcZaE + headers: + IdempotentReplayed: + description: | + Present and set to `true` when the response was replayed from a + previously stored result keyed by `Idempotency-Key`. Absent (or + `false`) for first-time requests. + schema: + type: boolean + example: true responses: Unauthorized: description: Unauthorized - Authentication credentials were missing or invalid @@ -2819,6 +3016,36 @@ components: - The email must be a valid email address. products.0.id: - The selected products.0.id is invalid. + Conflict: + description: | + Conflict — The request could not be completed because of one of + the following: + + - **Idempotency replay mismatch** (`Idempotency-Key` reused with + different request parameters). Retry with the original parameters + or generate a new key. + - **Optimistic concurrency conflict** (two writers modifying the + same resource at the same version). Reload the latest state + and retry — the conflict resolves itself unless contention is + sustained. + + The HTTP status (`409`) is the same for both; the `message` + distinguishes them. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + idempotency_replay_mismatch: + summary: Idempotency-Key reused with a different payload + value: + message: Idempotency-Key has already been used with different request parameters. + details: + idempotency_key: 9f6a3b1c-7e2d-4f7b-9c5a-2c1d5b7e9f3a + concurrency_conflict: + summary: Concurrent write on the same resource + value: + message: The resource was modified concurrently. Reload the latest state and retry. NotFound: description: Not Found - The requested resource does not exist content: @@ -2848,6 +3075,34 @@ components: errors: customerId: - Customer exists, but the wrong mode is used. Try switching live / test API keys. + IdempotencyConflict: + description: | + Conflict — A request was replayed with the same `Idempotency-Key` + but a different request body or different query parameters. + Idempotency keys are bound to the exact parameters of the first + request and cannot be reused with a different payload. Either + retry with the original parameters or generate a new key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + message: Idempotency-Key has already been used with different request parameters. + details: + idempotency_key: 9f6a3b1c-7e2d-4f7b-9c5a-2c1d5b7e9f3a + ConcurrencyConflict: + description: | + Conflict — Two writers tried to modify the same resource at the + same version. The earlier write won; this one was rejected to + preserve aggregate consistency. Reload the latest state of the + resource and retry the request — the conflict resolves itself + on the next attempt unless contention is sustained. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + message: The resource was modified concurrently. Reload the latest state and retry. TooManyRequests: description: Too Many Requests - Rate limit exceeded content: @@ -2877,20 +3132,41 @@ components: required: - value - currency + additionalProperties: false properties: value: type: string description: | - The amount as a decimal string. - Uses string type to preserve precision for financial calculations. + The amount as a decimal string. Sent and returned as a string to + preserve precision for financial calculations. + + The number of decimal places must match the currency's ISO 4217 + minor-unit count. The `(value, currency)` pair is validated + together — mixing precisions is rejected: + + - 0 decimals for JPY: `"100"` ✓, `"100.00"` ✗ + - 2 decimals for EUR, USD: `"10.00"` ✓, `"10"` ✗, `"10.0"` ✗ + - 3 decimals (reserved for future BHD/KWD/OMR/TND): `"1.000"` ✓ + + Validation is currency-aware (server uses + [moneyphp/money](https://github.com/moneyphp/money)'s + `DecimalMoneyParser`), so there's no ambiguity between + decimal-amount and minor-unit representations: `"10000"` is + accepted for JPY (10,000 yen) and rejected for EUR (EUR needs + `"10000.00"`). example: '99.99' - pattern: ^\d+(\.\d{1,2})?$ + pattern: ^\d+(\.\d{1,3})?$ currency: type: string - description: ISO 4217 currency code + description: | + ISO 4217 currency code. Vatly aligns its supported currency set + with Mollie's; currencies are enabled as PSP support and + downstream tax/FX coverage are in place. Expansion beyond this + list is tracked in issue #1404. + enum: + - EUR + - USD example: EUR - minLength: 3 - maxLength: 3 Link: type: object description: HATEOAS link to a related resource @@ -3143,6 +3419,7 @@ components: - status - createdAt - links + additionalProperties: false properties: id: type: string @@ -3211,6 +3488,7 @@ components: required: - checkoutUrl - self + additionalProperties: false properties: checkoutUrl: allOf: @@ -3230,6 +3508,7 @@ components: description: A product to add to the checkout required: - id + additionalProperties: false properties: id: type: string @@ -3270,6 +3549,7 @@ components: - redirectUrlSuccess - redirectUrlCanceled - products + additionalProperties: false properties: redirectUrlSuccess: type: string @@ -3316,6 +3596,7 @@ components: - createdAt - metadata - links + additionalProperties: false properties: id: type: string @@ -3351,6 +3632,7 @@ components: description: HATEOAS links to related resources required: - self + additionalProperties: false properties: self: $ref: '#/components/schemas/Link' @@ -3360,6 +3642,7 @@ components: description: Request body for creating a new customer required: - email + additionalProperties: false properties: email: type: string @@ -3389,6 +3672,7 @@ components: - status - createdAt - links + additionalProperties: false properties: id: type: string @@ -3437,6 +3721,7 @@ components: description: HATEOAS links to related resources required: - self + additionalProperties: false properties: self: $ref: '#/components/schemas/Link' @@ -3458,6 +3743,7 @@ components: - status - createdAt - links + additionalProperties: false properties: id: type: string @@ -3527,6 +3813,7 @@ components: description: HATEOAS links to related resources required: - self + additionalProperties: false properties: self: $ref: '#/components/schemas/Link' @@ -3549,6 +3836,7 @@ components: - lines - customerDetails - links + additionalProperties: false properties: id: type: string @@ -3631,6 +3919,7 @@ components: required: - self - customerInvoice + additionalProperties: false properties: self: allOf: @@ -3656,6 +3945,7 @@ components: - total - subtotal - taxes + additionalProperties: false properties: id: type: string @@ -3695,6 +3985,7 @@ components: required: - href - type + additionalProperties: false properties: href: type: string @@ -3726,6 +4017,7 @@ components: - taxSummary - lines - links + additionalProperties: false properties: id: type: string @@ -3801,6 +4093,7 @@ components: required: - self - originalOrder + additionalProperties: false properties: self: allOf: @@ -3827,6 +4120,7 @@ components: - total - subtotal - taxes + additionalProperties: false properties: id: type: string @@ -3865,6 +4159,7 @@ components: description: Request body for creating a partial refund required: - items + additionalProperties: false properties: items: type: array @@ -3883,6 +4178,7 @@ components: CreateFullRefundRequest: type: object description: Request body for creating a full refund + additionalProperties: false properties: metadata: $ref: '#/components/schemas/Metadata' @@ -3901,6 +4197,7 @@ components: - reason - originalOrderId - links + additionalProperties: false properties: id: type: string @@ -3957,6 +4254,7 @@ components: required: - self - originalOrder + additionalProperties: false properties: self: allOf: @@ -3983,7 +4281,10 @@ components: - entityType - entityId - object + - createdAt + - testmode - links + additionalProperties: false properties: id: type: string @@ -4002,6 +4303,7 @@ components: - `order.canceled` - Order was canceled - `order.chargeback_received` - Chargeback was received for an order - `order.chargeback_reversed` - Chargeback was reversed + - `payment.failed` - A payment failed and a dunning process was initiated for the order - `refund.completed` - Refund was processed successfully - `refund.failed` - Refund processing failed - `refund.canceled` - Refund was canceled @@ -4009,6 +4311,10 @@ components: - `subscription.canceled_immediately` - Subscription was canceled immediately - `subscription.canceled_with_grace_period` - Subscription was canceled with a grace period - `subscription.cancellation_grace_period_completed` - Subscription grace period ended + - `subscription.resumed` - A canceled subscription was resumed during its grace period + - `checkout.paid` - Checkout was paid successfully + - `checkout.failed` - Checkout payment failed + - `checkout.canceled` - Checkout was canceled - `checkout.expired` - Checkout session expired example: order.paid entityType: @@ -4024,11 +4330,23 @@ components: description: | The full resource payload at the time of the event. The shape depends on the `entityType` (e.g., Order, Refund, Chargeback, Subscription, Checkout). + createdAt: + type: string + format: date-time + description: ISO 8601 timestamp of when the webhook event was created. + example: '2026-05-21T14:32:08+00:00' + testmode: + type: boolean + description: | + Whether the event originated from a test-mode resource. + `true` for events created with a `test_` prefixed token, `false` for live events. + example: false links: type: object description: HATEOAS links to related resources required: - self + additionalProperties: false properties: self: allOf: @@ -4061,6 +4379,7 @@ components: - nextRenewalAt - trialUntil - links + additionalProperties: false properties: id: type: string @@ -4190,6 +4509,7 @@ components: required: - self - customer + additionalProperties: false properties: self: allOf: @@ -4204,6 +4524,7 @@ components: description: | Request body for updating a subscription. At least one of `subscriptionPlanId` or `quantity` must be provided. + additionalProperties: false properties: subscriptionPlanId: type: string @@ -4246,8 +4567,10 @@ components: - 'null' format: date description: | - Set the billing anchor to a specific date. - The new cycle is recalculated around this anchor. + Set the billing anchor to a specific calendar date (YYYY-MM-DD, + interpreted in UTC). The new billing cycle is recalculated around + this anchor. Date-only by design — the anchor identifies a day, + not an instant, so daylight-saving transitions don't shift it. Cannot be combined with `resetAnchor`. example: '2024-02-01' resetAnchor: @@ -4276,6 +4599,7 @@ components: required: - redirectUrlSuccess - redirectUrlCanceled + additionalProperties: false properties: redirectUrlSuccess: type: string @@ -4332,6 +4656,7 @@ components: required: - itemId - amount + additionalProperties: false properties: itemId: type: string diff --git a/src/API/Types/WebhookEvent.php b/src/API/Types/WebhookEvent.php index 1012be5..e39c4dd 100644 --- a/src/API/Types/WebhookEvent.php +++ b/src/API/Types/WebhookEvent.php @@ -8,6 +8,7 @@ class WebhookEvent public const ORDER_CANCELED = 'order.canceled'; public const ORDER_CHARGEBACK_RECEIVED = 'order.chargeback_received'; public const ORDER_CHARGEBACK_REVERSED = 'order.chargeback_reversed'; + public const PAYMENT_FAILED = 'payment.failed'; public const REFUND_COMPLETED = 'refund.completed'; public const REFUND_FAILED = 'refund.failed'; public const REFUND_CANCELED = 'refund.canceled'; @@ -15,5 +16,9 @@ class WebhookEvent public const SUBSCRIPTION_CANCELED_IMMEDIATELY = 'subscription.canceled_immediately'; public const SUBSCRIPTION_CANCELED_WITH_GRACE_PERIOD = 'subscription.canceled_with_grace_period'; public const SUBSCRIPTION_CANCELLATION_GRACE_PERIOD_COMPLETED = 'subscription.cancellation_grace_period_completed'; + public const SUBSCRIPTION_RESUMED = 'subscription.resumed'; + public const CHECKOUT_PAID = 'checkout.paid'; + public const CHECKOUT_FAILED = 'checkout.failed'; + public const CHECKOUT_CANCELED = 'checkout.canceled'; public const CHECKOUT_EXPIRED = 'checkout.expired'; } From 33b5553328f8e945ac2c85c0fa0e8cd9448e9a0d Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 29 May 2026 00:17:30 +0200 Subject: [PATCH 2/5] refactor: rename Types\WebhookEvent to WebhookEventName Avoids the name collision with Resources\WebhookEvent and matches the `eventName` field it documents plus the sibling *Status enum naming. Co-Authored-By: Claude Opus 4.7 --- docs/Webhooks.md | 7 ++++++- src/API/Resources/WebhookEvent.php | 2 +- src/API/Types/{WebhookEvent.php => WebhookEventName.php} | 2 +- src/API/Webhooks/WebhookPayload.php | 2 +- tests/Endpoints/WebhookEventEndpointTest.php | 6 +++--- 5 files changed, 12 insertions(+), 7 deletions(-) rename src/API/Types/{WebhookEvent.php => WebhookEventName.php} (98%) diff --git a/docs/Webhooks.md b/docs/Webhooks.md index 2fff630..3583d37 100644 --- a/docs/Webhooks.md +++ b/docs/Webhooks.md @@ -4,7 +4,7 @@ Vatly sends webhooks to notify your application when events happen — for examp ## Webhook events -The `eventName` field on a delivery identifies what happened. See [`Vatly\API\Types\WebhookEvent`](../src/API/Types/WebhookEvent.php) for the constants. +The `eventName` field on a delivery identifies what happened. See [`Vatly\API\Types\WebhookEventName`](../src/API/Types/WebhookEventName.php) for the constants. | Event | Description | |-------|-------------| @@ -12,6 +12,7 @@ The `eventName` field on a delivery identifies what happened. See [`Vatly\API\Ty | `order.canceled` | Order was canceled. | | `order.chargeback_received` | Chargeback was received for an order. | | `order.chargeback_reversed` | Chargeback was reversed. | +| `payment.failed` | A payment failed and a dunning process was initiated for the order. | | `refund.completed` | Refund was processed successfully. | | `refund.failed` | Refund processing failed. | | `refund.canceled` | Refund was canceled. | @@ -19,6 +20,10 @@ The `eventName` field on a delivery identifies what happened. See [`Vatly\API\Ty | `subscription.canceled_immediately` | Subscription was canceled immediately. | | `subscription.canceled_with_grace_period` | Subscription was canceled, customer keeps access until the period ends. | | `subscription.cancellation_grace_period_completed` | Grace period after cancellation ended. | +| `subscription.resumed` | A canceled subscription was resumed during its grace period. | +| `checkout.paid` | Checkout was paid successfully. | +| `checkout.failed` | Checkout payment failed. | +| `checkout.canceled` | Checkout was canceled. | | `checkout.expired` | Checkout session expired. | --- diff --git a/src/API/Resources/WebhookEvent.php b/src/API/Resources/WebhookEvent.php index 83380d5..af7156e 100644 --- a/src/API/Resources/WebhookEvent.php +++ b/src/API/Resources/WebhookEvent.php @@ -21,7 +21,7 @@ class WebhookEvent extends BaseResource /** * Name of the event that triggered this webhook. * - * @see \Vatly\API\Types\WebhookEvent + * @see \Vatly\API\Types\WebhookEventName * @example order.paid */ public string $eventName; diff --git a/src/API/Types/WebhookEvent.php b/src/API/Types/WebhookEventName.php similarity index 98% rename from src/API/Types/WebhookEvent.php rename to src/API/Types/WebhookEventName.php index e39c4dd..d325302 100644 --- a/src/API/Types/WebhookEvent.php +++ b/src/API/Types/WebhookEventName.php @@ -2,7 +2,7 @@ namespace Vatly\API\Types; -class WebhookEvent +class WebhookEventName { public const ORDER_PAID = 'order.paid'; public const ORDER_CANCELED = 'order.canceled'; diff --git a/src/API/Webhooks/WebhookPayload.php b/src/API/Webhooks/WebhookPayload.php index 108a6d0..f3a830f 100644 --- a/src/API/Webhooks/WebhookPayload.php +++ b/src/API/Webhooks/WebhookPayload.php @@ -28,7 +28,7 @@ class WebhookPayload /** * Name of the event that triggered this webhook. * - * @see \Vatly\API\Types\WebhookEvent + * @see \Vatly\API\Types\WebhookEventName * @example order.paid */ public string $eventName; diff --git a/tests/Endpoints/WebhookEventEndpointTest.php b/tests/Endpoints/WebhookEventEndpointTest.php index 7644cc8..385cab3 100644 --- a/tests/Endpoints/WebhookEventEndpointTest.php +++ b/tests/Endpoints/WebhookEventEndpointTest.php @@ -5,7 +5,7 @@ namespace Vatly\Tests\Endpoints; use Vatly\API\Resources\WebhookEvent; -use Vatly\API\Types\WebhookEvent as WebhookEventType; +use Vatly\API\Types\WebhookEventName; use Vatly\API\VatlyApiClient; class WebhookEventEndpointTest extends BaseEndpointTest @@ -19,7 +19,7 @@ public function can_get_webhook_event(): void $responseBodyArray = [ 'id' => $webhookEventId, 'resource' => 'webhook_event', - 'eventName' => WebhookEventType::ORDER_PAID, + 'eventName' => WebhookEventName::ORDER_PAID, 'entityType' => 'order', 'entityId' => $orderId, 'object' => [ @@ -53,7 +53,7 @@ public function can_get_webhook_event(): void $this->assertInstanceOf(WebhookEvent::class, $event); $this->assertEquals($webhookEventId, $event->id); $this->assertEquals('webhook_event', $event->resource); - $this->assertEquals(WebhookEventType::ORDER_PAID, $event->eventName); + $this->assertEquals(WebhookEventName::ORDER_PAID, $event->eventName); $this->assertEquals('order', $event->entityType); $this->assertEquals($orderId, $event->entityId); $this->assertIsObject($event->object); From 6e2d4ad14f996085234d444b55199157794fa68c Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 29 May 2026 09:40:42 +0200 Subject: [PATCH 3/5] feat: adopt mandate info on subscription responses The latest vatlify OpenAPI spec exposes an inline `mandate` summary (payment method on file) on subscription responses. Adopt the spec verbatim and add SDK support: a minimal Mandate DTO, ResourceFactory hydration (with null handling), the Subscription::$mandate property, docs, and test coverage including the null case. Co-Authored-By: Claude Opus 4.7 --- docs/Subscriptions.md | 1 + openapi.yaml | 81 ++++++++++++++++++++ src/API/Resources/ResourceFactory.php | 8 ++ src/API/Resources/Subscription.php | 7 ++ src/API/Types/Mandate.php | 39 ++++++++++ tests/Endpoints/SubscriptionEndpointTest.php | 23 ++++++ 6 files changed, 159 insertions(+) create mode 100644 src/API/Types/Mandate.php diff --git a/docs/Subscriptions.md b/docs/Subscriptions.md index 19fb7d8..4666398 100644 --- a/docs/Subscriptions.md +++ b/docs/Subscriptions.md @@ -22,6 +22,7 @@ Below you'll find all properties for the Vatly Subscription resource. | `trialStart` | `string \| null` | Trial period start (ISO 8601). | | `trialEnd` | `string \| null` | Trial period end (ISO 8601). | | `metadata` | `array` | Your custom metadata. | +| `mandate` | `object \| null` | Payment method on file (`method`, `maskedIdentifier`). Null when the subscription has no mandate yet. | | `createdAt` | `string` | Creation timestamp (ISO 8601). | --- diff --git a/openapi.yaml b/openapi.yaml index d5e59df..6d34d9b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -611,6 +611,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -682,6 +685,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -2194,6 +2200,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -2230,6 +2239,9 @@ paths: interval: year intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-01T00:00:00Z' endedAt: null cancelledAt: null @@ -2295,6 +2307,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -2411,6 +2426,9 @@ paths: interval: year intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -2624,6 +2642,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -2707,6 +2728,9 @@ paths: interval: month intervalCount: 1 status: active + mandate: + method: card + maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null cancelledAt: null @@ -4371,6 +4395,7 @@ components: - interval - intervalCount - status + - mandate - startedAt - endedAt - cancelledAt @@ -4453,6 +4478,15 @@ components: - paused - canceled example: active + mandate: + oneOf: + - $ref: '#/components/schemas/Mandate' + - type: 'null' + description: | + Payment method on file for this subscription, as an inline summary + intended for billing-portal rendering ("Visa ending in 4242"). May + be `null` when the subscription has no mandate yet (e.g. ended / + canceled-before-payment states). startedAt: type: - string @@ -4618,6 +4652,53 @@ components: description: | Pre-fill billing address fields. Customer can modify these values in the hosted form. + Mandate: + type: object + description: | + A mandate represents an authorization to charge a customer's payment method + on a recurring basis (cards, SEPA Direct Debit, PayPal, Bacs). + + This shape is the inline summary exposed on subscriptions — enough to + render "Visa ending in 4242" in a billing portal without an extra round + trip. A dedicated `/v1/mandates/{id}` endpoint with richer detail (status, + validity timestamps, billing snapshot) is on the roadmap; fields here are + forward-compatible additions to that future schema. + required: + - method + - maskedIdentifier + additionalProperties: false + properties: + method: + type: string + description: | + Normalized payment method category, provider-agnostic: + - `card` — credit/debit card (incl. Apple Pay, Google Pay wallets) + - `sepa_debit` — SEPA Direct Debit (incl. iDEAL, Bancontact, KBC and + other rail aliases that settle into a directdebit mandate) + - `paypal` — PayPal billing agreement + - `bacs_debit` — UK Bacs Direct Debit + enum: + - card + - sepa_debit + - paypal + - bacs_debit + example: card + maskedIdentifier: + type: + - string + - 'null' + description: | + Customer-facing masked identifier for the payment method on file: + the last 4 of a card (`"4242"`), a masked IBAN (`"NL91****4300"`), + a masked PayPal email (`"j***@example.com"`), or last 4 of a Bacs + account (`"****5678"`). + + May briefly be `null` for freshly-subscribed customers when the + underlying authorization event doesn't carry the masked identifier + inline — in that case it lands on a follow-up sync (runs at most + every 5 minutes) and the field updates from `null` to the resolved + value once the sync completes. + example: '4242' TaxSummaryRate: type: object description: Simplified tax rate information diff --git a/src/API/Resources/ResourceFactory.php b/src/API/Resources/ResourceFactory.php index 1703e8b..7d02348 100644 --- a/src/API/Resources/ResourceFactory.php +++ b/src/API/Resources/ResourceFactory.php @@ -9,6 +9,7 @@ use Vatly\API\Resources\Links\BaseLinksResource; use Vatly\API\Resources\Links\LinksResourceFactory; use Vatly\API\Types\Address; +use Vatly\API\Types\Mandate; use Vatly\API\Types\Money; use Vatly\API\Types\TaxSummaryCollection; use Vatly\API\VatlyApiClient; @@ -69,6 +70,13 @@ public static function createResourceFromApiResult(object $apiResult, BaseResour break; + case 'mandate': + $resource->{$property} = null === $value + ? null + : Mandate::createResourceFromApiResult($value); + + break; + default: $resource->{$property} = $value; diff --git a/src/API/Resources/Subscription.php b/src/API/Resources/Subscription.php index b58ab49..d4a529f 100644 --- a/src/API/Resources/Subscription.php +++ b/src/API/Resources/Subscription.php @@ -6,6 +6,7 @@ use Vatly\API\Resources\Links\SubscriptionLinks; use Vatly\API\Types\Address; use Vatly\API\Types\Link; +use Vatly\API\Types\Mandate; use Vatly\API\Types\Money; use Vatly\API\Types\SubscriptionStatus; @@ -68,6 +69,12 @@ class Subscription extends BaseResource public ?string $trialUntil = null; + /** + * Payment method on file for this subscription, as an inline summary. + * Null when the subscription has no mandate yet (e.g. ended / canceled-before-payment). + */ + public ?Mandate $mandate = null; + public SubscriptionLinks $links; /** diff --git a/src/API/Types/Mandate.php b/src/API/Types/Mandate.php new file mode 100644 index 0000000..8b1d0f1 --- /dev/null +++ b/src/API/Types/Mandate.php @@ -0,0 +1,39 @@ +method = $method; + $this->maskedIdentifier = $maskedIdentifier; + } + + public static function createResourceFromApiResult($value): Mandate + { + if (is_array($value)) { + $value = (object) $value; + } + + return new self($value->method, $value->maskedIdentifier ?? null); + } +} diff --git a/tests/Endpoints/SubscriptionEndpointTest.php b/tests/Endpoints/SubscriptionEndpointTest.php index cea55e0..a2dd337 100644 --- a/tests/Endpoints/SubscriptionEndpointTest.php +++ b/tests/Endpoints/SubscriptionEndpointTest.php @@ -5,6 +5,7 @@ use Vatly\API\Resources\ResourceFactory; use Vatly\API\Resources\Subscription; use Vatly\API\Resources\SubscriptionCollection; +use Vatly\API\Types\Mandate; use Vatly\API\Types\SubscriptionStatus; use Vatly\API\VatlyApiClient; @@ -43,6 +44,9 @@ public function can_get_subscription() $this->assertEquals('2023-02-11T10:50:50+02:00', $subscription->nextRenewalAt); $this->assertEquals('US', $subscription->billingAddress->country); $this->assertEquals('New York', $subscription->billingAddress->city); + $this->assertInstanceOf(Mandate::class, $subscription->mandate); + $this->assertEquals('card', $subscription->mandate->method); + $this->assertEquals('4242', $subscription->mandate->maskedIdentifier); $this->assertTrue($subscription->isActive()); $this->assertFalse($subscription->isCanceled()); $this->assertFalse($subscription->isOnGracePeriod()); @@ -59,6 +63,21 @@ public function can_get_subscription() ); } + /** @test */ + public function mandate_can_be_null() + { + $subscriptionId = 'subscription_no_mandate'; + $responseBodyArray = $this->subscriptionDemoData($subscriptionId); + $responseBodyArray['mandate'] = null; + + $this->httpClient->setSendReturnObjectFromArray($responseBodyArray); + + /** @var Subscription $subscription */ + $subscription = $this->client->subscriptions->get($subscriptionId); + + $this->assertNull($subscription->mandate); + } + /** @test */ public function can_list_subscriptions() { @@ -345,6 +364,10 @@ private function subscriptionDemoData(string $subscriptionId, string $status = S 'value' => '10.00', 'currency' => 'EUR', ], + 'mandate' => [ + 'method' => 'card', + 'maskedIdentifier' => '4242', + ], 'links' => [ 'self' => [ 'href' => self::API_ENDPOINT_URL . '/subscriptions/' . $subscriptionId, From 791e2d3e5cc0e66e0f350fc7e560cbbdd8bc119d Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 29 May 2026 10:50:28 +0200 Subject: [PATCH 4/5] feat: adopt latest vatlify spec (US-English canceledAt + currency-aware Money) vatlify shipped the two fixes from sandervanhooft/vatlify#1454: - Standardize on US English: `cancelledAt` -> `canceledAt` on the Subscription schema (and resume-endpoint prose keyed off `status` rather than `endedAt`/`endsAt`). - Money is now validated currency-aware via a `oneOf` over ISO 4217 minor units (2 / 0 / 3 decimals) instead of one permissive pattern. Adopt the spec verbatim and rename `Subscription::$cancelledAt` -> `$canceledAt` so hydration keeps matching the JSON key. `Money` stays a passthrough DTO (server enforces precision). Co-Authored-By: Claude Opus 4.7 --- openapi.yaml | 122 ++++++++++++++----- src/API/Resources/Subscription.php | 2 +- tests/Endpoints/SubscriptionEndpointTest.php | 4 +- 3 files changed, 97 insertions(+), 31 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 6d34d9b..0d69d21 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -616,7 +616,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2024-03-15T10:30:00Z' nextRenewalAt: '2024-03-15T10:30:00Z' @@ -690,7 +690,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2024-03-15T10:30:00Z' nextRenewalAt: '2024-03-15T10:30:00Z' @@ -2205,7 +2205,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2024-03-15T10:30:00Z' nextRenewalAt: '2024-03-15T10:30:00Z' @@ -2244,7 +2244,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-01T00:00:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-01-01T00:00:00Z' renewedUntil: '2025-01-01T00:00:00Z' nextRenewalAt: '2025-01-01T00:00:00Z' @@ -2312,7 +2312,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2024-03-15T10:30:00Z' nextRenewalAt: '2024-03-15T10:30:00Z' @@ -2431,7 +2431,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2025-02-15T10:30:00Z' nextRenewalAt: '2025-02-15T10:30:00Z' @@ -2473,7 +2473,7 @@ paths: **Notes:** - A subscription that was canceled with a grace period (the default) can be resumed via - `POST /subscriptions/{subscriptionId}/resume` while `ends_at` is still in the future + `POST /subscriptions/{subscriptionId}/resume` while its `status` is `on_grace_period` - A subscription that was canceled immediately cannot be reactivated; create a new subscription instead - Customers retain access until `renewedUntil` date (unless canceled immediately) - No refunds are issued automatically; use the Refunds API if needed @@ -2591,19 +2591,21 @@ paths: **When this works:** - The subscription's `status` is `on_grace_period` - - The subscription's `endsAt` is still in the future + + (`endedAt` stays `null` throughout the grace period — it is only set once the + subscription has actually ended, so resumability is keyed off `status`, not `endedAt`.) **Result:** - Status returns to `active` - The existing billing cycle and renewal schedule are preserved (no new charge fires immediately) - The original payment mandate remains in effect - A `subscription.resumed` webhook is delivered to your configured endpoint - - `canceledAt` and `endsAt` are cleared; `resumedAt` is set on the subscription + - `canceledAt` is cleared (`endedAt` was already `null` during the grace period) - The refreshed subscription is returned in the response body **When this does NOT work:** - Subscription was canceled immediately (`status` is `canceled`) — create a new subscription instead - - Grace period has already lapsed (`endsAt` is in the past) — create a new subscription instead + - Grace period has already lapsed (`status` is now `canceled` and `endedAt` is set) — create a new subscription instead - Subscription is already active (no-op rejected for clarity, not silently ignored) tags: - Subscriptions @@ -2647,7 +2649,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-02-15T10:30:00Z' renewedUntil: '2024-03-15T10:30:00Z' nextRenewalAt: '2024-03-15T10:30:00Z' @@ -2671,7 +2673,7 @@ paths: description: | Subscription is not within an active grace period. Common causes: - Subscription was canceled immediately (status `canceled`) - - Grace period has already lapsed (`endsAt` in the past) + - Grace period has already lapsed (`status` is now `canceled`) - Subscription is already active (was not canceled, or already resumed) content: application/json: @@ -2733,7 +2735,7 @@ paths: maskedIdentifier: '4242' startedAt: '2024-01-15T10:30:00Z' endedAt: null - cancelledAt: null + canceledAt: null renewedAt: '2024-03-15T10:30:00Z' renewedUntil: '2024-04-15T10:30:00Z' nextRenewalAt: '2024-04-15T10:30:00Z' @@ -3164,22 +3166,20 @@ components: The amount as a decimal string. Sent and returned as a string to preserve precision for financial calculations. - The number of decimal places must match the currency's ISO 4217 - minor-unit count. The `(value, currency)` pair is validated - together — mixing precisions is rejected: + The number of required decimal places is **currency-dependent**: it + equals the currency's ISO 4217 minor-unit count — the same rule the + server enforces via [moneyphp/money](https://github.com/moneyphp/money)'s + `DecimalMoneyParser`. The `(value, currency)` pair is validated + together (see the `oneOf` below); mixing precisions is rejected: - - 0 decimals for JPY: `"100"` ✓, `"100.00"` ✗ - - 2 decimals for EUR, USD: `"10.00"` ✓, `"10"` ✗, `"10.0"` ✗ - - 3 decimals (reserved for future BHD/KWD/OMR/TND): `"1.000"` ✓ + - 2 decimals — EUR, USD (and most currencies): `"10.00"` ✓, `"10"` ✗, `"10.0"` ✗, `"10.000"` ✗ + - 0 decimals — e.g. JPY: `"1000"` ✓, `"1000.00"` ✗ *(not yet enabled)* + - 3 decimals — e.g. BHD, KWD, OMR, TND: `"1.000"` ✓, `"1.00"` ✗ *(not yet enabled)* - Validation is currency-aware (server uses - [moneyphp/money](https://github.com/moneyphp/money)'s - `DecimalMoneyParser`), so there's no ambiguity between - decimal-amount and minor-unit representations: `"10000"` is - accepted for JPY (10,000 yen) and rejected for EUR (EUR needs - `"10000.00"`). + Because the value is a decimal amount (not minor units), `"1000"` + means one thousand units — valid only for a 0-decimal currency; for + EUR/USD you must write `"1000.00"`. example: '99.99' - pattern: ^\d+(\.\d{1,3})?$ currency: type: string description: | @@ -3191,6 +3191,72 @@ components: - EUR - USD example: EUR + oneOf: + - title: Two-decimal currencies (default) + properties: + currency: + not: + enum: + - BIF + - CLP + - DJF + - GNF + - ISK + - JPY + - KMF + - KRW + - PYG + - RWF + - UGX + - VND + - VUV + - XAF + - XOF + - XPF + - BHD + - IQD + - JOD + - KWD + - LYD + - OMR + - TND + value: + pattern: ^\d+\.\d{2}$ + - title: Zero-decimal currencies + properties: + currency: + enum: + - BIF + - CLP + - DJF + - GNF + - ISK + - JPY + - KMF + - KRW + - PYG + - RWF + - UGX + - VND + - VUV + - XAF + - XOF + - XPF + value: + pattern: ^\d+$ + - title: Three-decimal currencies + properties: + currency: + enum: + - BHD + - IQD + - JOD + - KWD + - LYD + - OMR + - TND + value: + pattern: ^\d+\.\d{3}$ Link: type: object description: HATEOAS link to a related resource @@ -4398,7 +4464,7 @@ components: - mandate - startedAt - endedAt - - cancelledAt + - canceledAt - renewedAt - renewedUntil - nextRenewalAt @@ -4500,7 +4566,7 @@ components: - 'null' format: date-time description: When the subscription ended (ISO 8601 format) - cancelledAt: + canceledAt: type: - string - 'null' diff --git a/src/API/Resources/Subscription.php b/src/API/Resources/Subscription.php index d4a529f..cf89176 100644 --- a/src/API/Resources/Subscription.php +++ b/src/API/Resources/Subscription.php @@ -59,7 +59,7 @@ class Subscription extends BaseResource public ?string $endedAt; - public ?string $cancelledAt; + public ?string $canceledAt; public ?string $renewedAt; diff --git a/tests/Endpoints/SubscriptionEndpointTest.php b/tests/Endpoints/SubscriptionEndpointTest.php index a2dd337..3fd0ed7 100644 --- a/tests/Endpoints/SubscriptionEndpointTest.php +++ b/tests/Endpoints/SubscriptionEndpointTest.php @@ -38,7 +38,7 @@ public function can_get_subscription() $this->assertTrue($subscription->testmode); $this->assertEquals('2023-01-11T10:50:50+02:00', $subscription->startedAt); $this->assertNull($subscription->endedAt); - $this->assertNull($subscription->cancelledAt); + $this->assertNull($subscription->canceledAt); $this->assertNull($subscription->renewedAt); $this->assertNull($subscription->renewedUntil); $this->assertEquals('2023-02-11T10:50:50+02:00', $subscription->nextRenewalAt); @@ -339,7 +339,7 @@ private function subscriptionDemoData(string $subscriptionId, string $status = S 'description' => 'Test subscription description', 'startedAt' => '2023-01-11T10:50:50+02:00', 'endedAt' => null, - 'cancelledAt' => null, + 'canceledAt' => null, 'renewedAt' => null, 'renewedUntil' => null, 'nextRenewalAt' => '2023-02-11T10:50:50+02:00', From 9af7554b3895265509775d6f69b1fc9e23285a30 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 29 May 2026 11:16:18 +0200 Subject: [PATCH 5/5] docs: reconcile Subscriptions.md with the actual Subscription resource The properties table, helper-method examples, and status list had drifted from src/API/Resources/Subscription.php and src/API/Types/SubscriptionStatus.php. - Replace invented fields (planId, currentPeriodStart/End, trialStart/End, metadata, createdAt) with the real resource properties, and add the ones that were missing (resource, name, description, billingAddress, basePrice, quantity, interval, intervalCount, renewedAt/Until, nextRenewalAt, links). - Fix helper-method example: isActive/isTrial/isOnGracePeriod/isCanceled (drop the non-existent isCreated/onTrial/onGracePeriod/isPaused). - Add the missing `canceled` status; keep the verified `created`/`paused` statuses (both defined in SubscriptionStatus.php and the spec enum) and align descriptions with SubscriptionStatus. - Update code examples to real property names (subscriptionPlanId, renewedUntil). Co-Authored-By: Claude Opus 4.7 --- docs/Subscriptions.md | 64 ++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/docs/Subscriptions.md b/docs/Subscriptions.md index 4666398..ffbcb36 100644 --- a/docs/Subscriptions.md +++ b/docs/Subscriptions.md @@ -8,22 +8,30 @@ Below you'll find all properties for the Vatly Subscription resource. ### Properties -| Name | Type | Description | -| --- |------------------| --- | -| `id` | `string` | Unique identifier for the subscription (`subscription_...`). | -| `status` | `string` | The status: `active`, `created`, `trial`, `on_grace_period`, or `paused`. | -| `customerId` | `string` | The customer ID. | -| `planId` | `string` | The subscription plan ID. | -| `testmode` | `bool` | Whether this is a test subscription. | -| `currentPeriodStart` | `string` | Current billing period start (ISO 8601). | -| `currentPeriodEnd` | `string` | Current billing period end (ISO 8601). | -| `canceledAt` | `string \| null` | When the subscription was canceled (ISO 8601). | -| `endedAt` | `string \| null` | When the subscription ended (ISO 8601). | -| `trialStart` | `string \| null` | Trial period start (ISO 8601). | -| `trialEnd` | `string \| null` | Trial period end (ISO 8601). | -| `metadata` | `array` | Your custom metadata. | -| `mandate` | `object \| null` | Payment method on file (`method`, `maskedIdentifier`). Null when the subscription has no mandate yet. | -| `createdAt` | `string` | Creation timestamp (ISO 8601). | +| Name | Type | Description | +| --- |---------------------| --- | +| `id` | `string` | Unique identifier for the subscription (`subscription_...`). | +| `resource` | `string` | Resource type, always `subscription`. | +| `customerId` | `string` | ID of the customer who owns this subscription. | +| `subscriptionPlanId` | `string` | ID of the subscription plan this subscription is based on. | +| `testmode` | `bool` | Whether this subscription is in test mode. | +| `name` | `string` | Name of the subscription (from the plan). | +| `description` | `string` | Description of the subscription. | +| `billingAddress` | `Address` | Customer's billing address for this subscription. | +| `basePrice` | `Money` | Price per billing cycle before taxes. | +| `quantity` | `int` | Number of subscription units (e.g. seats). | +| `interval` | `string` | Billing interval unit: `day`, `week`, `month`, or `year`. | +| `intervalCount` | `int` | Number of interval units between billing cycles. | +| `status` | `string` | The subscription status (see [Subscription statuses](#subscription-statuses)). | +| `startedAt` | `string` | When the subscription started (ISO 8601). | +| `endedAt` | `string \| null` | When the subscription ended (ISO 8601). | +| `canceledAt` | `string \| null` | When the subscription was canceled (ISO 8601). | +| `renewedAt` | `string \| null` | When the subscription was last renewed (ISO 8601). | +| `renewedUntil` | `string \| null` | Current billing period end date (ISO 8601). | +| `nextRenewalAt` | `string \| null` | When the next renewal will be attempted (ISO 8601). Null if canceled or ended. | +| `trialUntil` | `string \| null` | When the trial period ends (ISO 8601). Null if not in trial or trial has ended. | +| `mandate` | `Mandate \| null` | Payment method on file (`method`, `maskedIdentifier`). Null when the subscription has no mandate yet. | +| `links` | `SubscriptionLinks` | HATEOAS links to related resources (`self`, `customer`). | --- @@ -42,7 +50,7 @@ Retrieve a subscription by its ID. $subscription = $vatly->subscriptions->get('subscription_abc123'); echo $subscription->status; -echo $subscription->planId; +echo $subscription->subscriptionPlanId; if ($subscription->isActive()) { echo 'Subscription is active'; @@ -137,7 +145,7 @@ $subscription = $vatly->subscriptions->cancel('subscription_abc123'); // Subscription is now on grace period until current period ends echo $subscription->status; // 'on_grace_period' -echo $subscription->currentPeriodEnd; // When it ends +echo $subscription->renewedUntil; // Current billing period end ``` @@ -175,11 +183,12 @@ $subscription->resume(); | Status | Description | |--------|-------------| -| `active` | Subscription is active and billing | -| `created` | Subscription created, not yet active | -| `trial` | In trial period | -| `on_grace_period` | Canceled but still active until current period ends | -| `paused` | Subscription is paused | +| `active` | Subscription is active and will renew | +| `created` | Subscription has been created but not yet started | +| `trial` | Subscription is in trial period | +| `on_grace_period` | Subscription is canceled but still active until the period ends | +| `paused` | Subscription is temporarily paused | +| `canceled` | Subscription has been canceled | --- @@ -193,9 +202,8 @@ The Subscription object provides convenient helper methods. ```php -$subscription->isActive(); // true if status is 'active' -$subscription->isCreated(); // true if status is 'created' -$subscription->onTrial(); // true if status is 'trial' -$subscription->onGracePeriod(); // true if status is 'on_grace_period' -$subscription->isPaused(); // true if status is 'paused' +$subscription->isActive(); // true if status is 'active' +$subscription->isTrial(); // true if status is 'trial' +$subscription->isOnGracePeriod(); // true if status is 'on_grace_period' +$subscription->isCanceled(); // true if status is 'canceled' ```