From 8de737641ec180c7f7c530c6329615a5463ebad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 25 Oct 2026 17:02:16 +0100 Subject: [PATCH] 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 --- .github/workflows/tests.yml | 22 +++ README.md | 49 +++++++ composer.json | 5 +- phpunit.xml | 45 +++--- src/Id/AnikeenId.php | 37 +++++ src/Id/Concerns/ManagesSubscriptions.php | 63 +++++---- src/Id/Exceptions/ApiException.php | 39 +++++ src/Id/Repository/AppTokenRepository.php | 8 ++ src/Id/Resources/AppSubscription.php | 126 +++++++++++++++++ src/Id/Resources/AppSubscriptions.php | 116 +++++++++++++++ src/Id/Support/AppRequest.php | 62 ++++++++ tests/Id/AppSubscriptionsTest.php | 172 +++++++++++++++++++++++ tests/Id/TestCase.php | 62 ++++++++ 13 files changed, 749 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 src/Id/Exceptions/ApiException.php create mode 100644 src/Id/Resources/AppSubscription.php create mode 100644 src/Id/Resources/AppSubscriptions.php create mode 100644 src/Id/Support/AppRequest.php create mode 100644 tests/Id/AppSubscriptionsTest.php create mode 100644 tests/Id/TestCase.php 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) ?? []; + } +}