App subscriptions: manage a user's subscriptions with the app's own token

- $user->appSubscriptions(): create (idempotency_key), find, all, checkout, revoke, pause,
  resume, update against /v1/client/subscriptions with a client credentials token, so apps don't
  depend on the user's login
- user id from the billable's anikeen_id (AnikeenId::useUserIdField())
- ApiException with status, stable error code, isNotFound/isConflict/isTemporary; network
  errors no longer escape as raw Guzzle exceptions
- rejected app tokens are renewed once; asApp() never changes the shared client's token
- tests (testbench + Guzzle mock) and CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Maurice PreußandClaude Opus 5.5 committed 2026-10-25 17:02:16 +01:00
1 parent 9c3f9344a0
commit 8de737641e
13 files changed
+749 -57

No files matched your search

+37
View File
@@ -58,6 +58,8 @@ class AnikeenId
*/
private static string $refreshTokenField = 'anikeen_id_refresh_token';
private static string $userIdField = 'anikeen_id';
/**
* Guzzle is used to make http requests.
*/
@@ -143,6 +145,41 @@ class AnikeenId
return self::$accessTokenField;
}
/**
* Attribute of the billable model that holds the user's Anikeen ID (used by app requests,
* e.g. appSubscriptions(), which act on behalf of a user without the user's token).
*/
public static function useUserIdField(string $userIdField): void
{
self::$userIdField = $userIdField;
}
public static function getUserIdField(): string
{
return self::$userIdField;
}
/**
* A copy that uses the app's own access token (client credentials) instead of a user's token.
* The shared instance keeps its token, so app rights never leak into other requests.
*
* @throws Exceptions\RequestFreshAccessTokenException
*/
public function asApp(): static
{
return (clone $this)->withToken(app(Contracts\AppTokenRepository::class)->getAccessToken());
}
/**
* Replace the HTTP client, e.g. with one using a Guzzle MockHandler in tests.
*/
public function setHttpClient(Client $client): static
{
$this->client = $client;
return $this;
}
public static function getMode(): string
{
return config('services.anikeen.mode') ?: 'production';
+37 -26
View File
@@ -1,27 +1,38 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
trait ManagesSubscriptions
{
use HasBillable;
/**
* Get subscriptions from the current user.
*
* @throws Throwable
*/
public function subscriptions(): Subscriptions
{
if (!isset($this->subscriptionsCache)) {
$this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/subscriptions'))
->setBillable($this);
}
return $this->subscriptionsCache;
}
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
trait ManagesSubscriptions
{
use HasBillable;
/**
* Get subscriptions from the current user.
*
* @throws Throwable
*/
public function subscriptions(): Subscriptions
{
if (!isset($this->subscriptionsCache)) {
$this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/subscriptions'))
->setBillable($this);
}
return $this->subscriptionsCache;
}
/**
* The user's subscriptions, managed with the app's own token instead of the user's
* (trusted apps only, scope subscriptions:client). Use this in jobs and anywhere the user's
* login may have expired.
*/
public function appSubscriptions(): AppSubscriptions
{
return new AppSubscriptions($this);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Anikeen\Id\Exceptions;
use Throwable;
/**
* A request to the ID service failed. `status` is the HTTP status (0 when the service could not
* be reached), `error` the service's stable error code when it sent one (e.g. "already_cancelled").
*/
class ApiException extends ResourceException
{
public function __construct(
string $message,
public readonly int $status = 0,
public readonly ?string $error = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status, $previous);
}
public function isNotFound(): bool
{
return $this->status === 404;
}
public function isConflict(): bool
{
return $this->status === 409;
}
/**
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/
public function isTemporary(): bool
{
return $this->status === 0 || $this->status >= 500 || $this->status === 429;
}
}
+8
View File
@@ -32,6 +32,14 @@ class AppTokenRepository implements Repository
return $this->requestFreshAccessToken('*');
}
/**
* Drop the cached token, e.g. after the ID service rejected it.
*/
public function forgetAccessToken(): void
{
Cache::forget(self::ACCESS_TOKEN_CACHE_KEY);
}
/**
* @throws RequestFreshAccessTokenException
*/
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $vat_rate
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status "active" or "inactive"
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
* @property bool $is_past_due
* @property int $failed_renewal_retries
* @property ?array $payload
* @property ?string $idempotency_key
*/
class AppSubscription implements JsonSerializable
{
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* Change a subscription that wasn't checked out yet.
*
* @throws ApiException
*/
public function update(array $attributes): self
{
return $this->send('PUT', '', $attributes);
}
/**
* Activate the subscription and bill its first period.
*
* @throws ApiException 409 "already_active", "billing_address_missing", …
*/
public function checkout(): self
{
return $this->send('PUT', '/checkout');
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Subscriptions of a user, managed with the app's own token (client credentials, scope
* `subscriptions:client`) instead of the user's token, so they keep working when the user's
* login expired. The app has to be trusted by the ID service. Nothing is requested until a
* method is called.
*/
class AppSubscriptions
{
public function __construct(protected Billable|Model $billable)
{
}
/**
* The user's Anikeen ID (see AnikeenId::useUserIdField()).
*/
public function userId(): string
{
$userId = $this->billable->{AnikeenId::getUserIdField()} ?? null;
if (empty($userId)) {
throw new InvalidArgumentException(sprintf('The billable has no Anikeen ID in [%s].', AnikeenId::getUserIdField()));
}
return (string) $userId;
}
/**
* Create a subscription for the user (it is billed only after checkout()).
*
* @param array{
* group?: null|string,
* name: string,
* description?: null|string,
* unit: string,
* price: float|string,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string,
* webhook_url?: null|string,
* webhook_secret?: null|string,
* idempotency_key?: null|string
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
* subscription created by the first one instead of a duplicate.
*
* @throws ApiException
*/
public function create(array $attributes): AppSubscription
{
return new AppSubscription(AppRequest::send('POST', 'v1/client/subscriptions', [
...$attributes,
'user_id' => $this->userId(),
])->data);
}
/**
* Find one of the user's subscriptions; null when it doesn't exist (or belongs to someone else).
*
* @throws ApiException
*/
public function find(string $id): ?AppSubscription
{
try {
$subscription = new AppSubscription(AppRequest::send('GET', sprintf('v1/client/subscriptions/%s', $id))->data);
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return $subscription->user_id === $this->userId() ? $subscription : null;
}
/**
* All of the user's subscriptions, optionally filtered.
*
* @param array{group?: string, active?: bool, cancelled?: bool} $filters
* @return list<AppSubscription>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$subscriptions = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/subscriptions', null, [
...array_map(fn ($value) => is_bool($value) ? (int) $value : $value, $filters),
'user_id' => $this->userId(),
'per_page' => 100,
'page' => $page,
]);
foreach ($result->data as $data) {
$subscriptions[] = new AppSubscription($data);
}
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $subscriptions;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code.
*/
final class AppRequest
{
/**
* @throws ApiException
*/
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result
{
$result = self::attempt($method, $path, $payload, $query);
if ($result->status === 401) {
$tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query);
}
}
if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null;
throw new ApiException(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
return $result;
}
/**
* @throws ApiException
*/
private static function attempt(string $method, string $path, ?array $payload, array $query): Result
{
try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query);
} catch (ApiException $e) {
throw $e;
} catch (GuzzleException|Throwable $e) {
// network errors / timeouts (ConnectException isn't a RequestException) and token errors
throw new ApiException(sprintf('Anikeen ID is not reachable: %s', $e->getMessage()), 0, null, $e);
}
}
}