mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-01 13:19:34 +00:00
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:
1 parent
9c3f9344a0
commit
8de737641e
13 files changed
+749
-57
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user