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()); + } + } +}