diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..24a4fda
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,22 @@
+name: tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.3', '8.4']
+ steps:
+ - uses: actions/checkout@v6
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ - run: composer update --no-interaction --prefer-dist --no-progress
+ - run: vendor/bin/phpunit
diff --git a/README.md b/README.md
index a48656f..1314472 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,55 @@ class User extends Authenticatable
then, you can use the `Billable` trait methods in your user model.
+### App subscriptions (trusted apps, no user token)
+
+`subscriptions()` acts with the user's own access token and stops working when the user's login
+expired. Trusted apps can manage a user's subscriptions with the app's own token (client
+credentials, scope `subscriptions:client`) instead:
+
+```php
+$subscriptions = $team->owner->appSubscriptions(); // nothing is requested yet
+
+$subscription = $subscriptions->create([
+ 'group' => $team->id,
+ 'name' => 'Cloud Server S',
+ 'unit' => 'hour',
+ 'price' => 0.0137,
+ 'idempotency_key' => "server:{$pendingServer->id}", // retries return the same subscription
+]);
+
+$subscription->checkout(); // bill the first period
+$subscription->pause(); // expires when the paid period ends
+$subscription->resume(); // expired subscriptions start a new period
+$subscription->revoke(refund: false); // cancel
+
+$subscriptions->find($id); // null if unknown or not this user's
+$subscriptions->all(['group' => $team->id, 'active' => true]);
+```
+
+The user's Anikeen ID is read from the billable's `anikeen_id` attribute
+(`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the
+subscriptions it created (plus those of clients delegated to it).
+
+Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable
+`error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`,
+`already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`,
+`idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
+
+```php
+try {
+ $subscription->revoke();
+} catch (ApiException $e) {
+ if ($e->isNotFound() || $e->error === 'already_cancelled') {
+ // already gone
+ } elseif ($e->isTemporary()) {
+ // retry later
+ } else {
+ throw $e;
+ }
+}
+```
+
### Change the default access token / refresh token field name
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
diff --git a/composer.json b/composer.json
index a4c5e6d..10d7319 100644
--- a/composer.json
+++ b/composer.json
@@ -22,8 +22,9 @@
"firebase/php-jwt": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^8.0|^9.0",
- "laravel/framework": "^10.0|^11.0|^12.0|^13.0"
+ "phpunit/phpunit": "^11.0|^12.0",
+ "laravel/framework": "^10.0|^11.0|^12.0|^13.0",
+ "orchestra/testbench": "^9.0|^10.0|^11.0"
},
"autoload": {
"psr-4": {
diff --git a/phpunit.xml b/phpunit.xml
index 54a9935..55c80ed 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,29 +1,16 @@
-
-
-
-
- tests
-
-
-
-
- src
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php
index 30e20bf..64a4d64 100644
--- a/src/Id/AnikeenId.php
+++ b/src/Id/AnikeenId.php
@@ -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';
diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php
index eb5225a..6a5cdda 100644
--- a/src/Id/Concerns/ManagesSubscriptions.php
+++ b/src/Id/Concerns/ManagesSubscriptions.php
@@ -1,27 +1,38 @@
-subscriptionsCache)) {
- $this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
- ->request('GET', 'v1/subscriptions'))
- ->setBillable($this);
- }
-
- return $this->subscriptionsCache;
- }
+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);
+ }
}
\ No newline at end of file
diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php
new file mode 100644
index 0000000..35a236d
--- /dev/null
+++ b/src/Id/Exceptions/ApiException.php
@@ -0,0 +1,39 @@
+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;
+ }
+}
diff --git a/src/Id/Repository/AppTokenRepository.php b/src/Id/Repository/AppTokenRepository.php
index 158bc72..980ed02 100644
--- a/src/Id/Repository/AppTokenRepository.php
+++ b/src/Id/Repository/AppTokenRepository.php
@@ -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
*/
diff --git a/src/Id/Resources/AppSubscription.php b/src/Id/Resources/AppSubscription.php
new file mode 100644
index 0000000..3ad1974
--- /dev/null
+++ b/src/Id/Resources/AppSubscription.php
@@ -0,0 +1,126 @@
+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;
+ }
+}
diff --git a/src/Id/Resources/AppSubscriptions.php b/src/Id/Resources/AppSubscriptions.php
new file mode 100644
index 0000000..472a911
--- /dev/null
+++ b/src/Id/Resources/AppSubscriptions.php
@@ -0,0 +1,116 @@
+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
+ *
+ * @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;
+ }
+}
diff --git a/src/Id/Support/AppRequest.php b/src/Id/Support/AppRequest.php
new file mode 100644
index 0000000..9070d4f
--- /dev/null
+++ b/src/Id/Support/AppRequest.php
@@ -0,0 +1,62 @@
+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);
+ }
+ }
+}
diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php
new file mode 100644
index 0000000..d094fe5
--- /dev/null
+++ b/tests/Id/AppSubscriptionsTest.php
@@ -0,0 +1,172 @@
+anikeen_id = $anikeenId;
+ $owner->anikeen_id_access_token = 'user-token';
+
+ return $owner;
+ }
+
+ private function subscription(array $overrides = []): array
+ {
+ return ['data' => ['id' => '01JSUB', 'user_id' => '01JUSER', 'name' => 'Server', 'active' => false, 'cancelled' => false, ...$overrides]];
+ }
+
+ public function test_create_sends_the_user_id_with_the_app_token(): void
+ {
+ $this->respond(201, $this->subscription());
+
+ $subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1']);
+
+ $request = $this->sent[0]['request'];
+ $this->assertSame('POST', $request->getMethod());
+ $this->assertSame('/api/v1/client/subscriptions', $request->getUri()->getPath());
+ $this->assertSame('Bearer app-token', $request->getHeaderLine('Authorization'));
+ $this->assertSame(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1', 'user_id' => '01JUSER'], $this->sentJson(0));
+ $this->assertSame('01JSUB', $subscription->id);
+ }
+
+ public function test_nothing_is_requested_until_a_method_is_called(): void
+ {
+ $this->owner()->appSubscriptions();
+
+ $this->assertCount(0, $this->sent);
+ }
+
+ public function test_actions_use_the_app_endpoints(): void
+ {
+ $this->respond(201, $this->subscription());
+ $this->respond(200, $this->subscription(['active' => true]));
+ $this->respond(200, $this->subscription(['active' => true, 'autorenew' => false]));
+ $this->respond(200, $this->subscription(['active' => true, 'autorenew' => true]));
+ $this->respond(200, $this->subscription(['cancelled' => true]));
+
+ $subscription = $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
+
+ $this->assertTrue($subscription->checkout()->active);
+ $this->assertFalse($subscription->pause()->autorenew);
+ $this->assertTrue($subscription->resume()->autorenew);
+ $this->assertTrue($subscription->revoke(refund: true)->cancelled);
+
+ $this->assertSame([
+ 'POST /api/v1/client/subscriptions',
+ 'PUT /api/v1/client/subscriptions/01JSUB/checkout',
+ 'PUT /api/v1/client/subscriptions/01JSUB/pause',
+ 'PUT /api/v1/client/subscriptions/01JSUB/resume',
+ 'PUT /api/v1/client/subscriptions/01JSUB/revoke',
+ ], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath(), $this->sent));
+ $this->assertSame(['refund' => true], $this->sentJson(4));
+ }
+
+ public function test_errors_carry_status_and_error_code(): void
+ {
+ $this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
+
+ try {
+ $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
+ $this->fail('Expected an ApiException.');
+ } catch (ApiException $e) {
+ $this->assertSame(409, $e->status);
+ $this->assertSame('already_cancelled', $e->error);
+ $this->assertTrue($e->isConflict());
+ $this->assertFalse($e->isTemporary());
+ }
+ }
+
+ public function test_find_returns_null_for_unknown_subscriptions_and_other_users(): void
+ {
+ $this->respond(404, ['message' => 'Subscription not found.', 'error' => 'subscription_not_found']);
+ $this->respond(200, $this->subscription(['user_id' => '01JSOMEONEELSE']));
+ $this->respond(200, $this->subscription());
+
+ $subscriptions = $this->owner()->appSubscriptions();
+
+ $this->assertNull($subscriptions->find('01JUNKNOWN'));
+ $this->assertNull($subscriptions->find('01JSUB'));
+ $this->assertSame('01JSUB', $subscriptions->find('01JSUB')->id);
+ }
+
+ public function test_all_follows_pages_and_filters_by_user(): void
+ {
+ $this->respond(200, ['data' => [['id' => 'a', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 1, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
+ $this->respond(200, ['data' => [['id' => 'b', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 2, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
+
+ $all = $this->owner()->appSubscriptions()->all(['group' => 'team-1', 'active' => true]);
+
+ $this->assertSame(['a', 'b'], array_map(fn ($s) => $s->id, $all));
+ parse_str($this->sent[0]['request']->getUri()->getQuery(), $query);
+ $this->assertSame(['group' => 'team-1', 'active' => '1', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $query);
+ }
+
+ public function test_a_rejected_app_token_is_renewed_once(): void
+ {
+ $this->respond(401, ['message' => 'Unauthenticated.']);
+ $this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
+
+ $this->assertSame([], $this->owner()->appSubscriptions()->all());
+
+ $this->assertSame('Bearer app-token', $this->sent[0]['request']->getHeaderLine('Authorization'));
+ $this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
+ }
+
+ public function test_an_unreachable_service_is_a_temporary_api_exception(): void
+ {
+ $this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/client/subscriptions')));
+
+ try {
+ $this->owner()->appSubscriptions()->all();
+ $this->fail('Expected an ApiException.');
+ } catch (ApiException $e) {
+ $this->assertSame(0, $e->status);
+ $this->assertTrue($e->isTemporary());
+ }
+ }
+
+ public function test_the_shared_client_keeps_the_users_token(): void
+ {
+ $this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
+ app(AnikeenId::class)->withToken('user-token');
+
+ $this->owner()->appSubscriptions()->all();
+
+ $this->assertSame('user-token', app(AnikeenId::class)->getToken());
+ }
+
+ public function test_the_user_id_field_is_configurable(): void
+ {
+ AnikeenId::useUserIdField('id_service_user');
+ $owner = $this->owner(null);
+ $owner->id_service_user = '01JOTHER';
+
+ try {
+ $this->assertSame('01JOTHER', $owner->appSubscriptions()->userId());
+ } finally {
+ AnikeenId::useUserIdField('anikeen_id');
+ }
+ }
+
+ public function test_a_missing_user_id_is_reported(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->owner(null)->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
+ }
+}
diff --git a/tests/Id/TestCase.php b/tests/Id/TestCase.php
new file mode 100644
index 0000000..020e72b
--- /dev/null
+++ b/tests/Id/TestCase.php
@@ -0,0 +1,62 @@
+ */
+ protected array $sent = [];
+
+ protected function getPackageProviders($app): array
+ {
+ return [AnikeenIdServiceProvider::class];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ config(['services.anikeen.client_id' => 'app-client', 'services.anikeen.client_secret' => 'secret']);
+
+ $this->http = new MockHandler();
+ $stack = HandlerStack::create($this->http);
+ $stack->push(Middleware::history($this->sent));
+
+ $this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new Client(['handler' => $stack, 'base_uri' => 'https://id.test/api/'])));
+ $this->app->instance(AppTokenRepository::class, new class implements AppTokenRepository {
+ public int $forgotten = 0;
+
+ public function getAccessToken(): string
+ {
+ return $this->forgotten ? 'fresh-app-token' : 'app-token';
+ }
+
+ public function forgetAccessToken(): void
+ {
+ $this->forgotten++;
+ }
+ });
+ }
+
+ protected function respond(int $status, array $body = []): void
+ {
+ $this->http->append(new Response($status, ['Content-Type' => 'application/json'], json_encode($body)));
+ }
+
+ protected function sentJson(int $index): array
+ {
+ return json_decode((string) $this->sent[$index]['request']->getBody(), true) ?? [];
+ }
+}