From ce820cd198f8d3dd7d0ce23ace5c44c36df1e2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Wed, 4 Nov 2026 16:15:48 +0100 Subject: [PATCH] Add subscription status helpers status() returns draft, trial, active, paused, cancelling, expired or cancelled (SubscriptionStatus constants), derived from the fields the same way Anikeen ID derives its status, so it also works with older responses. isActive() is true while the subscription runs and is paid. Both Subscription and AppSubscription have them. Document the states. Co-Authored-By: Claude Opus 5.5 --- README.md | 27 ++- README.stub | 27 ++- src/Id/Concerns/HasSubscriptionStatus.php | 82 ++++++++ src/Id/Enums/SubscriptionStatus.php | 33 +++ src/Id/Resources/AppSubscription.php | 7 +- src/Id/Resources/Subscription.php | 233 +++++++++++----------- tests/Id/SubscriptionStatusTest.php | 56 ++++++ 7 files changed, 348 insertions(+), 117 deletions(-) create mode 100644 src/Id/Concerns/HasSubscriptionStatus.php create mode 100644 src/Id/Enums/SubscriptionStatus.php create mode 100644 tests/Id/SubscriptionStatusTest.php diff --git a/README.md b/README.md index d719615..a5157c1 100644 --- a/README.md +++ b/README.md @@ -338,8 +338,31 @@ return redirect()->away($subscription->confirmationUrl(route('pass.return'))); // later: https://shop.test/pass/return?status=completed&subscription=01J… ``` -A subscription has `active` (running and paid), `cancelled` and `autorenew` (false once paused). -After the return, check `$user->subscriptions()->find($id)->active`. +After the return, read the subscription and check its state: + +```php +$subscription = $user->subscriptions()->find($id); + +if ($subscription->isActive()) { + // grant access +} +``` + +`status()` returns one of the `Anikeen\Id\Enums\SubscriptionStatus` constants (the API sends the +same value as `status`): + +| `status()` | Meaning | `isActive()` | +|---|---|---| +| `draft` | Created, not confirmed by the user yet | no | +| `trial` | Running, in its trial period | yes | +| `active` | Running and billed | yes | +| `paused` | Runs until the paid period ends, then expires (`resume()` to continue) | yes | +| `cancelling` | Runs until a scheduled cancellation takes effect | yes | +| `expired` | Paused and ran out; `resume()` starts a new period | no | +| `cancelled` | Cancelled for good | no | + +There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, +`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. ### Webhooks diff --git a/README.stub b/README.stub index 5bb5ac5..b135453 100644 --- a/README.stub +++ b/README.stub @@ -338,8 +338,31 @@ return redirect()->away($subscription->confirmationUrl(route('pass.return'))); // later: https://shop.test/pass/return?status=completed&subscription=01J… ``` -A subscription has `active` (running and paid), `cancelled` and `autorenew` (false once paused). -After the return, check `$user->subscriptions()->find($id)->active`. +After the return, read the subscription and check its state: + +```php +$subscription = $user->subscriptions()->find($id); + +if ($subscription->isActive()) { + // grant access +} +``` + +`status()` returns one of the `Anikeen\Id\Enums\SubscriptionStatus` constants (the API sends the +same value as `status`): + +| `status()` | Meaning | `isActive()` | +|---|---|---| +| `draft` | Created, not confirmed by the user yet | no | +| `trial` | Running, in its trial period | yes | +| `active` | Running and billed | yes | +| `paused` | Runs until the paid period ends, then expires (`resume()` to continue) | yes | +| `cancelling` | Runs until a scheduled cancellation takes effect | yes | +| `expired` | Paused and ran out; `resume()` starts a new period | no | +| `cancelled` | Cancelled for good | no | + +There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`, +`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. ### Webhooks diff --git a/src/Id/Concerns/HasSubscriptionStatus.php b/src/Id/Concerns/HasSubscriptionStatus.php new file mode 100644 index 0000000..2cddb04 --- /dev/null +++ b/src/Id/Concerns/HasSubscriptionStatus.php @@ -0,0 +1,82 @@ +active; + $started = !empty($this->starts_at); + + return match (true) { + (bool) $this->cancelled => SubscriptionStatus::CANCELLED, + !$active && !$started => SubscriptionStatus::DRAFT, + $active && !empty($this->cancels_at) => SubscriptionStatus::CANCELLING, + $active && !$this->autorenew => SubscriptionStatus::PAUSED, + $active && !empty($this->trial_ends_at) && strtotime($this->trial_ends_at) > time() => SubscriptionStatus::TRIAL, + $active => SubscriptionStatus::ACTIVE, + !$this->autorenew && $started => SubscriptionStatus::EXPIRED, + default => SubscriptionStatus::INACTIVE, + }; + } + + /** + * Not checked out or confirmed yet (third-party apps: send the user to confirmationUrl()). + */ + public function isDraft(): bool + { + return $this->status() === SubscriptionStatus::DRAFT; + } + + /** + * Running and paid: active, in trial, paused or with a scheduled cancellation. Grant access + * while this is true. + */ + public function isActive(): bool + { + return in_array($this->status(), [SubscriptionStatus::TRIAL, SubscriptionStatus::ACTIVE, SubscriptionStatus::PAUSED, SubscriptionStatus::CANCELLING], true); + } + + /** + * Paused: runs until the paid period ends, then expires (resume() to continue). + */ + public function isPaused(): bool + { + return $this->status() === SubscriptionStatus::PAUSED; + } + + /** + * Running until a scheduled cancellation takes effect. + */ + public function isCancelling(): bool + { + return $this->status() === SubscriptionStatus::CANCELLING; + } + + /** + * Paused and ran out: resume() starts a new period. + */ + public function isExpired(): bool + { + return $this->status() === SubscriptionStatus::EXPIRED; + } + + /** + * Cancelled for good. + */ + public function isCancelled(): bool + { + return $this->status() === SubscriptionStatus::CANCELLED; + } +} diff --git a/src/Id/Enums/SubscriptionStatus.php b/src/Id/Enums/SubscriptionStatus.php new file mode 100644 index 0000000..95aeff5 --- /dev/null +++ b/src/Id/Enums/SubscriptionStatus.php @@ -0,0 +1,33 @@ + $this->billable->anikeenId() - ->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes))) - ->setBillable($this->billable); - } - - /** - * Force given subscription to check out (trusted apps only). - * - * @throws Throwable - */ - public function checkout(): self - { - return (new self(fn() => $this->billable->anikeenId() - ->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id)))) - ->setBillable($this->billable); - } - - /** - * Revoke a given running subscription from the current user. - * - * @throws Throwable - */ - public function revoke(bool $refund = false): self - { - $attributes = [ - 'refund' => $refund, - ]; - - return (new self(fn() => $this->billable->anikeenId() - ->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes))) - ->setBillable($this->billable); - } - - /** - * Pause a given running subscription from the current user. - * - * @throws Throwable - */ - public function pause(): self - { - return (new self(fn() => $this->billable->anikeenId() - ->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id)))) - ->setBillable($this->billable); - } - - /** - * Resume a given running subscription from the current user. - * - * @throws Throwable - */ - public function resume(): self - { - return (new self(fn() => $this->billable->anikeenId() - ->request('PUT', sprintf('v1/subscriptions/%s/resume', $this->id)))) - ->setBillable($this->billable); - } -} + $this->billable->anikeenId() + ->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes))) + ->setBillable($this->billable); + } + + /** + * Force given subscription to check out (trusted apps only). + * + * @throws Throwable + */ + public function checkout(): self + { + return (new self(fn() => $this->billable->anikeenId() + ->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id)))) + ->setBillable($this->billable); + } + + /** + * Revoke a given running subscription from the current user. + * + * @throws Throwable + */ + public function revoke(bool $refund = false): self + { + $attributes = [ + 'refund' => $refund, + ]; + + return (new self(fn() => $this->billable->anikeenId() + ->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes))) + ->setBillable($this->billable); + } + + /** + * Pause a given running subscription from the current user. + * + * @throws Throwable + */ + public function pause(): self + { + return (new self(fn() => $this->billable->anikeenId() + ->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id)))) + ->setBillable($this->billable); + } + + /** + * Resume a given running subscription from the current user. + * + * @throws Throwable + */ + public function resume(): self + { + return (new self(fn() => $this->billable->anikeenId() + ->request('PUT', sprintf('v1/subscriptions/%s/resume', $this->id)))) + ->setBillable($this->billable); + } +} diff --git a/tests/Id/SubscriptionStatusTest.php b/tests/Id/SubscriptionStatusTest.php new file mode 100644 index 0000000..ddeca9c --- /dev/null +++ b/tests/Id/SubscriptionStatusTest.php @@ -0,0 +1,56 @@ + '01JSUB', 'active' => false, 'autorenew' => true, 'cancelled' => false, 'starts_at' => null, 'cancels_at' => null, 'trial_ends_at' => null]; + + public static function states(): array + { + $started = ['starts_at' => '2026-10-01T00:00:00+00:00']; + + return [ + 'draft' => [[], SubscriptionStatus::DRAFT, false], + 'active' => [['active' => true, ...$started], SubscriptionStatus::ACTIVE, true], + 'trial' => [['active' => true, 'trial_ends_at' => date(DATE_ATOM, time() + 86400), ...$started], SubscriptionStatus::TRIAL, true], + 'trial over' => [['active' => true, 'trial_ends_at' => '2026-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::ACTIVE, true], + 'paused' => [['active' => true, 'autorenew' => false, ...$started], SubscriptionStatus::PAUSED, true], + 'cancelling' => [['active' => true, 'cancels_at' => '2027-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::CANCELLING, true], + 'expired' => [['autorenew' => false, ...$started], SubscriptionStatus::EXPIRED, false], + 'cancelled' => [['autorenew' => false, 'cancelled' => true, ...$started], SubscriptionStatus::CANCELLED, false], + ]; + } + + #[DataProvider('states')] + public function test_the_status_follows_the_fields(array $fields, string $status, bool $running): void + { + $data = [...self::BASE, ...$fields]; + + $this->respond(200, ['data' => [$data]]); // subscriptions() loads the list + $this->respond(200, ['data' => $data]); + $owner = new class extends Model { + use Billable; + }; + $owner->anikeen_id_access_token = 'user-token'; + $subscription = $owner->subscriptions()->find('01JSUB'); + + $app = new AppSubscription((object) $data); + + foreach ([$subscription, $app] as $resource) { + $this->assertSame($status, $resource->status()); + $this->assertSame($running, $resource->isActive()); + $this->assertSame($status === SubscriptionStatus::DRAFT, $resource->isDraft()); + $this->assertSame($status === SubscriptionStatus::PAUSED, $resource->isPaused()); + $this->assertSame($status === SubscriptionStatus::CANCELLING, $resource->isCancelling()); + $this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired()); + $this->assertSame($status === SubscriptionStatus::CANCELLED, $resource->isCancelled()); + } + } +}