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 1/2] 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) ?? []; + } +} From 9613d31a8d2efe2642f8058168c9225c601dafbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 25 Oct 2026 17:18:05 +0100 Subject: [PATCH 2/2] Bring the package up to date with the Anikeen ID API Fixed (called endpoints that don't exist): - charge(): POST v1/billing/charge (Stripe options aren't passed through anymore) - Invoice::getInvoiceTemporaryUrl(): GET instead of a non-existent PUT - OrderItems::create(): POST v1/orders/{order}/items - createUser() / isEmailExisting(): admin endpoints with the app token - sshKeysByUserId(): app endpoint; no longer returns the first user's keys for every user - createOrderPreview(): country_iso goes into billing_address as the API expects - public endpoints (products, order preview) work without a token - SshKeys::find() results can be deleted Added: - Billable: sshKeys() (create, update, delete), billingOverview(), PaymentMethod::delete() - AnikeenId: products(), boxes(), shipping labels (list, show, pdf, issue, ship, price list), createOrderForUser(), createTransactionForUser(), findOrCreateSocialiteUser() Requirements: PHP 8.2+, Laravel 11-13 (Laravel 10 and PHP 8.1 are end of life). Docs generator handles union types; README regenerated. Co-Authored-By: Claude Opus 5.5 --- README.md | 72 +++++++- README.stub | 49 +++++ composer.json | 24 +-- generator/generate-docs.php | 24 ++- phpunit.xml | 32 ++-- src/Id/AnikeenId.php | 9 +- src/Id/Billable.php | 2 + src/Id/Concerns/ManagesBalance.php | 26 ++- src/Id/Concerns/ManagesPricing.php | 8 +- src/Id/Concerns/ManagesProducts.php | 29 +++ src/Id/Concerns/ManagesShippingLabels.php | 85 +++++++++ src/Id/Concerns/ManagesSshKeys.php | 13 +- src/Id/Concerns/ManagesSubscriptions.php | 76 ++++---- src/Id/Concerns/ManagesUserBilling.php | 61 +++++++ src/Id/Concerns/ManagesUserSshKeys.php | 23 +++ src/Id/Concerns/ManagesUsers.php | 34 +++- src/Id/Resources/Invoice.php | 8 +- src/Id/Resources/OrderItems.php | 13 +- src/Id/Resources/PaymentMethod.php | 14 +- src/Id/Resources/SshKey.php | 12 +- src/Id/Resources/SshKeys.php | 4 +- tests/Id/EndpointsTest.php | 210 ++++++++++++++++++++++ 22 files changed, 720 insertions(+), 108 deletions(-) create mode 100644 src/Id/Concerns/ManagesProducts.php create mode 100644 src/Id/Concerns/ManagesShippingLabels.php create mode 100644 src/Id/Concerns/ManagesUserBilling.php create mode 100644 src/Id/Concerns/ManagesUserSshKeys.php create mode 100644 tests/Id/EndpointsTest.php diff --git a/README.md b/README.md index 1314472..01ae3e2 100644 --- a/README.md +++ b/README.md @@ -341,10 +341,41 @@ public function retrievingToken(string $grantType, array $attributes): Result public function createOrderPreview(array $attributes = []): Result ``` +### ManagesProducts + +```php +public function products(): Result +public function boxes(): Result +``` + +### ManagesShippingLabels + +```php +public function shippingLabels(array $parameters = []): Result +public function shippingLabel(string $id): Result +public function shippingLabelPdf(string $id): ?string +public function issueShippingLabel(array $attributes): Result +public function shipShippingLabel(array $hex): Result +public function shippingPriceList(): Result +``` + +### ManagesUserBilling + +```php +public function createOrderForUser(string $userId, array $attributes): Result +public function createTransactionForUser(string $userId, array $attributes): Result +``` + +### ManagesSocialiteUsers + +```php +public function socialiteUsers(): SocialiteUsers +``` + ### ManagesSshKeys ```php -public function sshKeysByUserId(string $sskKeyId): SshKeys +public function sshKeysByUserId(string $userId): SshKeys ``` ### ManagesUsers @@ -352,8 +383,10 @@ public function sshKeysByUserId(string $sskKeyId): SshKeys ```php public function getAuthedUser(): Result public function createUser(array $attributes): Result +public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result +public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function isEmailExisting(string $email): Result -public function setParent(?mixed $parent): self +public function setParent(mixed $parent): self public function getParent() ``` @@ -365,6 +398,8 @@ public function getParent() ```php public function addresses(): Addresses public function hasDefaultBillingAddress(): bool +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesBalance @@ -372,25 +407,34 @@ public function hasDefaultBillingAddress(): bool ```php public function balance(): float public function charges(): float -public function charge(float $amount, string $paymentMethodId, array $options = []): Transaction +public function billingOverview(): object +public function charge(string|float $amount, string $paymentMethodId, array $options = []): Transaction +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesCountries ```php public function countries(): Countries +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesInvoices ```php public function invoices(array $parameters = []): Invoices +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesOrders ```php public function orders(array $parameters = []): Orders +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesPaymentMethods @@ -400,32 +444,52 @@ public function paymentMethods(): PaymentMethods public function hasPaymentMethod(): ?PaymentMethod public function defaultPaymentMethod(): ?PaymentMethod public function hasDefaultPaymentMethod(): bool +public function setDefaultPaymentMethod(string $id): PaymentMethod public function billingPortalUrl(?string $returnUrl = null, array $options = []): string public function createSetupIntent(array $options = []): Result +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesProfile ```php public function profilePortalUrl(?string $returnUrl = null, array $options = []): string +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesSubscriptions ```php public function subscriptions(): Subscriptions +public function appSubscriptions(): AppSubscriptions +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesTaxation ```php -public function vat(): float +public function vatRate(): float +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` ### ManagesTransactions ```php public function transactions(): Transactions +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable +``` + +### ManagesUserSshKeys + +```php +public function sshKeys(): SshKeys +public function setBillable(Billable|Model $billable): self +public function getBillable(): Billable ``` diff --git a/README.stub b/README.stub index db68d40..bafe028 100644 --- a/README.stub +++ b/README.stub @@ -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 10d7319..916adb0 100644 --- a/composer.json +++ b/composer.json @@ -1,30 +1,30 @@ { "name": "anikeen/id", - "description": "PHP AnikeenId API Client for Laravel 10+", + "description": "PHP Anikeen ID API client for Laravel 11+", "license": "MIT", "authors": [ { - "name": "René Preuß", + "name": "Ren\u00e9 Preu\u00df", "email": "rene@anikeen.com" }, { - "name": "Maurice Preuß", + "name": "Maurice Preu\u00df", "email": "maurice@anikeen.com" } ], "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "guzzlehttp/guzzle": "^6.3|^7.0", - "socialiteproviders/manager": "^3.4|^4.0.1", - "firebase/php-jwt": "^7.0" + "illuminate/support": "^11.0|^12.0|^13.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "guzzlehttp/guzzle": "^7.8", + "socialiteproviders/manager": "^4.6", + "firebase/php-jwt": "^6.10|^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.0|^12.0", - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "orchestra/testbench": "^9.0|^10.0|^11.0" + "laravel/framework": "^11.0|^12.0|^13.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "phpunit/phpunit": "^11.0|^12.0" }, "autoload": { "psr-4": { diff --git a/generator/generate-docs.php b/generator/generate-docs.php index 84e01e2..84ad269 100644 --- a/generator/generate-docs.php +++ b/generator/generate-docs.php @@ -7,6 +7,20 @@ use Anikeen\Id\Billable; use Illuminate\Support\Arr; use Illuminate\Support\Str; +// short type name incl. union, intersection and nullable types +function typeName(ReflectionType $type): string +{ + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + $separator = $type instanceof ReflectionUnionType ? '|' : '&'; + + return collect($type->getTypes())->map(fn (ReflectionType $t) => typeName($t))->implode($separator); + } + + $name = Arr::last(explode('\\', $type->getName())); + + return ($type->allowsNull() && !in_array($name, ['mixed', 'null'], true) ? '?' : '') . $name; +} + // Liste der Klassen, die ausgewertet werden sollen $classes = [ AnikeenId::class, @@ -51,10 +65,7 @@ $allMarkdown = collect($classes) // Typ-Hint $typeHint = ''; if ($p->hasType()) { - $type = $p->getType(); - $nullable = $type->allowsNull() ? '?' : ''; - $name = Arr::last(explode('\\', $type->getName())); - $typeHint = $nullable . $name . ' '; + $typeHint = typeName($p->getType()) . ' '; } // Parameter-Name @@ -84,10 +95,7 @@ $allMarkdown = collect($classes) // Rückgabetyp, falls vorhanden if ($method->hasReturnType()) { - $retType = $method->getReturnType(); - $nullable = $retType->allowsNull() ? '?' : ''; - $typeName = Arr::last(explode('\\', $retType->getName())); - $decl .= ': ' . $nullable . $typeName; + $decl .= ': ' . typeName($method->getReturnType()); } return $decl; diff --git a/phpunit.xml b/phpunit.xml index 55c80ed..bd95b1b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,16 +1,16 @@ - - - - - tests - - - - - src - - - + + + + + tests + + + + + src + + + diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index 64a4d64..93f504b 100644 --- a/src/Id/AnikeenId.php +++ b/src/Id/AnikeenId.php @@ -3,8 +3,11 @@ namespace Anikeen\Id; use Anikeen\Id\Concerns\ManagesPricing; +use Anikeen\Id\Concerns\ManagesProducts; +use Anikeen\Id\Concerns\ManagesShippingLabels; use Anikeen\Id\Concerns\ManagesSocialiteUsers; use Anikeen\Id\Concerns\ManagesSshKeys; +use Anikeen\Id\Concerns\ManagesUserBilling; use Anikeen\Id\Concerns\ManagesUsers; use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException; use Anikeen\Id\Exceptions\RequestRequiresClientIdException; @@ -21,6 +24,9 @@ class AnikeenId { use OauthTrait; use ManagesPricing; + use ManagesProducts; + use ManagesShippingLabels; + use ManagesUserBilling; use ManagesSocialiteUsers; use ManagesSshKeys; use ManagesUsers; @@ -398,7 +404,8 @@ class AnikeenId 'Client-ID' => $this->getClientId(), 'Accept' => 'application/json', ]; - if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->getToken()) { + // public endpoints (products, order preview, …) work without a token + if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->token) { $headers['Authorization'] = 'Bearer ' . $bearerToken; } if ($json) { diff --git a/src/Id/Billable.php b/src/Id/Billable.php index 6074035..81b4737 100644 --- a/src/Id/Billable.php +++ b/src/Id/Billable.php @@ -12,6 +12,7 @@ use Anikeen\Id\Concerns\ManagesProfile; use Anikeen\Id\Concerns\ManagesSubscriptions; use Anikeen\Id\Concerns\ManagesTaxation; use Anikeen\Id\Concerns\ManagesTransactions; +use Anikeen\Id\Concerns\ManagesUserSshKeys; use Throwable; trait Billable @@ -26,6 +27,7 @@ trait Billable use ManagesSubscriptions; use ManagesTaxation; use ManagesTransactions; + use ManagesUserSshKeys; /** * Get the currently authenticated user. diff --git a/src/Id/Concerns/ManagesBalance.php b/src/Id/Concerns/ManagesBalance.php index 7bf856d..7913096 100644 --- a/src/Id/Concerns/ManagesBalance.php +++ b/src/Id/Concerns/ManagesBalance.php @@ -30,21 +30,31 @@ trait ManagesBalance } /** - * Charge given amount from bank to current user. + * Billing overview of the current user: current_balance, current_charges, vat_rate, + * has_billing_address, has_default_payment_method, open_orders. * - * @param float $amount Amount to charge in euros. - * @param string $paymentMethodId Payment method ID. - * @param array $options Additional options for the charge. * @throws Throwable */ - public function charge(float $amount, string $paymentMethodId, array $options = []): Transaction + public function billingOverview(): object + { + return $this->anikeenId()->request('GET', 'v1/billing')->data; + } + + /** + * Charge given amount (deposit) from one of the current user's payment methods. + * + * @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals). + * @param string $paymentMethodId Stripe payment method ID (pm_…). + * @param array $options Ignored; Stripe options can no longer be passed through. + * @throws Throwable + */ + public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction { return (new Transaction(fn() => $this->anikeenId() - ->request('POST', 'billing/charge', [ + ->request('POST', 'v1/billing/charge', [ 'amount' => $amount, 'payment_method_id' => $paymentMethodId, - 'options' => $options, ]))) ->setBillable($this); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Concerns/ManagesPricing.php b/src/Id/Concerns/ManagesPricing.php index 43f27d2..ee521a5 100644 --- a/src/Id/Concerns/ManagesPricing.php +++ b/src/Id/Concerns/ManagesPricing.php @@ -32,6 +32,12 @@ trait ManagesPricing */ public function createOrderPreview(array $attributes = []): Result { + // the API expects the country as part of the billing address + if (isset($attributes['country_iso']) && !isset($attributes['billing_address'])) { + $attributes['billing_address'] = ['country_iso' => $attributes['country_iso']]; + unset($attributes['country_iso']); + } + return $this->post('v1/orders/preview', $attributes); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Concerns/ManagesProducts.php b/src/Id/Concerns/ManagesProducts.php new file mode 100644 index 0000000..77d63c6 --- /dev/null +++ b/src/Id/Concerns/ManagesProducts.php @@ -0,0 +1,29 @@ +request('GET', 'v1/products'); + } + + /** + * Shipping boxes (for shipping labels). + * + * @throws Throwable + */ + public function boxes(): Result + { + return $this->request('GET', 'v1/boxes'); + } +} diff --git a/src/Id/Concerns/ManagesShippingLabels.php b/src/Id/Concerns/ManagesShippingLabels.php new file mode 100644 index 0000000..44bd633 --- /dev/null +++ b/src/Id/Concerns/ManagesShippingLabels.php @@ -0,0 +1,85 @@ +asApp()->request('GET', 'v1/admin/shipping-labels', null, $parameters); + } + + /** + * @throws Throwable + */ + public function shippingLabel(string $id): Result + { + return $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s', $id)); + } + + /** + * The label as PDF (binary string), null if there is none. + * + * @throws Throwable + */ + public function shippingLabelPdf(string $id): ?string + { + $result = $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s/pdf', $id)); + + return $result->success() ? (string) $result->response()->getBody() : null; + } + + /** + * Buy a shipping label; the postage is billed to the given user. + * + * @param array{ + * box: array, + * weight: int, + * address: array, + * billed_user_id: string, + * test?: bool + * } $attributes + * - box: barcode, id, common_name or size of the box + * - weight: in grams + * - address: recipient address + * - billed_user_id: Anikeen ID of the user who pays the postage + * - test: validate only, no real label is bought + * @throws Throwable + */ + public function issueShippingLabel(array $attributes): Result + { + return $this->asApp()->request('POST', 'v1/admin/shipping-labels/issue', $attributes); + } + + /** + * Mark the label with the scanned matrix code as shipped. + * + * @param list $hex Hex bytes of the scanned Internetmarke matrix code + * @throws Throwable + */ + public function shipShippingLabel(array $hex): Result + { + return $this->asApp()->request('POST', 'v1/admin/shipping-labels/ship', ['hex' => $hex]); + } + + /** + * Prices of the available franking types. + * + * @throws Throwable + */ + public function shippingPriceList(): Result + { + return $this->asApp()->request('GET', 'v1/admin/shipping-labels/price-list'); + } +} diff --git a/src/Id/Concerns/ManagesSshKeys.php b/src/Id/Concerns/ManagesSshKeys.php index 07f05d5..5fa1c0e 100644 --- a/src/Id/Concerns/ManagesSshKeys.php +++ b/src/Id/Concerns/ManagesSshKeys.php @@ -12,17 +12,16 @@ trait ManagesSshKeys use Get; /** - * Get currently authed user with Bearer Token. + * SSH keys of a user, read with the app's own token (trusted apps, scope ssh-keys:read), + * e.g. to install them on a new server. * * @throws Throwable */ - public function sshKeysByUserId(string $sskKeyId): SshKeys + public function sshKeysByUserId(string $userId): SshKeys { - if (!isset($this->sshKeysCache)) { - $this->sshKeysCache = SshKeys::builder(fn() => $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId))) - ->setParent($this); - } + $app = $this->asApp(); - return $this->sshKeysCache; + return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId))) + ->setParent($app); } } \ No newline at end of file diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php index 6a5cdda..d9cb6dd 100644 --- a/src/Id/Concerns/ManagesSubscriptions.php +++ b/src/Id/Concerns/ManagesSubscriptions.php @@ -1,38 +1,38 @@ -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 +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/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php new file mode 100644 index 0000000..44472b9 --- /dev/null +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -0,0 +1,61 @@ +, + * checkout?: bool, + * transaction_id?: string, + * transaction?: array, + * invoice_date?: string, + * currency_iso?: string + * } $attributes + * @throws Throwable + */ + public function createOrderForUser(string $userId, array $attributes): Result + { + return $this->asApp()->request('POST', 'v1/admin/orders', [...$attributes, 'user_id' => $userId]); + } + + /** + * Book a payment of a user that was captured elsewhere (e.g. PayPal, Ko-fi). The same + * provider/payment_intent can only be booked once (409 with the existing transaction_id). + * + * @param string $userId The user's Anikeen ID. + * @param array{ + * payment_provider?: string, + * payment_intent?: string, + * status: string, + * type: string, + * amount: float|string, + * currency_iso: string, + * created_at: string, + * group?: string, + * invoice_id?: string + * } $attributes + * @throws Throwable + */ + public function createTransactionForUser(string $userId, array $attributes): Result + { + return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]); + } +} diff --git a/src/Id/Concerns/ManagesUserSshKeys.php b/src/Id/Concerns/ManagesUserSshKeys.php new file mode 100644 index 0000000..bc6745a --- /dev/null +++ b/src/Id/Concerns/ManagesUserSshKeys.php @@ -0,0 +1,23 @@ +anikeenId(); + + return SshKeys::builder(fn() => $anikeenId->get('v1/ssh-keys'))->setParent($anikeenId); + } +} diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php index cdeb20b..68824e5 100644 --- a/src/Id/Concerns/ManagesUsers.php +++ b/src/Id/Concerns/ManagesUsers.php @@ -42,7 +42,36 @@ trait ManagesUsers */ public function createUser(array $attributes): Result { - return $this->post('v1/users', $attributes); + // the API takes a single display name + if (!isset($attributes['name']) && (isset($attributes['first_name']) || isset($attributes['last_name']))) { + $attributes['name'] = trim(($attributes['first_name'] ?? '') . ' ' . ($attributes['last_name'] ?? '')) ?: null; + } + + if (isset($attributes['password']) && !isset($attributes['password_confirmation'])) { + $attributes['password_confirmation'] = $attributes['password']; + } + + unset($attributes['first_name'], $attributes['last_name']); + + // finds the user by email or creates it (trusted apps, app token) + return $this->asApp()->post('v1/admin/users', $attributes); + } + + /** + * Find the user of a social login or create it (trusted apps, app token). + * + * @param string $provider The socialite driver, e.g. "twitch". + * @param string $providerId The user's ID at the provider. + * @throws Throwable + */ + public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result + { + return $this->asApp()->post('v1/admin/users/socialite', array_filter([ + 'provider' => $provider, + 'provider_id' => $providerId, + 'email' => $email, + 'name' => $name, + ], fn ($value) => $value !== null)); } /** @@ -67,7 +96,8 @@ trait ManagesUsers */ public function isEmailExisting(string $email): Result { - return $this->post('v1/users/check', [ + // `data.exists` tells whether an account uses the email (trusted apps, app token) + return $this->asApp()->post('v1/admin/users/check', [ 'email' => $email, ]); } diff --git a/src/Id/Resources/Invoice.php b/src/Id/Resources/Invoice.php index 055cd71..e9d3012 100644 --- a/src/Id/Resources/Invoice.php +++ b/src/Id/Resources/Invoice.php @@ -17,11 +17,11 @@ class Invoice extends BaseResource * * @throws Throwable */ - public function getInvoiceTemporaryUrl(): string + public function getInvoiceTemporaryUrl(): ?string { return $this->billable->anikeenId() - ->request('PUT', sprintf('v1/invoices/%s', $this->id)) + ->request('GET', sprintf('v1/invoices/%s', $this->id)) ->data - ->temporary_url; + ->temporary_url ?? null; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Resources/OrderItems.php b/src/Id/Resources/OrderItems.php index b0dd390..5ef55d6 100644 --- a/src/Id/Resources/OrderItems.php +++ b/src/Id/Resources/OrderItems.php @@ -28,12 +28,19 @@ class OrderItems extends BaseCollection * }> * } $attributes The order data: * - items: Array of order items, each with type, name, description, price, unit, and quantity + * + * Only trusted apps may add items, and only to draft orders. Returns the first created item. + * * @throws Throwable */ public function create(string $orderId, array $attributes = []): OrderItem { - return (new OrderItem(fn() => $this->billable->anikeenId() - ->request('POST', sprintf('v1/orders/%s', $orderId), $attributes))) + return (new OrderItem(fn() => tap($this->billable->anikeenId() + ->request('POST', sprintf('v1/orders/%s/items', $orderId), $attributes), function ($result) { + if (is_array($result->data)) { + $result->data = $result->data[0] ?? null; + } + }))) ->setBillable($this->billable) ->setParent($this->parent); } @@ -48,4 +55,4 @@ class OrderItems extends BaseCollection ->setBillable($this->billable) ->setParent($this->parent); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Resources/PaymentMethod.php b/src/Id/Resources/PaymentMethod.php index 8107c6f..7674805 100644 --- a/src/Id/Resources/PaymentMethod.php +++ b/src/Id/Resources/PaymentMethod.php @@ -10,4 +10,16 @@ use Anikeen\Id\Concerns\HasBillable; class PaymentMethod extends BaseResource { use HasBillable; -} \ No newline at end of file + + /** + * Remove this payment method from the current user. + * + * @throws \Throwable + */ + public function delete(): bool + { + return $this->billable->anikeenId() + ->request('DELETE', sprintf('v1/payment-methods/%s', $this->id)) + ->success(); + } +} \ No newline at end of file diff --git a/src/Id/Resources/SshKey.php b/src/Id/Resources/SshKey.php index 52b726a..c2c01af 100644 --- a/src/Id/Resources/SshKey.php +++ b/src/Id/Resources/SshKey.php @@ -17,8 +17,18 @@ class SshKey extends BaseResource * * @throws Throwable */ + /** + * Rename the SSH key. + * + * @throws Throwable + */ + public function update(string $name): self + { + return (new self(fn() => $this->parent->put(sprintf('v1/ssh-keys/%s', $this->id), ['name' => $name])))->setParent($this->parent); + } + public function delete(): bool { return $this->parent->delete(sprintf('v1/ssh-keys/%s', $this->id))->success(); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Resources/SshKeys.php b/src/Id/Resources/SshKeys.php index 3ad451d..4dd3e4a 100644 --- a/src/Id/Resources/SshKeys.php +++ b/src/Id/Resources/SshKeys.php @@ -29,6 +29,6 @@ class SshKeys extends BaseCollection */ public function find(string $id): ?SshKey { - return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id)))); + return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))))->setParent($this->parent); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php new file mode 100644 index 0000000..8704144 --- /dev/null +++ b/tests/Id/EndpointsTest.php @@ -0,0 +1,210 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + private function sentCall(int $index): string + { + $request = $this->sent[$index]['request']; + + return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); + } + + private function list(array $items = []): array + { + return ['data' => $items, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => count($items)], 'links' => ['next' => null]]; + } + + public function test_charge_uses_the_versioned_endpoint_without_stripe_options(): void + { + $this->respond(201, ['data' => ['id' => 'tx1', 'status' => 'succeeded']]); + + $this->owner()->charge(10, 'pm_1', ['transfer_data' => ['destination' => 'acct_x']]); + + $this->assertSame('POST /api/v1/billing/charge Bearer user-token', $this->sentCall(0)); + $this->assertSame(['amount' => 10, 'payment_method_id' => 'pm_1'], $this->sentJson(0)); + } + + public function test_billing_overview(): void + { + $this->respond(200, ['data' => ['current_charges' => 11.9, 'has_default_payment_method' => true]]); + + $this->assertSame(11.9, $this->owner()->billingOverview()->current_charges); + $this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0)); + } + + public function test_invoice_download_url_is_read_not_updated(): void + { + $this->respond(200, $this->list([['id' => 'inv1']])); + $this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]); + $this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]); + + $url = $this->owner()->invoices()->find('inv1')->getInvoiceTemporaryUrl(); + + $this->assertSame('https://s3/inv1.pdf', $url); + $this->assertSame('GET /api/v1/invoices/inv1 Bearer user-token', $this->sentCall(2)); + } + + public function test_order_items_are_created_on_the_items_endpoint(): void + { + $this->respond(200, $this->list()); // GET v1/orders + $this->respond(200, ['data' => ['id' => 'order1', 'status' => 'draft']]); // GET v1/orders/order1 + $this->respond(200, $this->list()); // GET v1/orders/order1/items + $this->respond(201, ['data' => [['id' => 'item1', 'name' => 'Domain']]]); // POST v1/orders/order1/items + + $created = $this->owner()->orders()->find('order1')->orderItems() + ->create('order1', ['items' => [['type' => 'digital', 'name' => 'Domain', 'price' => 10, 'unit' => 'year', 'units' => 1]]]); + + $this->assertSame('item1', $created->id); + $this->assertSame('POST /api/v1/orders/order1/items Bearer user-token', $this->sentCall(3)); + } + + public function test_create_user_uses_the_app_token_and_admin_endpoint(): void + { + $this->respond(200, ['data' => ['id' => '01JNEW']]); + + app(AnikeenId::class)->createUser(['first_name' => 'Erika', 'last_name' => 'Mustermann', 'email' => 'erika@example.com', 'password' => 'secret-123']); + + $this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0)); + $this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0)); + } + + public function test_email_check_and_socialite_users(): void + { + $this->respond(200, ['data' => ['exists' => true]]); + $this->respond(200, ['data' => ['id' => '01JUSER']]); + + $this->assertTrue(app(AnikeenId::class)->isEmailExisting('erika@example.com')->data->exists); + app(AnikeenId::class)->findOrCreateSocialiteUser('twitch', '123', 'streamer@example.com'); + + $this->assertSame('POST /api/v1/admin/users/check Bearer app-token', $this->sentCall(0)); + $this->assertSame('POST /api/v1/admin/users/socialite Bearer app-token', $this->sentCall(1)); + $this->assertSame(['provider' => 'twitch', 'provider_id' => '123', 'email' => 'streamer@example.com'], $this->sentJson(1)); + } + + public function test_ssh_keys_of_a_user_for_apps_are_not_cached_across_users(): void + { + $this->respond(200, ['data' => [['id' => 'k1', 'name' => 'A']]]); + $this->respond(200, ['data' => [['id' => 'k2', 'name' => 'B']]]); + + $first = app(AnikeenId::class)->sshKeysByUserId('01JA'); + $second = app(AnikeenId::class)->sshKeysByUserId('01JB'); + + $this->assertSame('k1', $first->toArray()[0]->id); + $this->assertSame('k2', $second->toArray()[0]->id); + $this->assertSame('GET /api/v1/client/users/01JA/ssh-keys Bearer app-token', $this->sentCall(0)); + $this->assertSame('GET /api/v1/client/users/01JB/ssh-keys Bearer app-token', $this->sentCall(1)); + } + + public function test_users_manage_their_own_ssh_keys(): void + { + $this->respond(200, $this->list()); + $this->respond(201, ['data' => ['id' => 'k1', 'name' => 'Laptop']]); + $this->respond(200, ['data' => ['id' => 'k1', 'name' => 'Work']]); + $this->respond(204); + + $keys = $this->owner()->sshKeys(); + $key = $keys->create('ssh-ed25519 AAAA… erika@laptop', 'Laptop'); + $renamed = $key->update('Work'); + + $this->assertSame('Work', $renamed->name); + $this->assertTrue($renamed->delete()); + $this->assertSame([ + 'GET /api/v1/ssh-keys Bearer user-token', + 'POST /api/v1/ssh-keys Bearer user-token', + 'PUT /api/v1/ssh-keys/k1 Bearer user-token', + 'DELETE /api/v1/ssh-keys/k1 Bearer user-token', + ], array_map(fn ($i) => $this->sentCall($i), range(0, 3))); + } + + public function test_payment_methods_can_be_deleted(): void + { + $this->respond(200, ['data' => [['id' => 'pm_1']]]); + $this->respond(200, ['data' => ['id' => 'pm_1', 'type' => 'card']]); + $this->respond(204); + + $this->assertTrue($this->owner()->paymentMethods()->find('pm_1')->delete()); + $this->assertSame('DELETE /api/v1/payment-methods/pm_1 Bearer user-token', $this->sentCall(2)); + } + + public function test_products_and_boxes(): void + { + $this->respond(200, ['data' => [['id' => 'p1']]]); + $this->respond(200, [['id' => 'b1']]); + + app(AnikeenId::class)->products(); + app(AnikeenId::class)->boxes(); + + $this->assertStringStartsWith('GET /api/v1/products', $this->sentCall(0)); + $this->assertStringStartsWith('GET /api/v1/boxes', $this->sentCall(1)); + } + + public function test_shipping_labels_use_the_app_token(): void + { + foreach (range(1, 5) as $i) { + $this->respond(200, ['data' => []]); + } + $this->http->append(new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7')); + + $id = app(AnikeenId::class); + $id->shippingLabels(); + $id->shippingLabel('l1'); + $id->issueShippingLabel(['box' => ['id' => 'b1'], 'weight' => 20, 'address' => [], 'billed_user_id' => '01JUSER', 'test' => true]); + $id->shipShippingLabel(['0A', 'FF']); + $id->shippingPriceList(); + $pdf = $id->shippingLabelPdf('l1'); + + $this->assertSame('%PDF-1.7', $pdf); + $this->assertSame([ + 'GET /api/v1/admin/shipping-labels Bearer app-token', + 'GET /api/v1/admin/shipping-labels/l1 Bearer app-token', + 'POST /api/v1/admin/shipping-labels/issue Bearer app-token', + 'POST /api/v1/admin/shipping-labels/ship Bearer app-token', + 'GET /api/v1/admin/shipping-labels/price-list Bearer app-token', + 'GET /api/v1/admin/shipping-labels/l1/pdf Bearer app-token', + ], array_map(fn ($i) => $this->sentCall($i), range(0, 5))); + } + + public function test_orders_and_transactions_for_users(): void + { + $this->respond(201, ['data' => ['id' => 'o1']]); + $this->respond(201, ['data' => ['id' => 'tx1']]); + + app(AnikeenId::class)->createOrderForUser('01JUSER', ['items' => [], 'checkout' => true]); + app(AnikeenId::class)->createTransactionForUser('01JUSER', ['status' => 'succeeded', 'type' => 'deposit', 'amount' => '9.99', 'currency_iso' => 'EUR', 'created_at' => '2026-10-26']); + + $this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sentCall(0)); + $this->assertSame('01JUSER', $this->sentJson(0)['user_id']); + $this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1)); + } + + public function test_order_preview_maps_the_country_into_the_billing_address(): void + { + $this->respond(200, ['data' => ['gross_total' => 11.9]]); + + app(AnikeenId::class)->createOrderPreview(['country_iso' => 'DE', 'items' => []]); + + $this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0)); + } +}