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 <noreply@anthropic.com>
This commit is contained in:
Maurice PreußandClaude Opus 5.5 committed 2026-11-04 16:15:48 +01:00
1 parent 2faccac0f4
commit ce820cd198
7 files changed
+348 -117

No files matched your search

+25 -2
View File
@@ -338,8 +338,31 @@ return redirect()->away($subscription->confirmationUrl(route('pass.return')));
// later: https://shop.test/pass/return?status=completed&subscription=01J… // 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, read the subscription and check its state:
After the return, check `$user->subscriptions()->find($id)->active`.
```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 ### Webhooks
+25 -2
View File
@@ -338,8 +338,31 @@ return redirect()->away($subscription->confirmationUrl(route('pass.return')));
// later: https://shop.test/pass/return?status=completed&subscription=01J… // 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, read the subscription and check its state:
After the return, check `$user->subscriptions()->find($id)->active`.
```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 ### Webhooks
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SubscriptionStatus;
/**
* The state of a subscription, derived from its fields the same way Anikeen ID derives `status`
* (so it also works with responses that only carry the fields).
*/
trait HasSubscriptionStatus
{
/**
* One of the SubscriptionStatus constants: draft, trial, active, paused, cancelling, expired
* or cancelled.
*/
public function status(): string
{
$active = (bool) $this->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;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The state of a subscription, see Subscription::status() and AppSubscription::status().
*/
class SubscriptionStatus
{
/** Created, not checked out or confirmed by the user yet. */
const DRAFT = 'draft';
/** Running and billed, in its trial period. */
const TRIAL = 'trial';
/** Running and billed. */
const ACTIVE = 'active';
/** Paused: runs until its paid period ends, then expires. */
const PAUSED = 'paused';
/** Running until a scheduled cancellation takes effect. */
const CANCELLING = 'cancelling';
/** Paused and ran out: can be resumed (new period) or cancelled. */
const EXPIRED = 'expired';
/** Cancelled for good. */
const CANCELLED = 'cancelled';
/** None of the above. */
const INACTIVE = 'inactive';
}
+6 -1
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Resources; namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use DateTimeInterface; use DateTimeInterface;
@@ -24,7 +25,9 @@ use JsonSerializable;
* @property bool $active * @property bool $active
* @property bool $autorenew * @property bool $autorenew
* @property bool $cancelled * @property bool $cancelled
* @property ?string $status "active" or "inactive" * @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property ?string $starts_at * @property ?string $starts_at
* @property ?string $ends_at * @property ?string $ends_at
* @property ?string $accounting_at * @property ?string $accounting_at
@@ -36,6 +39,8 @@ use JsonSerializable;
*/ */
class AppSubscription implements JsonSerializable class AppSubscription implements JsonSerializable
{ {
use HasSubscriptionStatus;
public function __construct(protected object $data) public function __construct(protected object $data)
{ {
} }
+121 -112
View File
@@ -1,112 +1,121 @@
<?php <?php
namespace Anikeen\Id\Resources; namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable; use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl; use Anikeen\Id\Concerns\HasConfirmationUrl;
use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Throwable; use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable;
/**
* @property string $id /**
* @property string $name * @property string $id
* @property string $description * @property string $name
* @property string $unit * @property string $description
* @property float $price * @property string $unit
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units) * @property float $price
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap) * @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap * @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property float $vat_rate * @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property array $payload * @property float $vat_rate
* @property string $ends_at * @property array $payload
* @property null|string $confirmation_url where the user confirms it (third-party apps) * @property bool $active
*/ * @property bool $autorenew
class Subscription extends BaseResource * @property bool $cancelled
{ * @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
use HasBillable; * @property ?string $starts_at
use HasConfirmationUrl; * @property string $ends_at
* @property ?string $cancels_at when a scheduled cancellation takes effect
/** * @property ?string $trial_ends_at
* Update a given subscription from the current user. * @property null|string $confirmation_url where the user confirms it (third-party apps)
* */
* @param array{ class Subscription extends BaseResource
* group: string, {
* name: string, use HasBillable;
* description: null|string, use HasConfirmationUrl;
* unit: string, use HasSubscriptionStatus;
* price: float,
* monthly_cap?: null|float, /**
* vat_rate: float, * Update a given subscription from the current user.
* payload: null|array, *
* ends_at: null|string * @param array{
* } $attributes The subscription data: * group: string,
* - group: The group (optional) * name: string,
* - name: The name (required when set) * description: null|string,
* - description: The description (optional) * unit: string,
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year") * price: float,
* - price: The price per unit (required when set) * monthly_cap?: null|float,
* - monthly_cap: Net, at most this per calendar month (hour and day units; optional) * vat_rate: float,
* - vat_rate: The VAT rate (required when set) * payload: null|array,
* - payload: The payload (optional) * ends_at: null|string
* - ends_at: The end date (optional) * } $attributes The subscription data:
* @throws Throwable * - group: The group (optional)
*/ * - name: The name (required when set)
public function update(array $attributes): self * - description: The description (optional)
{ * - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
return (new self(fn() => $this->billable->anikeenId() * - price: The price per unit (required when set)
->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes))) * - monthly_cap: Net, at most this per calendar month (hour and day units; optional)
->setBillable($this->billable); * - vat_rate: The VAT rate (required when set)
} * - payload: The payload (optional)
* - ends_at: The end date (optional)
/** * @throws Throwable
* Force given subscription to check out (trusted apps only). */
* public function update(array $attributes): self
* @throws Throwable {
*/ return (new self(fn() => $this->billable->anikeenId()
public function checkout(): self ->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes)))
{ ->setBillable($this->billable);
return (new self(fn() => $this->billable->anikeenId() }
->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id))))
->setBillable($this->billable); /**
} * Force given subscription to check out (trusted apps only).
*
/** * @throws Throwable
* Revoke a given running subscription from the current user. */
* public function checkout(): self
* @throws Throwable {
*/ return (new self(fn() => $this->billable->anikeenId()
public function revoke(bool $refund = false): self ->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id))))
{ ->setBillable($this->billable);
$attributes = [ }
'refund' => $refund,
]; /**
* Revoke a given running subscription from the current user.
return (new self(fn() => $this->billable->anikeenId() *
->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes))) * @throws Throwable
->setBillable($this->billable); */
} public function revoke(bool $refund = false): self
{
/** $attributes = [
* Pause a given running subscription from the current user. 'refund' => $refund,
* ];
* @throws Throwable
*/ return (new self(fn() => $this->billable->anikeenId()
public function pause(): self ->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes)))
{ ->setBillable($this->billable);
return (new self(fn() => $this->billable->anikeenId() }
->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id))))
->setBillable($this->billable); /**
} * Pause a given running subscription from the current user.
*
/** * @throws Throwable
* Resume a given running subscription from the current user. */
* public function pause(): self
* @throws Throwable {
*/ return (new self(fn() => $this->billable->anikeenId()
public function resume(): self ->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id))))
{ ->setBillable($this->billable);
return (new self(fn() => $this->billable->anikeenId() }
->request('PUT', sprintf('v1/subscriptions/%s/resume', $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);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\SubscriptionStatus;
use Anikeen\Id\Resources\AppSubscription;
use Illuminate\Database\Eloquent\Model;
use PHPUnit\Framework\Attributes\DataProvider;
class SubscriptionStatusTest extends TestCase
{
private const BASE = ['id' => '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());
}
}
}