From b86d35672615b4cc37dcb1c4d55c7ae04f5731b2 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Wed, 27 May 2026 11:53:35 +0200 Subject: [PATCH] feat: add subscription.resume endpoint Adds POST /v1/subscriptions/{id}/resume to reverse a pending cancellation while the subscription is still on grace period. - SubscriptionEndpoint::resume() with idempotency-key support - Subscription::resume() shortcut on the resource - OpenAPI spec entry + cancel notes updated to point at it - Tests for both happy path and idempotency-key forwarding Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/Subscriptions.md | 27 ++++++++++++++ openapi.yaml | 38 +++++++++++++++++++- src/API/Endpoints/SubscriptionEndpoint.php | 29 +++++++++++++++ src/API/Resources/Subscription.php | 8 +++++ tests/Endpoints/SubscriptionEndpointTest.php | 31 ++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/docs/Subscriptions.md b/docs/Subscriptions.md index 95ad540..19fb7d8 100644 --- a/docs/Subscriptions.md +++ b/docs/Subscriptions.md @@ -141,6 +141,33 @@ echo $subscription->currentPeriodEnd; // When it ends +--- + +## Resume a subscription + +`POST /v1/subscriptions/:id/resume` + + + +Reverse a pending cancellation while the subscription is still on its grace period. Once a subscription has fully ended it cannot be resumed. + + + + +```php +$subscription = $vatly->subscriptions->resume('subscription_abc123'); + +echo $subscription->status; // 'active' +``` + +If you already have a `Subscription` resource instance: + +```php +$subscription->resume(); +``` + + + --- ## Subscription statuses diff --git a/openapi.yaml b/openapi.yaml index 7489fbb..c1e5d11 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2373,7 +2373,8 @@ paths: - **Immediate:** Subscription ends immediately when `immediately=true` is specified **Notes:** - - Canceled subscriptions cannot be reactivated through the API + - Subscriptions on grace period can be reactivated via `POST /subscriptions/{subscriptionId}/resume` + - Subscriptions that have fully ended cannot be reactivated - Customers retain access until `renewedUntil` date (unless canceled immediately) - No refunds are issued automatically; use the Refunds API if needed tags: @@ -2405,6 +2406,41 @@ paths: 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 diff --git a/src/API/Endpoints/SubscriptionEndpoint.php b/src/API/Endpoints/SubscriptionEndpoint.php index 3248465..426ed23 100644 --- a/src/API/Endpoints/SubscriptionEndpoint.php +++ b/src/API/Endpoints/SubscriptionEndpoint.php @@ -8,6 +8,7 @@ use Vatly\API\Resources\BaseResourcePage; use Vatly\API\Resources\Customer; use Vatly\API\Resources\Links\PaginationLinks; +use Vatly\API\Resources\ResourceFactory; use Vatly\API\Resources\Subscription; use Vatly\API\Resources\SubscriptionCollection; use Vatly\API\Types\Link; @@ -110,6 +111,34 @@ public function cancel(string $subscriptionId, array $data = []): ?BaseResource return $this->rest_delete($subscriptionId, $data); } + /** + * Resume a subscription that is currently on grace period. + * + * Reverses a pending cancellation while the subscription is still active + * (i.e. before `renewedUntil`). Once a subscription has fully ended it + * cannot be resumed. + * + * @throws ApiException + */ + public function resume(string $subscriptionId, array $data = []): ?BaseResource + { + $this->validateSubscriptionId($subscriptionId); + + $resource = "{$this->getResourcePath()}/" . urlencode($subscriptionId) . "/resume"; + + $result = $this->client->performHttpCall( + self::REST_CREATE, + $resource, + $this->parseRequestBody($data), + ); + + if ($result === null) { + return null; + } + + return ResourceFactory::createResourceFromApiResult($result, $this->getResourceObject()); + } + /** * @param string $subscriptionId * @return void diff --git a/src/API/Resources/Subscription.php b/src/API/Resources/Subscription.php index c4d06a3..b58ab49 100644 --- a/src/API/Resources/Subscription.php +++ b/src/API/Resources/Subscription.php @@ -97,6 +97,14 @@ public function cancel(array $data = []): ?BaseResource return $this->apiClient->subscriptions->cancel($this->id, $data); } + /** + * @throws ApiException + */ + public function resume(array $data = []): ?BaseResource + { + return $this->apiClient->subscriptions->resume($this->id, $data); + } + public function isCanceled(): bool { return $this->status === SubscriptionStatus::CANCELED; diff --git a/tests/Endpoints/SubscriptionEndpointTest.php b/tests/Endpoints/SubscriptionEndpointTest.php index 4c6aa2c..9f4f01e 100644 --- a/tests/Endpoints/SubscriptionEndpointTest.php +++ b/tests/Endpoints/SubscriptionEndpointTest.php @@ -189,6 +189,37 @@ public function can_cancel_subscription() ); } + /** @test */ + public function can_resume_subscription() + { + /** @var Subscription $subscription */ + $subscription = ResourceFactory::createResourceFromApiResult((object) $this->subscriptionDemoData('subscription_123', SubscriptionStatus::ON_GRACE_PERIOD), new Subscription($this->client)); + + $this->httpClient->setSendReturnObjectFromArray($this->subscriptionDemoData('subscription_123', SubscriptionStatus::ACTIVE)); + $resumed = $subscription->resume(); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL.'/subscriptions/subscription_123/resume', + [], + null + ); + + $this->assertInstanceOf(Subscription::class, $resumed); + $this->assertEquals(SubscriptionStatus::ACTIVE, $resumed->status); + } + + /** @test */ + public function can_resume_subscription_with_idempotency_key() + { + $this->httpClient->setSendReturnObjectFromArray($this->subscriptionDemoData('subscription_123')); + $this->client->setIdempotencyKey('my-resume-idempotency-key'); + $this->client->subscriptions->resume('subscription_123'); + + $headers = $this->httpClient->lastSentHeaders(); + $this->assertEquals('my-resume-idempotency-key', $headers['Idempotency-Key']); + } + /** @test */ public function can_update_billing_details() {