Compare commits

..
Author SHA1 Message Date
Maurice PreußandClaude Opus 5.5 c4a42b4ddc Check out a running service from its start
AppSubscription::checkout() takes an optional $startsAt (needs id-core
with starts_at support on PUT v1/client/subscriptions/{id}/checkout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:03:47 +01:00
envoyr a1707e9189 Merge pull request #3 from anikeen-com/app-subscriptions
App orders: read the orders that bill the app's subscriptions
2026-10-25 18:18:57 +01:00
Maurice PreußandClaude Opus 5.5 820f1b37cc Read the orders that bill the app's subscriptions
$user->appOrders() and app(AnikeenId::class)->appOrders() read
v1/client/orders with the app's own token (scope orders:client):
all(), pending() and find(), paginated transparently, user scoped
when called on a billable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:14:40 +01:00
envoyr 9a6f40ad99 Merge pull request #2 from anikeen-com/app-subscriptions
Bring anikeen/id up to date: app subscriptions, fixed and missing endpoints, Laravel 11-13
2026-10-25 17:25:22 +01:00
Maurice PreußandClaude Opus 5.5 9613d31a8d 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 <noreply@anthropic.com>
2026-10-25 17:18:05 +01:00
Maurice PreußandClaude Opus 5.5 8de737641e App subscriptions: manage a user's subscriptions with the app's own token
- $user->appSubscriptions(): create (idempotency_key), find, all, checkout, revoke, pause,
  resume, update against /v1/client/subscriptions with a client credentials token, so apps don't
  depend on the user's login
- user id from the billable's anikeen_id (AnikeenId::useUserIdField())
- ApiException with status, stable error code, isNotFound/isConflict/isTemporary; network
  errors no longer escape as raw Guzzle exceptions
- rejected app tokens are renewed once; asApp() never changes the shared client's token
- tests (testbench + Guzzle mock) and CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:02:16 +01:00
René PreußandClaude Sonnet 4.6 9c3f9344a0 Add ManagesSocialiteUsers concern and resources for listing third-party connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:47:12 +02:00
Maurice Preuß a7ec7aa908 add set default payment method
Signed-off-by: Maurice Preuß <envoyr@froxlor.org>
2026-05-17 16:46:06 +02:00
envoyr 4555efa80c Update Laravel version requirements in composer.json
Updated Laravel version requirements to include 13.0.
2026-05-12 17:29:14 +02:00
Maurice Preuß 4f78e32af1 update composer.json
Signed-off-by: Maurice Preuß <envoyr@froxlor.org>
2026-05-08 16:27:18 +02:00
envoyr bc9c202c3b update composer.json 2025-09-21 17:15:45 +00:00
envoyr cecf8560ff Merge pull request #1 from anikeen-com/laravel-10
Update composer.json
2025-09-21 19:13:39 +02:00
envoyr 25248e7822 refactor code 2025-09-21 17:12:19 +00:00
envoyr c641ec725d add refund to revoke endpoint 2025-09-21 12:13:56 +00:00
envoyr 8eb0c25582 fix api path 2025-09-19 17:21:23 +00:00
envoyr 30ac4ae4f9 update provider 2025-09-19 16:37:22 +00:00
envoyr e1a6af11a3 introduce new scopes 2025-09-19 15:36:28 +00:00
envoyr bb5df7f115 fix mode
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:28:08 +00:00
envoyr 297404b05d add AllowDynamicProperties attribute
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:22:11 +00:00
envoyr 5ab57dcdfe add staging key
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:17:50 +00:00
envoyr 0f14fa1b4c remove endpoint
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:32:06 +00:00
envoyr 437e78770c fix typo
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:23:00 +00:00
envoyr 0dbb27fc94 update transaction description
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:22:23 +00:00
René Preuß 8232de4003 Update OauthTrait.php 2025-07-30 23:08:13 +02:00
René Preuß ac3e28f67f Update composer.json 2025-07-30 21:52:09 +02:00
envoyr 63e3f0a4a2 add refresh token method
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-02 07:29:43 +02:00
envoyr 1d2119a32b update resources
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-02 06:27:53 +02:00
envoyr 1725ec68de update subscriptions
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-01 18:16:51 +02:00
envoyr dcda4b990e update user provider
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 12:00:34 +02:00
envoyr 937fde603b update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:03:27 +02:00
envoyr 80b1f003b2 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:02:53 +02:00
envoyr aff901de4e update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:02:23 +02:00
envoyr 92eadcca08 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:00:27 +02:00
envoyr 8b874f540c update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 09:08:29 +02:00
envoyr 3bcebd9d45 update subscription, add alias
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 09:02:24 +02:00
envoyr 71663bffd8 small fixes
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 06:43:07 +02:00
envoyr 5b2b3c72cc add serializable
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 04:23:10 +02:00
envoyr 235918f0c0 fix some issues
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 04:12:54 +02:00
envoyr 4b23f6ddbb refactored code
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 03:45:10 +02:00
envoyr 85702fcb2c update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:50:12 +02:00
envoyr 65d4b12006 update identifier
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:47:21 +02:00
envoyr 5d621b7cdc update config
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:46:44 +02:00
envoyr 4d6fe7c325 update provider
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:39:49 +02:00
envoyr d447a88430 add mode
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:33:02 +02:00
envoyr d9a330222b update billable
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 17:26:02 +02:00
envoyr 21946e3a22 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 05:32:44 +02:00
envoyr 1b96b87e1d refactored code
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 05:27:06 +02:00
82 changed files with 3468 additions and 867 deletions

No files matched your search

+22
View File
@@ -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
+241 -73
View File
@@ -9,13 +9,11 @@ PHP Anikeen ID API Client for Laravel 11+
## Table of contents ## Table of contents
1. [Installation](#installation) 1. [Installation](#installation)
2. [Event Listener](#event-listener) 2. [Configuration](#configuration)
3. [Configuration](#configuration) 3. [General](#general)
4. [Implementing Auth](#implementing-auth) 4. [Examples](#examples)
5. [General](#general) 5. [Documentation](#documentation)
6. [Examples](#examples) 6. [Development](#Development)
7. [Documentation](#documentation)
8. [Development](#Development)
## Installation ## Installation
@@ -23,19 +21,9 @@ PHP Anikeen ID API Client for Laravel 11+
composer require anikeen/id composer require anikeen/id
``` ```
## Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider`
```
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen-id', \Anikeen\Id\Socialite\Provider::class);
});
```
## Configuration ## Configuration
Add environmental variables to your `.env` Add environmental variables to your `.env` file:
``` ```
ANIKEEN_ID_KEY= ANIKEEN_ID_KEY=
@@ -43,12 +31,19 @@ ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
``` ```
To switch from `production` to `staging` use following variable:
```
ANIKEEN_ID_MODE=staging
```
You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available.
**Add to `config/services.php`:** Add to `config/services.php` file:
```php ```php
'anikeen' => [ 'anikeen' => [
'mode' => env('ANIKEEN_ID_MODE'),
'client_id' => env('ANIKEEN_ID_KEY'), 'client_id' => env('ANIKEEN_ID_KEY'),
'client_secret' => env('ANIKEEN_ID_SECRET'), 'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
@@ -56,35 +51,144 @@ You will need to add an entry to the services configuration file so that after c
], ],
``` ```
### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
$middleware->web(append: [ public function boot(): void
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, {
]); Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class);
});
}
``` ```
## Implementing Auth ### Registering Middleware
This method should typically be called in the `boot` method of your `AuthServiceProvider` class: Append it to the global middleware stack in your application's `bootstrap/app.php` file:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
```
### Implementing Billable
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
```php
use Anikeen\Id\Billable;
class User extends Authenticatable
{
use Billable;
// Your model code...
}
```
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->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$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;
}
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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:
```php ```php
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdSsoUserProvider;
use Illuminate\Http\Request;
/** public function boot(): void
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{ {
Auth::provider('sso-users', function ($app, array $config) { AnikeenId::useAccessTokenField('anikeen_id_access_token');
return new AnikeenIdSsoUserProvider( AnikeenId::useRefreshTokenField('anikeen_id_refresh_token');
}
```
### Implementing Auth
This method should typically be called in the `boot` method of your `AppServiceProvider` class:
```php
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdUserProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
public function boot(): void
{
Auth::provider('anikeen', function ($app, array $config) {
return new AnikeenIdUserProvider(
$app->make(AnikeenId::class), $app->make(AnikeenId::class),
$app->make(Request::class), $app->make(Request::class),
$config['model'], $config['model'],
$config['fields'] ?? [], $config['fields'] ?? [],
$config['access_token_field'] ?? null
); );
}); });
} }
@@ -100,8 +204,8 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
], ],
'api' => [ 'api' => [
'driver' => 'anikeen-id', 'driver' => 'anikeen',
'provider' => 'sso-users', 'provider' => 'anikeen',
], ],
], ],
``` ```
@@ -115,11 +219,10 @@ reference the provider in the `providers` configuration of your `auth.php` confi
'model' => App\Models\User::class, 'model' => App\Models\User::class,
], ],
'sso-users' => [ 'anikeen' => [
'driver' => 'sso-users', 'driver' => 'anikeen',
'model' => App\Models\User::class, 'model' => App\Models\User::class,
'fields' => ['first_name', 'last_name', 'email'], 'fields' => ['first_name', 'last_name', 'email'],
'access_token_field' => 'sso_access_token',
], ],
], ],
``` ```
@@ -253,12 +356,42 @@ public function retrievingToken(string $grantType, array $attributes): Result
public function createOrderPreview(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 appOrders(): AppOrders
public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
```
### ManagesSocialiteUsers
```php
public function socialiteUsers(): SocialiteUsers
```
### ManagesSshKeys ### ManagesSshKeys
```php ```php
public function sshKeysByUserId(string $sskKeyId): Result public function sshKeysByUserId(string $userId): SshKeys
public function createSshKey(string $publicKey, ?string $name = null): Result
public function deleteSshKey(int $sshKeyId): Result
``` ```
### ManagesUsers ### ManagesUsers
@@ -266,79 +399,114 @@ public function deleteSshKey(int $sshKeyId): Result
```php ```php
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): 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 isEmailExisting(string $email): Result
public function setParent(mixed $parent): self
public function getParent()
``` ```
## Billable ## Billable
### ManagesAddresses
```php
public function addresses(): Addresses
public function hasDefaultBillingAddress(): bool
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesBalance ### ManagesBalance
```php ```php
public function balance(): float public function balance(): float
public function charges(): float public function charges(): float
public function charge(float $amount, string $paymentMethodId, array $options = []): Result 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 ### ManagesInvoices
```php ```php
public function invoices(): Result public function invoices(array $parameters = []): Invoices
public function invoice(string $invoiceId): Result public function setBillable(Billable|Model $billable): self
public function getInvoiceDownloadUrl(string $invoiceId): string public function getBillable(): Billable
``` ```
### ManagesOrders ### ManagesOrders
```php ```php
public function orders(): Result public function orders(array $parameters = []): Orders
public function createOrder(array $attributes = []): Result public function setBillable(Billable|Model $billable): self
public function order(string $orderId): Result public function getBillable(): Billable
public function updateOrder(string $orderId, array $attributes = []): Result
public function checkoutOrder(string $orderId): Result
public function revokeOrder(string $orderId): Result
public function deleteOrder(string $orderId): Result
public function orderItems(string $orderId): Result
public function createOrderItem(string $orderId, array $attributes = []): Result
public function orderItem(string $orderId, string $orderItemId): Result
public function updateOrderItem(string $orderId, string $orderItemId, array $attributes = []): Result
public function deleteOrderItem(string $orderId, string $orderItemId): Result
``` ```
### ManagesPaymentMethods ### ManagesPaymentMethods
```php ```php
public function hasPaymentMethod(): bool public function paymentMethods(): PaymentMethods
public function paymentMethods(): Result public function hasPaymentMethod(): ?PaymentMethod
public function defaultPaymentMethod(): ?PaymentMethod
public function hasDefaultPaymentMethod(): bool public function hasDefaultPaymentMethod(): bool
public function defaultPaymentMethod(): Result public function setDefaultPaymentMethod(string $id): PaymentMethod
public function billingPortalUrl(string $returnUrl, array $options): string public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
public function createSetupIntent(array $options = []): Result 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 ### ManagesSubscriptions
```php ```php
public function subscriptions(): Result public function subscriptions(): Subscriptions
public function subscription(string $subscriptionId): Result public function appSubscriptions(): AppSubscriptions
public function createSubscription(array $attributes): Result public function appOrders(): AppOrders
public function checkoutSubscription(string $subscriptionId): Result public function setBillable(Billable|Model $billable): self
public function revokeSubscription(string $subscriptionId): Result public function getBillable(): Billable
public function resumeSubscription(string $subscriptionId): Result
``` ```
### ManagesTaxation ### ManagesTaxation
```php ```php
public function vat(): float public function vatRate(): float
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesTransactions ### ManagesTransactions
```php ```php
public function transactions(): Result public function transactions(): Transactions
public function createTransaction(array $attributes = []): Result public function setBillable(Billable|Model $billable): self
public function transaction(string $transactionId): Result public function getBillable(): Billable
```
### ManagesUserSshKeys
```php
public function sshKeys(): SshKeys
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
+143 -40
View File
@@ -9,13 +9,11 @@ PHP Anikeen ID API Client for Laravel 11+
## Table of contents ## Table of contents
1. [Installation](#installation) 1. [Installation](#installation)
2. [Event Listener](#event-listener) 2. [Configuration](#configuration)
3. [Configuration](#configuration) 3. [General](#general)
4. [Implementing Auth](#implementing-auth) 4. [Examples](#examples)
5. [General](#general) 5. [Documentation](#documentation)
6. [Examples](#examples) 6. [Development](#Development)
7. [Documentation](#documentation)
8. [Development](#Development)
## Installation ## Installation
@@ -23,19 +21,9 @@ PHP Anikeen ID API Client for Laravel 11+
composer require anikeen/id composer require anikeen/id
``` ```
## Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider`
```
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen-id', \Anikeen\Id\Socialite\Provider::class);
});
```
## Configuration ## Configuration
Add environmental variables to your `.env` Add environmental variables to your `.env` file:
``` ```
ANIKEEN_ID_KEY= ANIKEEN_ID_KEY=
@@ -43,12 +31,19 @@ ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
``` ```
To switch from `production` to `staging` use following variable:
```
ANIKEEN_ID_MODE=staging
```
You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available.
**Add to `config/services.php`:** Add to `config/services.php` file:
```php ```php
'anikeen' => [ 'anikeen' => [
'mode' => env('ANIKEEN_ID_MODE'),
'client_id' => env('ANIKEEN_ID_KEY'), 'client_id' => env('ANIKEEN_ID_KEY'),
'client_secret' => env('ANIKEEN_ID_SECRET'), 'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
@@ -56,35 +51,144 @@ You will need to add an entry to the services configuration file so that after c
], ],
``` ```
### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
$middleware->web(append: [ public function boot(): void
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, {
]); Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class);
});
}
``` ```
## Implementing Auth ### Registering Middleware
This method should typically be called in the `boot` method of your `AuthServiceProvider` class: Append it to the global middleware stack in your application's `bootstrap/app.php` file:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
```
### Implementing Billable
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
```php
use Anikeen\Id\Billable;
class User extends Authenticatable
{
use Billable;
// Your model code...
}
```
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->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$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;
}
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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:
```php ```php
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdSsoUserProvider;
use Illuminate\Http\Request;
/** public function boot(): void
* Register any authentication / authorization services.
*
* @return void
*/
public function boot()
{ {
Auth::provider('sso-users', function ($app, array $config) { AnikeenId::useAccessTokenField('anikeen_id_access_token');
return new AnikeenIdSsoUserProvider( AnikeenId::useRefreshTokenField('anikeen_id_refresh_token');
}
```
### Implementing Auth
This method should typically be called in the `boot` method of your `AppServiceProvider` class:
```php
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdUserProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
public function boot(): void
{
Auth::provider('anikeen', function ($app, array $config) {
return new AnikeenIdUserProvider(
$app->make(AnikeenId::class), $app->make(AnikeenId::class),
$app->make(Request::class), $app->make(Request::class),
$config['model'], $config['model'],
$config['fields'] ?? [], $config['fields'] ?? [],
$config['access_token_field'] ?? null
); );
}); });
} }
@@ -100,8 +204,8 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
], ],
'api' => [ 'api' => [
'driver' => 'anikeen-id', 'driver' => 'anikeen',
'provider' => 'sso-users', 'provider' => 'anikeen',
], ],
], ],
``` ```
@@ -115,11 +219,10 @@ reference the provider in the `providers` configuration of your `auth.php` confi
'model' => App\Models\User::class, 'model' => App\Models\User::class,
], ],
'sso-users' => [ 'anikeen' => [
'driver' => 'sso-users', 'driver' => 'anikeen',
'model' => App\Models\User::class, 'model' => App\Models\User::class,
'fields' => ['first_name', 'last_name', 'email'], 'fields' => ['first_name', 'last_name', 'email'],
'access_token_field' => 'sso_access_token',
], ],
], ],
``` ```
+13 -11
View File
@@ -1,28 +1,30 @@
{ {
"name": "anikeen/id", "name": "anikeen/id",
"description": "PHP AnikeenId API Client for Laravel 10+", "description": "PHP Anikeen ID API client for Laravel 11+",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
"name": "René Preuß", "name": "Ren\u00e9 Preu\u00df",
"email": "rene@anikeen.com" "email": "rene@anikeen.com"
}, },
{ {
"name": "Maurice Preuß", "name": "Maurice Preu\u00df",
"email": "maurice@anikeen.com" "email": "maurice@anikeen.com"
} }
], ],
"require": { "require": {
"php": "^8.0", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^11.0|^12.0", "illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^11.0|^12.0", "illuminate/console": "^11.0|^12.0|^13.0",
"guzzlehttp/guzzle": "^6.3|^7.0", "guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^3.4|^4.0.1", "socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.0" "firebase/php-jwt": "^6.10|^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^8.0|^9.0" "laravel/framework": "^11.0|^12.0|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -49,4 +51,4 @@
} }
} }
} }
} }
+16 -8
View File
@@ -7,6 +7,20 @@ use Anikeen\Id\Billable;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; 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 // Liste der Klassen, die ausgewertet werden sollen
$classes = [ $classes = [
AnikeenId::class, AnikeenId::class,
@@ -51,10 +65,7 @@ $allMarkdown = collect($classes)
// Typ-Hint // Typ-Hint
$typeHint = ''; $typeHint = '';
if ($p->hasType()) { if ($p->hasType()) {
$type = $p->getType(); $typeHint = typeName($p->getType()) . ' ';
$nullable = $type->allowsNull() ? '?' : '';
$name = Arr::last(explode('\\', $type->getName()));
$typeHint = $nullable . $name . ' ';
} }
// Parameter-Name // Parameter-Name
@@ -84,10 +95,7 @@ $allMarkdown = collect($classes)
// Rückgabetyp, falls vorhanden // Rückgabetyp, falls vorhanden
if ($method->hasReturnType()) { if ($method->hasReturnType()) {
$retType = $method->getReturnType(); $decl .= ': ' . typeName($method->getReturnType());
$nullable = $retType->allowsNull() ? '?' : '';
$typeName = Arr::last(explode('\\', $retType->getName()));
$decl .= ': ' . $nullable . $typeName;
} }
return $decl; return $decl;
+14
View File
@@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAi4Ta8r01zKaGSnGi1EiD
uMFWRXBlK4y/ZIfWBpElmS2ygv4mGeP3hT4Flm696Z2UMy56KC+c7CC/PQCiutLk
5NUphyX/t+0QS5Dqpw6FB33fLTNNY7GqSmGIUE4os8XYZRSyDQRgtOgq3R3vJkoV
7zoavTJmSCQlG5Qf0T//iMmzQ+b+6VZm1CJSz5nGx94u1DuXNyP5Epkk0wuHrtwy
kADR2lmydNodJzqpSD+8yQqnAhOZNtNF4qwQ3g13fRvHycBp3G2nlCfOn2g5PmYD
KYBKqvTq4PQH4E+K3pbbMz6zf/T6Dw7zTfksqHR4hqMgN6byRRxmwuBczIumcu9b
y7xbgoIGIVZXgJliALPFi+zTPTN7c8MedFs/xCBHCmzWYTCZfHgr8RPRewD19tCG
NSny5R0vlArpuZCTTgedPESDeGU4eNEddg4yXFzKlpE2nNuvzZ1Ohruc5ETOSU19
RTCBUBkjeL6ESZRd/yKGjbVx4dEYxZdIz4yBl+hZ2ZOIyG7L3zPrccAWrPpG56xr
E5IDBXxLFhaJ5LlyEAGQehB0ShEuCdkr88Xz7ba9PHpGqY83l4//ULrqPIZPAa4Z
E3AWHT1ZtXNPeA4SzZ9Y9Oij4M3chyHxqM0lL3kYP+dstZehTujStfElDIx2Ni10
73tILu4edYS0FxsL19m8gbsCAwEAAQ==
-----END PUBLIC KEY-----
+9 -22
View File
@@ -1,29 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
backupGlobals="false" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupStaticAttributes="false" bootstrap="vendor/autoload.php"
colors="true" colors="true">
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
printerClass="\Codedungeon\PHPUnitPrettyResultPrinter\Printer">
<testsuites> <testsuites>
<testsuite name="Unit"> <testsuite name="Unit">
<directory>tests</directory> <directory>tests</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<filter> <source>
<whitelist processUncoveredFilesFromWhitelist="true"> <include>
<directory suffix=".php">src</directory> <directory suffix=".php">src</directory>
</whitelist> </include>
</filter> </source>
<php> </phpunit>
<env name="ANIKEEN_ID_KEY" value="38"/>
<env name="ANIKEEN_ID_SECRET" value="2goNRF8x37HPVZVaa28ySZGVXJuksvxnxM7TcGzM"/>
<env name="ANIKEEN_ID_BASE_URL" value="https://id.anikeen.com/api/"/>
<env name="CLIENT_ACCESS_TOKEN" value="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxNSIsImp0aSI6IjAxN2QxZDg0Y2MxNjAyZTYxOGZkNjYwZGViZWVjMDY4MTk2YmYzMDk1OGMzY2RiYzBjZmJkZWFjZjFhOTUxODQzZDU1YTk3OGY2YWIxY2YzIiwiaWF0IjoxNjY0NjIzMzA4LjQyODI4MSwibmJmIjoxNjY0NjIzMzA4LjQyODI4NCwiZXhwIjoxNjgwMzQ4MTA4LjQxNjc4MSwic3ViIjoiMzkiLCJzY29wZXMiOlsiYXBpIiwicmVhZF91c2VyIl0sImNsaWVudCI6eyJ0cnVzdGVkIjpmYWxzZX19.vxnzCaU4PpOrNVHa5AnGSS6gX_RCvxIERAnHFhjTrUzRafV9mr2Cvwd-BDVYoUr10wHvxa_TJSYfnAdDuhE-MEyDv13O3dL2XGTtJNa_Rg6L6CQ0JvC3wL-lWPvGPFax9pu-_lqbA3jm5B08hc3-7tq3f2nXcxjhtkqT6TTJv1-RCAppb2HCXiUDAqANzbhyInDjOH2WvFj1OGC_AI03J3W2KRWyeFLtUne8XKPFyr9XGcPuTrqogcuuXLeUt2kcf2bXbuIV1OlgIECrDiP1Ee0F2AzPs27ZVJ2z0R0JbT6AubKhGl5_Qi27cwjOH7hT2dmjcF1mLjzpN1uChLIdSnGSoStH8VzYHnHE2I8G-owW_aadG2UmGdnRY143q6g_28f3WIZNSucBSXkwFeS_t4fylsvpxhpjYJusf5qiEU_X3YbeawYMUCFUkSD2XTIypAqMJLNZQAeJ52eyL-9fln-Bv7n9v7K9G6ieR6Tm0tsJ1PRnaQi7rA1NTFwHoQmIOW9tfMycLzT7bgSoz3ra6Ez2J7ZNuWBZNKS0O-0YfSrAWyWK5U8YRfQuSVzP2VrIU63K6RGU2c284PfQGy11kgKUNQPykirb8p7MDQ8PwrxKaylBnD6hhDgjqEh2bfsr_43DfJA0R58L1HK3BmQnxgap0C77wK1e0yNlABpN28Q"/>
</php>
</phpunit>
+90 -31
View File
@@ -3,7 +3,11 @@
namespace Anikeen\Id; namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPricing; 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\ManagesSshKeys;
use Anikeen\Id\Concerns\ManagesUserBilling;
use Anikeen\Id\Concerns\ManagesUsers; use Anikeen\Id\Concerns\ManagesUsers;
use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException; use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
@@ -20,6 +24,10 @@ class AnikeenId
{ {
use OauthTrait; use OauthTrait;
use ManagesPricing; use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys; use ManagesSshKeys;
use ManagesUsers; use ManagesUsers;
@@ -47,19 +55,16 @@ class AnikeenId
public static bool $unserializesCookies = false; public static bool $unserializesCookies = false;
/** /**
* The base URL for Anikeen ID API. * The key for the access token.
*/ */
private static string $baseUrl = 'https://id.anikeen.com/api/'; private static string $accessTokenField = 'anikeen_id_access_token';
/** /**
* The key for the access token. * The key for the access token.
*/ */
private static string $accessTokenKey = 'anikeen_id_token'; private static string $refreshTokenField = 'anikeen_id_refresh_token';
/** private static string $userIdField = 'anikeen_id';
* The key for the access token.
*/
private static string $refreshTokenKey = 'anikeen_id_refresh_token';
/** /**
* Guzzle is used to make http requests. * Guzzle is used to make http requests.
@@ -91,6 +96,16 @@ class AnikeenId
*/ */
protected ?string $redirectUri = null; protected ?string $redirectUri = null;
/**
* The base URL for Anikeen ID.
*/
protected string $baseUrl = 'https://id.anikeen.com';
/**
* The staging base URL for Anikeen ID.
*/
protected string $stagingBaseUrl = 'https://staging.id.anikeen.com';
/** /**
* Constructor. * Constructor.
*/ */
@@ -105,42 +120,85 @@ class AnikeenId
if ($redirectUri = config('services.anikeen.redirect')) { if ($redirectUri = config('services.anikeen.redirect')) {
$this->setRedirectUri($redirectUri); $this->setRedirectUri($redirectUri);
} }
if (self::getMode() === 'staging' && !config('services.anikeen.base_url')) {
self::setBaseUrl($this->stagingBaseUrl);
}
if ($baseUrl = config('services.anikeen.base_url')) { if ($baseUrl = config('services.anikeen.base_url')) {
self::setBaseUrl($baseUrl); self::setBaseUrl($baseUrl);
} }
$this->client = new Client([ $this->client = new Client([
'base_uri' => self::$baseUrl, 'base_uri' => $this->baseUrl . '/api/',
]); ]);
} }
protected function setBaseUrl(string $baseUrl): void
{
$this->baseUrl = $baseUrl;
}
public function getBaseUrl(): string
{
return rtrim($this->baseUrl, '/');
}
public static function useAccessTokenField(string $accessTokenField): void
{
self::$accessTokenField = $accessTokenField;
}
public static function getAccessTokenField(): string
{
return self::$accessTokenField;
}
/** /**
* @param string $baseUrl * 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).
* @internal only for internal and debug purposes.
*/ */
public static function setBaseUrl(string $baseUrl): void public static function useUserIdField(string $userIdField): void
{ {
self::$baseUrl = $baseUrl; self::$userIdField = $userIdField;
} }
public static function useAccessTokenKey(string $accessTokenKey): void public static function getUserIdField(): string
{ {
self::$accessTokenKey = $accessTokenKey; return self::$userIdField;
} }
public static function getAccessTokenKey(): string /**
* 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 self::$accessTokenKey; return (clone $this)->withToken(app(Contracts\AppTokenRepository::class)->getAccessToken());
} }
public static function useRefreshTokenKey(string $refreshTokenKey): void /**
* Replace the HTTP client, e.g. with one using a Guzzle MockHandler in tests.
*/
public function setHttpClient(Client $client): static
{ {
self::$refreshTokenKey = $refreshTokenKey; $this->client = $client;
return $this;
} }
public static function getRefreshTokenKey(): string public static function getMode(): string
{ {
return self::$refreshTokenKey; return config('services.anikeen.mode') ?: 'production';
}
public static function useRefreshTokenField(string $refreshTokenField): void
{
self::$refreshTokenField = $refreshTokenField;
}
public static function getRefreshTokenField(): string
{
return self::$refreshTokenField;
} }
/** /**
@@ -149,7 +207,7 @@ class AnikeenId
* @param string|null $cookie * @param string|null $cookie
* @return string|static * @return string|static
*/ */
public static function cookie(string $cookie = null): string|static public static function cookie(?string $cookie = null): string|static
{ {
if (is_null($cookie)) { if (is_null($cookie)) {
return static::$cookie; return static::$cookie;
@@ -314,7 +372,7 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result
{ {
if ($paginator !== null) { if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor(); $parameters[$paginator->action] = $paginator->cursor();
@@ -322,7 +380,7 @@ class AnikeenId
try { try {
$response = $this->client->request($method, $path, [ $response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload), 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
'query' => Query::build($parameters), 'query' => Query::build($parameters),
'json' => $payload ?: null, 'json' => $payload ?: null,
]); ]);
@@ -340,14 +398,15 @@ class AnikeenId
* *
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
private function buildHeaders(bool $json = false): array private function buildHeaders(bool $json = false, bool $useClientSecret = false): array
{ {
$headers = [ $headers = [
'Client-ID' => $this->getClientId(), 'Client-ID' => $this->getClientId(),
'Accept' => 'application/json', 'Accept' => 'application/json',
]; ];
if ($this->token) { // public endpoints (products, order preview, …) work without a token
$headers['Authorization'] = 'Bearer ' . $this->token; if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->token) {
$headers['Authorization'] = 'Bearer ' . $bearerToken;
} }
if ($json) { if ($json) {
$headers['Content-Type'] = 'application/json'; $headers['Content-Type'] = 'application/json';
@@ -360,7 +419,7 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function get(string $path, array $parameters = [], Paginator $paginator = null): Result public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->request('GET', $path, null, $parameters, $paginator); return $this->request('GET', $path, null, $parameters, $paginator);
} }
@@ -369,7 +428,7 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function post(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->request('POST', $path, $payload, $parameters, $paginator); return $this->request('POST', $path, $payload, $parameters, $paginator);
} }
@@ -378,7 +437,7 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function put(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->request('PUT', $path, $payload, $parameters, $paginator); return $this->request('PUT', $path, $payload, $parameters, $paginator);
} }
@@ -387,7 +446,7 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function delete(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->request('DELETE', $path, $payload, $parameters, $paginator); return $this->request('DELETE', $path, $payload, $parameters, $paginator);
} }
+1 -1
View File
@@ -15,5 +15,5 @@ trait Delete
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
* @throws GuzzleException * @throws GuzzleException
*/ */
abstract public function delete(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result; abstract public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+1 -1
View File
@@ -15,5 +15,5 @@ trait Get
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
* @throws GuzzleException * @throws GuzzleException
*/ */
abstract public function get(string $path, array $parameters = [], Paginator $paginator = null): Result; abstract public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result;
} }
+1 -1
View File
@@ -15,5 +15,5 @@ trait Post
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
* @throws GuzzleException * @throws GuzzleException
*/ */
abstract public function post(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result; abstract public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+1 -1
View File
@@ -15,5 +15,5 @@ trait Put
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
* @throws GuzzleException * @throws GuzzleException
*/ */
abstract public function put(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result; abstract public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+1 -1
View File
@@ -15,5 +15,5 @@ trait Request
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
* @throws GuzzleException * @throws GuzzleException
*/ */
abstract public function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result; abstract public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null): Result;
} }
+5
View File
@@ -62,4 +62,9 @@ class UserProvider implements Base
{ {
return $this->providerName; return $this->providerName;
} }
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// TODO: Implement rehashPasswordIfRequired() method.
}
} }
+19 -23
View File
@@ -2,57 +2,53 @@
namespace Anikeen\Id; namespace Anikeen\Id;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Concerns\ManagesAddresses;
use Anikeen\Id\Concerns\ManagesBalance; use Anikeen\Id\Concerns\ManagesBalance;
use Anikeen\Id\Concerns\ManagesCountries;
use Anikeen\Id\Concerns\ManagesInvoices; use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders; use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods; use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesSubscriptions; use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation; use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions; use Anikeen\Id\Concerns\ManagesTransactions;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Concerns\ManagesUserSshKeys;
use Anikeen\Id\Helpers\Paginator; use Throwable;
use GuzzleHttp\Exception\GuzzleException;
use stdClass;
trait Billable trait Billable
{ {
use ManagesAddresses;
use ManagesBalance; use ManagesBalance;
use ManagesCountries;
use ManagesInvoices; use ManagesInvoices;
use ManagesOrders; use ManagesOrders;
use ManagesPaymentMethods; use ManagesPaymentMethods;
use ManagesProfile;
use ManagesSubscriptions; use ManagesSubscriptions;
use ManagesTaxation; use ManagesTaxation;
use ManagesTransactions; use ManagesTransactions;
use Request; use ManagesUserSshKeys;
protected stdClass|null $userData = null;
/** /**
* Get the currently authenticated user. * Get the currently authenticated user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
protected function getUserData(): stdClass public function getUserData(): object
{ {
if (!$this->userData) { if (!isset($this->userDataCache)) {
$this->userData = $this->request('GET', 'v1/user')->data; $this->userDataCache = $this->anikeenId()->request('GET', 'v1/user')->data;
} }
return $this->userData; return $this->userDataCache;
} }
/** /**
* Make a request to the Anikeen API. * Get the AnikeenId class.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
protected function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result public function anikeenId(): AnikeenId
{ {
$anikeenId = new AnikeenId(); return app(AnikeenId::class)->withToken($this->{AnikeenId::getAccessTokenField()});
$anikeenId->withToken($this->{AnikeenId::getAccessTokenKey()});
return $anikeenId->request($method, $path, $payload, $parameters, $paginator);
} }
} }
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Contracts\Billable;
use Illuminate\Database\Eloquent\Model;
trait HasBillable
{
public Billable|Model $billable;
public function setBillable(Billable|Model $billable): self
{
$this->billable = $billable;
return $this;
}
public function getBillable(): Billable
{
return $this->billable;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Concerns;
trait HasParent
{
protected mixed $parent;
public function setParent(mixed $parent): self
{
$this->parent = $parent;
return $this;
}
public function getParent()
{
return $this->parent;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Addresses;
use Throwable;
trait ManagesAddresses
{
use HasBillable;
/**
* Get addresses from the current user.
*
* @throws Throwable
*/
public function addresses(): Addresses
{
if (!isset($this->addressesCache)) {
$this->addressesCache = Addresses::builder(fn() => $this->anikeenId()
->request('GET', 'v1/addresses'))
->setBillable($this);
}
return $this->addressesCache;
}
/**
* Check if the current user has a default billing address.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\Addresses::hasDefaultBillingAddress()
*/
public function hasDefaultBillingAddress(): bool
{
return $this->addresses()->hasDefaultBillingAddress();
}
}
+29 -22
View File
@@ -2,20 +2,17 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\Transaction;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Throwable;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesBalance trait ManagesBalance
{ {
use Request; use HasBillable;
/** /**
* Get balance from the current user. * Get balance from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function balance(): float public function balance(): float
{ {
@@ -25,8 +22,7 @@ trait ManagesBalance
/** /**
* Get charges from the current user. * Get charges from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function charges(): float public function charges(): float
{ {
@@ -34,20 +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. * @throws Throwable
* @param string $paymentMethodId Payment method ID.
* @param array $options Additional options for the charge.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/ */
public function charge(float $amount, string $paymentMethodId, array $options = []): Result public function billingOverview(): object
{ {
return $this->request('POST', 'billing/charge', [ return $this->anikeenId()->request('GET', 'v1/billing')->data;
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
'options' => $options,
]);
} }
}
/**
* 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', 'v1/billing/charge', [
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
])))
->setBillable($this);
}
}␍
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Countries;
use Throwable;
trait ManagesCountries
{
use HasBillable;
/**
* Get available countries for the current user.
*
* @throws Throwable
*/
public function countries(): Countries
{
if (!isset($this->countriesCache)) {
$this->countriesCache = Countries::builder(fn() => $this->anikeenId()
->request('GET', 'v1/countries'))
->setBillable($this);
}
return $this->countriesCache;
}
}
+11 -32
View File
@@ -2,47 +2,26 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\Invoices;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Throwable;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesInvoices trait ManagesInvoices
{ {
use Request; use HasBillable;
/** /**
* Get invoices from the current user. * Get invoices from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function invoices(): Result public function invoices(array $parameters = []): Invoices
{ {
return $this->request('GET', 'v1/invoices'); if (!isset($this->invoicesCache)) {
} $this->invoicesCache = Invoices::builder(fn() => $this->anikeenId()
->request('GET', 'v1/invoices', [], $parameters))
->setBillable($this);
}
/** return $this->invoicesCache;
* Get given invoice from the current user.
*
* @param string $invoiceId The invoice ID
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function invoice(string $invoiceId): Result
{
return $this->request('GET', sprintf('v1/invoices/%s', $invoiceId));
}
/**
* Get download url from given invoice.
*
* @param string $invoiceId The invoice ID
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function getInvoiceDownloadUrl(string $invoiceId): string
{
return $this->request('PUT', sprintf('v1/invoices/%s', $invoiceId))->data->download_url;
} }
} }
+11 -244
View File
@@ -2,259 +2,26 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\Orders;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Throwable;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesOrders trait ManagesOrders
{ {
use Request; use HasBillable;
/** /**
* Get orders from the current user. * Get orders from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function orders(): Result public function orders(array $parameters = []): Orders
{ {
return $this->request('GET', 'v1/orders'); if (!isset($this->ordersCache)) {
} $this->ordersCache = Orders::builder(fn() => $this->anikeenId()
->request('GET', 'v1/orders', [], $parameters))
->setBillable($this);
}
/** return $this->ordersCache;
* Creates a new order for the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createOrder(array $attributes = []): Result
{
return $this->request('POST', 'v1/orders', $attributes);
}
/**
* Get given order from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function order(string $orderId): Result
{
return $this->request('GET', sprintf('v1/orders/%s', $orderId));
}
/**
* Update given order from the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* @param string $orderId The order ID.
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* }
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function updateOrder(string $orderId, array $attributes = []): Result
{
return $this->request('PUT', sprintf('v1/orders/%s', $orderId), $attributes);
}
/**
* Checkout given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function checkoutOrder(string $orderId): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/checkout', $orderId));
}
/**
* Revoke given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function revokeOrder(string $orderId): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/revoke', $orderId));
}
/**
* Delete given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteOrder(string $orderId): Result
{
return $this->request('DELETE', sprintf('v1/orders/%s', $orderId));
}
/**
* Get order items from given order.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function orderItems(string $orderId): Result
{
return $this->request('GET', sprintf('v1/orders/%s/items', $orderId));
}
/**
* Create a new order item for given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createOrderItem(string $orderId, array $attributes = []): Result
{
return $this->request('POST', sprintf('v1/orders/%s', $orderId), $attributes);
}
/**
* Get given order item from given order.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function orderItem(string $orderId, string $orderItemId): Result
{
return $this->request('GET', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId));
}
/**
* Update given order item from given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function updateOrderItem(string $orderId, string $orderItemId, array $attributes = []): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId), $attributes);
}
/**
* Delete given order item from given order.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteOrderItem(string $orderId, string $orderItemId): Result
{
return $this->request('DELETE', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId));
} }
} }
+67 -47
View File
@@ -2,86 +2,106 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\PaymentMethod;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Resources\PaymentMethods;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use Throwable;
trait ManagesPaymentMethods trait ManagesPaymentMethods
{ {
use Request; use HasBillable;
/**
* Check if current user has at least one payment method.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function hasPaymentMethod(): bool
{
return $this->paymentMethods()->count() > 0;
}
/** /**
* Get payment methods from the current user. * Get payment methods from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function paymentMethods(): Result public function paymentMethods(): PaymentMethods
{ {
return $this->request('GET', 'v1/payment-methods'); if (!isset($this->paymentMethodsCache)) {
$this->paymentMethodsCache = PaymentMethods::builder(fn() => $this->anikeenId()
->request('GET', 'v1/payment-methods'))
->setBillable($this);
}
return $this->paymentMethodsCache;
}
/**
* Check if current user has at least one payment method.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::hasPaymentMethod()
*/
public function hasPaymentMethod(): ?PaymentMethod
{
return $this->paymentMethods()->hasPaymentMethod();
} }
/** /**
* Get default payment method from the current user. * Get default payment method from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException * @see \Anikeen\Id\Resources\PaymentMethods::defaultPaymentMethod()
*/ */
public function hasDefaultPaymentMethod(): bool public function defaultPaymentMethod(): ?PaymentMethod
{ {
return $this->defaultPaymentMethod()->count() > 0; return $this->paymentMethods()->defaultPaymentMethod();
} }
/** /**
* Get default payment method from the current user. * Check if the current user has a default payment method.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException * @see \Anikeen\Id\Resources\PaymentMethods::hasDefaultPaymentMethod()
*/ */
public function defaultPaymentMethod(): Result public function hasDefaultPaymentMethod(): bool
{ {
return $this->request('GET', 'v1/payment-methods/default'); return $this->paymentMethods()->hasDefaultPaymentMethod();
} }
/** /**
* Get billing portal URL for the current user. * Set the default payment method for the current user.
* *
* @param string $returnUrl The URL to redirect to after the user has finished in the billing portal. * @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::setDefault()
*/
public function setDefaultPaymentMethod(string $id): PaymentMethod
{
unset($this->paymentMethodsCache);
return $this->paymentMethods()->setDefault($id);
}
/**
* Get billing portal URL for the current user.
*
* @param string|null $returnUrl The URL to redirect to after the user has finished in the billing portal.
* @param array $options Additional options for the billing portal. * @param array $options Additional options for the billing portal.
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function billingPortalUrl(string $returnUrl, array $options): string public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
{ {
return $this->request('POST', 'v1/stripe/billing-portal', [ return $this->anikeenId()
'return_url' => $returnUrl, ->request('POST', 'v1/billing/portal', [
'options' => $options, 'return_url' => $returnUrl,
])->data->url; 'options' => $options,
])
->data
->url;
} }
/** /**
* Create a new setup intent. * Create a new setup intent.
* *
* @param array $options Additional options for the setup intent. * @param array $options Additional options for the setup intent.
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function createSetupIntent(array $options = []): Result public function createSetupIntent(array $options = []): Result
{ {
return $this->request('POST', 'v1/payment-methods', [ return $this->anikeenId()
'options' => $options, ->request('POST', 'v1/payment-methods', [
]); 'options' => $options,
]);
} }
} }
+9 -5
View File
@@ -3,9 +3,8 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use Throwable;
trait ManagesPricing trait ManagesPricing
{ {
@@ -29,11 +28,16 @@ trait ManagesPricing
* } $attributes The order data: * } $attributes The order data:
* - country_iso: ISO 3166-1 alpha-2 country code * - country_iso: ISO 3166-1 alpha-2 country code
* - items: Array of order items (each with type, name, price, unit, units, and quantity) * - items: Array of order items (each with type, name, price, unit, units, and quantity)
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function createOrderPreview(array $attributes = []): Result 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); return $this->post('v1/orders/preview', $attributes);
} }
} }␍
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
trait ManagesProducts
{
/**
* Public product catalogue.
*
* @throws Throwable
*/
public function products(): Result
{
return $this->request('GET', 'v1/products');
}
/**
* Shipping boxes (for shipping labels).
*
* @throws Throwable
*/
public function boxes(): Result
{
return $this->request('GET', 'v1/boxes');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Anikeen\Id\Concerns;
use Throwable;
trait ManagesProfile
{
use HasBillable;
/**
* Get the profile url for the current user.
*
* @param string|null $returnUrl The URL to redirect to after the user has completed their profile.
* @param array $options Additional options for the profile URL.
* @return string
* @throws Throwable
*/
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
{
return $this->anikeenId()->request('POST', 'v1/user/profile', [
'return_url' => $returnUrl,
'options' => $options,
])->data->url;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
/**
* Shipping labels (Deutsche Post Internetmarke). Trusted apps only, sent with the app's token.
*/
trait ManagesShippingLabels
{
/**
* Latest shipping labels (100 per page).
*
* @throws Throwable
*/
public function shippingLabels(array $parameters = []): Result
{
return $this->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<string> $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');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\Resources\SocialiteUsers;
use Throwable;
trait ManagesSocialiteUsers
{
use Get;
/**
* Get all socialite connections for the authenticated user.
*
* @throws Throwable
*/
public function socialiteUsers(): SocialiteUsers
{
return SocialiteUsers::builder(fn() => $this->get('v1/user/socialite-users'))
->setParent($this);
}
}
+10 -37
View File
@@ -3,52 +3,25 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Delete;
use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\Resources\SshKeys;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Throwable;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesSshKeys trait ManagesSshKeys
{ {
use Get, Post, Delete; 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 RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function sshKeysByUserId(string $sskKeyId): Result public function sshKeysByUserId(string $userId): SshKeys
{ {
return $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId)); $app = $this->asApp();
}
/** return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
* Creates ssh key for the currently authed user. ->setParent($app);
*
* @param string $publicKey The public key to be added
* @param string|null $name The name of the key (optional)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createSshKey(string $publicKey, string $name = null): Result
{
return $this->post('v1/ssh-keys', [
'public_key' => $publicKey,
'name' => $name,
]);
}
/**
* Deletes a given ssh key for the currently authed user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteSshKey(int $sshKeyId): Result
{
return $this->delete(sprintf('v1/ssh-keys/%s', $sshKeyId));
} }
} }
+24 -79
View File
@@ -2,102 +2,47 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Result; use Anikeen\Id\Resources\Subscriptions;
use GuzzleHttp\Exception\GuzzleException; use Throwable;
trait ManagesSubscriptions trait ManagesSubscriptions
{ {
use Request; use HasBillable;
/** /**
* Get subscriptions from the current user. * Get subscriptions from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function subscriptions(): Result public function subscriptions(): Subscriptions
{ {
return $this->request('GET', 'v1/subscriptions'); if (!isset($this->subscriptionsCache)) {
$this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/subscriptions'))
->setBillable($this);
}
return $this->subscriptionsCache;
} }
/** /**
* Get given subscription from the current user. * 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
* @param string $subscriptionId The subscription ID. * login may have expired.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/ */
public function subscription(string $subscriptionId): Result public function appSubscriptions(): AppSubscriptions
{ {
return $this->request('GET', sprintf('v1/subscriptions/%s', $subscriptionId)); return new AppSubscriptions($this);
} }
/** /**
* Create a new subscription for the current user. * The user's orders that bill the app's subscriptions, read with the app's own token
* * (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
* @param array{
* name: null,
* description: string,
* unit: string,
* price: float,
* vat: null|float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* } $attributes The subscription data:
* - name: The name
* - description: The description
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit
* - vat: The VAT (optional)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/ */
public function createSubscription(array $attributes): Result public function appOrders(): AppOrders
{ {
return $this->request('POST', 'v1/subscriptions', $attributes); return new AppOrders($this);
} }
}␍
/**
* Force given subscription to check out (trusted apps only).
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function checkoutSubscription(string $subscriptionId): Result
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/checkout', $subscriptionId));
}
/**
* Revoke a given running subscription from the current user.
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function revokeSubscription(string $subscriptionId): Result
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/revoke', $subscriptionId));
}
/**
* Resume a given running subscription from the current user.
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function resumeSubscription(string $subscriptionId): Result
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/resume', $subscriptionId));
}
}
+5 -8
View File
@@ -2,22 +2,19 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Throwable;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesTaxation trait ManagesTaxation
{ {
use Request; use HasBillable;
/** /**
* Get VAT for the current user. * Get VAT for the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function vat(): float public function vatRate(): float
{ {
return $this->getUserData()->vat; return $this->getUserData()->vat_rate;
} }
} }
+11 -33
View File
@@ -2,48 +2,26 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request; use Anikeen\Id\Resources\Transactions;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Throwable;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait ManagesTransactions trait ManagesTransactions
{ {
use Request; use HasBillable;
/** /**
* Get transactions from the current user. * Get transactions from the current user.
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function transactions(): Result public function transactions(): Transactions
{ {
return $this->request('GET', 'v1/transactions'); if (!isset($this->transactionsCache)) {
} $this->transactionsCache = Transactions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/transactions'))
->setBillable($this);
}
/** return $this->transactionsCache;
* Create a new transaction for the current user.
*
* @param array $attributes The attributes for the transaction.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @todo Add type hinting for the attributes array.
*/
public function createTransaction(array $attributes = []): Result
{
return $this->request('POST', 'v1/transactions', $attributes);
}
/**
* Get given transaction from current current user.
*
* @param string $transactionId The transaction ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function transaction(string $transactionId): Result
{
return $this->request('GET', sprintf('v1/transactions/%s', $transactionId));
} }
} }
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result;
use Throwable;
/**
* Orders and payments on behalf of a user. Trusted apps only, sent with the app's token.
*/
trait ManagesUserBilling
{
/**
* Orders of all users that bill the app's subscriptions (e.g. ->pending() for unpaid ones).
*/
public function appOrders(): AppOrders
{
return new AppOrders();
}
/**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
* default payment method (402 when declined; the order stays pending and is retried).
*
* @param string $userId The user's Anikeen ID.
* @param array{
* group?: string,
* billing_address_id?: string,
* billing_address?: array,
* shipping_address_id?: string,
* shipping_address?: array,
* items: array<array{type: string, name: string, description?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
* 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]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\SshKeys;
use Throwable;
trait ManagesUserSshKeys
{
use HasBillable;
/**
* SSH keys of the current user (user token).
*
* @throws Throwable
*/
public function sshKeys(): SshKeys
{
$anikeenId = $this->anikeenId();
return SshKeys::builder(fn() => $anikeenId->get('v1/ssh-keys'))->setParent($anikeenId);
}
}
+51 -10
View File
@@ -5,19 +5,18 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use Throwable;
trait ManagesUsers trait ManagesUsers
{ {
use Get, Post; use Get, Post;
use HasParent;
/** /**
* Get currently authed user with Bearer Token * Get currently authed user with Bearer Token
* *
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function getAuthedUser(): Result public function getAuthedUser(): Result
{ {
@@ -39,24 +38,66 @@ trait ManagesUsers
* - username: The username (optional) * - username: The username (optional)
* - email: The email (required) * - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided) * - password: The password (optional, can be reset by the user if not provided)
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function createUser(array $attributes): Result 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));
}
/**
* Refreshes the access token using the refresh token.
*/
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
{
return $this->post('../oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $storedRefreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => $scope,
]);
} }
/** /**
* Checks if the given email exists. * Checks if the given email exists.
* *
* @param string $email The email to check. * @param string $email The email to check.
* @throws RequestRequiresClientIdException * @throws Throwable
* @throws GuzzleException
*/ */
public function isEmailExisting(string $email): Result 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, 'email' => $email,
]); ]);
} }
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Anikeen\Id\Contracts;
use Anikeen\Id\AnikeenId;
interface Billable
{
public function getUserData(): object;
public function anikeenId(): AnikeenId;
}
+28 -4
View File
@@ -6,11 +6,35 @@ class Scope
{ {
const USER = 'user'; const USER = 'user';
const USER_READ = 'user:read'; const USER_READ = 'user:read';
const ORDERS = 'orders';
const ORDERS_READ = 'orders:read'; const ADDRESSES = 'addresses';
const PRODUCTS = 'products'; const ADDRESSES_READ = 'addresses:read';
const PRODUCTS_READ = 'products:read';
const BILLING = 'billing'; const BILLING = 'billing';
const BILLING_READ = 'billing:read'; const BILLING_READ = 'billing:read';
const BILLING_CLIENT = 'billing:client';
const INVOICES = 'invoices';
const INVOICES_READ = 'invoices:read';
const INVOICES_CLIENT = 'invoices:client';
const ORDERS = 'orders';
const ORDERS_READ = 'orders:read';
const ORDERS_CLIENT = 'orders:client';
const PAYMENT_METHODS = 'payment-methods';
const PAYMENT_METHODS_READ = 'payment-methods:read';
const SUBSCRIPTIONS = 'subscriptions';
const SUBSCRIPTIONS_READ = 'subscriptions:read';
const SUBSCRIPTIONS_CLIENT = 'subscriptions:client';
const TRANSACTIONS = 'transactions';
const TRANSACTIONS_READ = 'transactions:read';
const TRANSACTIONS_CLIENT = 'transactions:client';
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
const ADMIN = 'admin'; const ADMIN = 'admin';
} }
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Anikeen\Id\Exceptions;
use Throwable;
/**
* A request to the ID service failed. `status` is the HTTP status (0 when the service could not
* be reached), `error` the service's stable error code when it sent one (e.g. "already_cancelled").
*/
class ApiException extends ResourceException
{
public function __construct(
string $message,
public readonly int $status = 0,
public readonly ?string $error = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status, $previous);
}
public function isNotFound(): bool
{
return $this->status === 404;
}
public function isConflict(): bool
{
return $this->status === 409;
}
/**
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/
public function isTemporary(): bool
{
return $this->status === 0 || $this->status >= 500 || $this->status === 429;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class CollectionException extends Exception
{
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class ResourceException extends Exception
{
//
}
+4 -1
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Helpers; namespace Anikeen\Id\Helpers;
use Anikeen\Id\AnikeenId;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key; use Firebase\JWT\Key;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
@@ -30,6 +31,8 @@ class JwtParser
private function getOauthPublicKey(): bool|string private function getOauthPublicKey(): bool|string
{ {
return file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key'); return AnikeenId::getMode() === 'staging'
? file_get_contents(dirname(__DIR__, 3) . '/oauth-public.staging.key')
: file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key');
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@ use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use stdClass; use stdClass;
abstract class CheckCredentials abstract class CheckCredentials extends UseParameters
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class CheckForAnyScope class CheckForAnyScope extends UseParameters
{ {
/** /**
* Handle the incoming request. * Handle the incoming request.
+1 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class CheckScopes class CheckScopes extends UseParameters
{ {
/** /**
* Handle the incoming request. * Handle the incoming request.
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Http\Middleware;
abstract class UseParameters
{
/**
* Specify the parameters for the middleware.
*
* @param string[]|string $param
*/
public static function using(array|string $param, string ...$params): string
{
if (is_array($param)) {
return static::class . ':' . implode(',', $param);
}
return static::class . ':' . implode(',', [$param, ...$params]);
}
}
+3 -5
View File
@@ -23,13 +23,11 @@ trait OauthTrait
], ],
]); ]);
$result = new Result($response, null); $result = new Result($response, null, $this);
} catch (RequestException $exception) { } catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception); $result = new Result($exception->getResponse(), $exception, $this);
} }
$result->anikeenId = $this;
return $result; return $result;
} }
} }
@@ -43,7 +43,7 @@ class AnikeenIdServiceProvider extends ServiceProvider
protected function registerGuard(): void protected function registerGuard(): void
{ {
Auth::resolved(function ($auth) { Auth::resolved(function ($auth) {
$auth->extend('anikeen-id', function ($app, $name, array $config) { $auth->extend('anikeen', function ($app, $name, array $config) {
return tap($this->makeGuard($config), function ($guard) { return tap($this->makeGuard($config), function ($guard) {
$this->app->refresh('request', $guard, 'setRequest'); $this->app->refresh('request', $guard, 'setRequest');
}); });
@@ -1,117 +0,0 @@
<?php
namespace Anikeen\Id\Providers;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class AnikeenIdSsoUserProvider implements UserProvider
{
private AnikeenId $anikeenId;
private ?string $accessTokenField = null;
private array $fields;
private string $model;
private Request $request;
public function __construct(
AnikeenId $anikeenId,
Request $request,
string $model,
array $fields,
?string $accessTokenField = null
)
{
$this->request = $request;
$this->model = $model;
$this->fields = $fields;
$this->accessTokenField = $accessTokenField;
$this->anikeenId = $anikeenId;
}
public function retrieveById(mixed $identifier): Builder|Model|null
{
$model = $this->createModel();
$token = $this->request->bearerToken();
$user = $this->newModelQuery($model)
->where($model->getAuthIdentifierName(), $identifier)
->first();
// Return user when found
if ($user) {
// Update access token when updated
if ($this->accessTokenField) {
$user[$this->accessTokenField] = $token;
if ($user->isDirty()) {
$user->save();
}
}
return $user;
}
// Create new user
$this->anikeenId->setToken($token);
$result = $this->anikeenId->getAuthedUser();
if (!$result->success()) {
return null;
}
$attributes = Arr::only((array)$result->data(), $this->fields);
$attributes[$model->getAuthIdentifierName()] = $result->data->id;
if ($this->accessTokenField) {
$attributes[$this->accessTokenField] = $token;
}
return $this->newModelQuery($model)->create($attributes);
}
/**
* Create a new instance of the model.
*/
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class;
}
/**
* Get a new query builder for the model instance.
*/
protected function newModelQuery(?Model $model = null): Builder
{
return is_null($model)
? $this->createModel()->newQuery()
: $model->newQuery();
}
public function retrieveByToken($identifier, $token)
{
return null;
}
public function updateRememberToken(Authenticatable $user, $token)
{
// void
}
public function retrieveByCredentials(array $credentials)
{
return null;
}
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
return false;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace Anikeen\Id\Providers;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class AnikeenIdUserProvider implements UserProvider
{
private ?string $accessTokenField;
public function __construct(
private AnikeenId $anikeenId,
private Request $request,
private string $model,
private array $fields = []
) {
$this->accessTokenField = AnikeenId::getAccessTokenField();
}
/**
* {@inheritDoc}
*/
public function retrieveByToken($identifier, $token): ?Authenticatable
{
// Token from request (if not already pass from $token):
$token = $token ?: $this->request->bearerToken();
if (! $token) {
return null;
}
// Set token in SSO client and request user info
$this->anikeenId->setToken($token);
$result = $this->anikeenId->getAuthedUser();
if (! $result->success()) {
return null;
}
// Only the desired fields
$data = Arr::only((array)$result->data(), $this->fields);
// Primary key (e.g. $user->id)
$pk = $this->createModel()->getAuthIdentifierName();
$data[$pk] = $result->data->id;
// Fill in access token field, if available
if ($this->accessTokenField) {
$data[$this->accessTokenField] = $token;
}
// Local eloquent model: either find or create a new one
/** @var Model $modelInstance */
$modelInstance = $this->newModelQuery()
->firstOrNew([$pk => $data[$pk]]);
$modelInstance->fill($data);
$modelInstance->save();
return $modelInstance;
}
/**
* {@inheritDoc}
*/
public function updateRememberToken(Authenticatable $user, $token): void
{
// no-op
}
/**
* {@inheritDoc}
*/
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
return null;
}
/**
* {@inheritDoc}
*/
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function retrieveById($identifier): ?Authenticatable
{
return $this->newModelQuery()
->where($this->createModel()->getAuthIdentifierName(), $identifier)
->first();
}
/**
* {@inheritDoc}
*/
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void
{
// no-op
}
/**
* @return Model
*/
protected function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class;
}
/**
* @return Builder
*/
protected function newModelQuery(): Builder
{
return $this->createModel()->newQuery();
}
}
+8
View File
@@ -32,6 +32,14 @@ class AppTokenRepository implements Repository
return $this->requestFreshAccessToken('*'); 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 * @throws RequestFreshAccessTokenException
*/ */
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
/**
* @property string $id
* @property null|string $company_name
* @property null|string $first_name
* @property null|string $last_name
* @property null|string $address_2
* @property null|string $address
* @property null|string $house_number
* @property null|string $postal_code
* @property null|string $city
* @property null|string $state
* @property string $country_iso
* @property null|string $phone_number
* @property null|string $email
* @property bool $primary
* @property bool $primary_billing_address
*/
class Address extends BaseResource
{
use HasBillable;
/**
* Update given address from the current user.
*
* VAT is calculated based on the billing address and shown in the address response.
*
* @param array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address_2: null|string,
* address: string,
* house_number: null|string,
* postal_code: string,
* city: string,
* state: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string,
* primary: bool,
* primary_billing_address: bool
* } $attributes The address data:
* - company_name: Company name (optional)
* - first_name: First name (required when set)
* - last_name: Last name (required when set)
* - address: Address line 1 (e.g. street address, P.O. Box, etc.)
* - address_2: Address line 2 (optional, e.g. apartment number, c/o, etc.)
* - house_number: House number (optional)
* - postal_code: Postal code (required when set)
* - city: City (required when set)
* - state: State (optional, e.g. province, region, etc.)
* - country_iso: Country ISO code (required when set, e.g. US, CA, etc.)
* - phone_number: Phone number (optional)
* - email: Email address (optional, e.g. for delivery notifications)
* - primary: Whether this address should be the primary address (optional)
* - primary_billing_address: Whether this address should be the primary billing address (optional)
* @throws Throwable
*/
public function update(array $attributes = []): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/addresses/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Delete given address from the current user.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/addresses/%s', $this->id))->success();
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class Addresses extends BaseCollection
{
use HasBillable;
/**
* Creates a new address for the current user.
*
* @param array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* postal_code: string,
* city: string,
* state: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string,
* primary: bool,
* primary_billing_address: bool
* } $attributes The address data:
*   - company_name: Company name (optional)
*   - first_name: First name
*   - last_name: Last name
*   - address: Address line 1 (e.g. street address, P.O. Box, etc.)
*   - address_2: Address line 2 (optional, e.g. apartment number, c/o, etc.)
*   - house_number: House number (optional)
*   - postal_code: Postal code
*   - city: City
*   - state: State (optional, e.g. province, region, etc.)
*   - country_iso: Country ISO code (e.g. US, CA, etc.)
*   - phone_number: Phone number (optional)
*   - email: Email address (optional, e.g. for delivery notifications)
*   - primary: Whether this address should be the primary address (optional)
*   - primary_billing_address: Whether this address should be the primary billing address (optional)
* @throws Throwable
*/
public function create(array $attributes = []): Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('POST', 'v1/addresses', $attributes)))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/addresses/%s', $id))))
->setBillable($this->billable);
}
/**
* Get default address from the current user.
*
* @throws Throwable
*/
public function defaultBillingAddress(): Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/addresses/%s', $this->billable->getUserData()->billing_address_id))))
->setBillable($this->billable);
}
/**
* Check if the current user has a default billing address.
*
* @throws Throwable
*/
public function hasDefaultBillingAddress(): bool
{
return $this->billable->getUserData()->billing_address_id !== null;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
/**
* Orders that bill the app's subscriptions, read with the app's own token (trusted apps,
* scope orders:client) - of one user ($user->appOrders()) or of all users
* (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called.
*
* Each order is an object with id, user_id, group, status (processing|pending|completed|cancelled),
* is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at,
* payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null).
*/
class AppOrders
{
public function __construct(protected Billable|Model|null $billable = null)
{
}
/**
* All matching orders, oldest first.
*
* @param array{status?: string, group?: string} $filters
* @return list<object>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$orders = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/orders', null, [
...$filters,
...($this->billable ? ['user_id' => $this->userId()] : []),
'per_page' => 100,
'page' => $page,
]);
array_push($orders, ...$result->data);
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $orders;
}
/**
* Closed orders that aren't paid (yet), oldest first.
*
* @return list<object>
*
* @throws ApiException
*/
public function pending(array $filters = []): array
{
return $this->all([...$filters, 'status' => 'pending']);
}
/**
* @throws ApiException
*/
public function find(string $id): ?object
{
try {
$order = AppRequest::send('GET', sprintf('v1/client/orders/%s', $id))->data;
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return !$this->billable || $order->user_id === $this->userId() ? $order : null;
}
private function userId(): string
{
return (new AppSubscriptions($this->billable))->userId();
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $vat_rate
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status "active" or "inactive"
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
* @property bool $is_past_due
* @property int $failed_renewal_retries
* @property ?array $payload
* @property ?string $idempotency_key
*/
class AppSubscription implements JsonSerializable
{
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* Change a subscription that wasn't checked out yet.
*
* @throws ApiException
*/
public function update(array $attributes): self
{
return $this->send('PUT', '', $attributes);
}
/**
* Activate the subscription and bill its first period.
*
* $startsAt (in the past) books a service that is already running, e.g. a domain registered
* earlier: the first period runs from $startsAt, is billed now and renews when it ends.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", …
*/
public function checkout(DateTimeInterface|string|null $startsAt = null): self
{
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Subscriptions of a user, managed with the app's own token (client credentials, scope
* `subscriptions:client`) instead of the user's token, so they keep working when the user's
* login expired. The app has to be trusted by the ID service. Nothing is requested until a
* method is called.
*/
class AppSubscriptions
{
public function __construct(protected Billable|Model $billable)
{
}
/**
* The user's Anikeen ID (see AnikeenId::useUserIdField()).
*/
public function userId(): string
{
$userId = $this->billable->{AnikeenId::getUserIdField()} ?? null;
if (empty($userId)) {
throw new InvalidArgumentException(sprintf('The billable has no Anikeen ID in [%s].', AnikeenId::getUserIdField()));
}
return (string) $userId;
}
/**
* Create a subscription for the user (it is billed only after checkout()).
*
* @param array{
* group?: null|string,
* name: string,
* description?: null|string,
* unit: string,
* price: float|string,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string,
* webhook_url?: null|string,
* webhook_secret?: null|string,
* idempotency_key?: null|string
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
* subscription created by the first one instead of a duplicate.
*
* @throws ApiException
*/
public function create(array $attributes): AppSubscription
{
return new AppSubscription(AppRequest::send('POST', 'v1/client/subscriptions', [
...$attributes,
'user_id' => $this->userId(),
])->data);
}
/**
* Find one of the user's subscriptions; null when it doesn't exist (or belongs to someone else).
*
* @throws ApiException
*/
public function find(string $id): ?AppSubscription
{
try {
$subscription = new AppSubscription(AppRequest::send('GET', sprintf('v1/client/subscriptions/%s', $id))->data);
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return $subscription->user_id === $this->userId() ? $subscription : null;
}
/**
* All of the user's subscriptions, optionally filtered.
*
* @param array{group?: string, active?: bool, cancelled?: bool} $filters
* @return list<AppSubscription>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$subscriptions = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/subscriptions', null, [
...array_map(fn ($value) => is_bool($value) ? (int) $value : $value, $filters),
'user_id' => $this->userId(),
'per_page' => 100,
'page' => $page,
]);
foreach ($result->data as $data) {
$subscriptions[] = new AppSubscription($data);
}
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $subscriptions;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result;
use Closure;
use GuzzleHttp\Psr7\Response;
use JsonSerializable;
use Throwable;
/**
* @property bool $success
* @property mixed $data
* @property int $total
* @property int $status
* @property null|array $links
* @property null|array $meta
* @property null|Paginator $paginator
* @property AnikeenId $anikeenId
* @property Response $response
* @property null|Throwable $exception
*/
#[\AllowDynamicProperties]
abstract class BaseCollection implements JsonSerializable
{
private Closure $callable;
public ?Result $result = null;
/**
* @throws CollectionException
*/
protected function __construct(callable $callable)
{
$this->result = $callable();
if (!$this->result->success()) {
throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
}
}
/**
* @throws CollectionException
*/
public static function builder(callable $callable): static
{
return new static($callable, false);
}
/**
* Returns the collection of resources as an array.
*/
public function toArray(): array
{
return (array)$this->result->data;
}
/**
* Returns the collection of resources as a JSON string.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Returns the collection of resources.
*/
public function paginate(): Result
{
return $this->result;
}
/**
* Returns the Resource based on the ID.
*/
abstract public function find(string $id): ?BaseResource;
public function __get(string $name)
{
return $this->result->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->result->{$name});
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result;
use JsonSerializable;
#[\AllowDynamicProperties]
abstract class BaseResource implements JsonSerializable
{
public Result $result;
/**
* @throws ResourceException
*/
public function __construct(callable $callable)
{
$this->result = $callable();
if (!$this->result->success()) {
throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
}
foreach ($this->result->data as $key => $value) {
if (!property_exists($this, $key)) {
$this->{$key} = $value;
}
}
}
/**
* Returns the collection of resources as a JSON string.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Returns the collection of resources as an array.
*/
public function toArray(): array
{
return (array)$this->result->data;
}
public function __get(string $name)
{
return null;
}
public function __isset(string $name): bool
{
return false;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Countries extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?BaseResource
{
return null;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
/**
* @property string $id
*/
class Invoice extends BaseResource
{
use HasBillable;
/**
* Get temporary download url from given invoice.
*
* @throws Throwable
*/
public function getInvoiceTemporaryUrl(): ?string
{
return $this->billable->anikeenId()
->request('GET', sprintf('v1/invoices/%s', $this->id))
->data
->temporary_url ?? null;
}
}␍
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Invoices extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?Invoice
{
return (new Invoice(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/invoices/%s', $id))))
->setBillable($this->billable);
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
/**
* @property string $id
*/
class Order extends BaseResource
{
use HasBillable;
/**
* Update given order from the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* The billing and shipping addresses are each persisted as standalone Address entities
* in the database, but are also embedded (deep-copied) into the Order object itself
* rather than merely referenced. This guarantees that the order retains its own snapshot
* of both addresses for future reference.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* }
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* @throws Throwable
*/
public function update(array $attributes = []): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Checkout given order from the current user.
*
* @throws Throwable
*/
public function checkout(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/checkout', $this->id))))
->setBillable($this->billable);
}
/**
* Revoke given order from the current user.
*
* @throws Throwable
*/
public function revoke(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/revoke', $this->id))))
->setBillable($this->billable);
}
/**
* Delete given order from the current user.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
}
/**
* Get order items from given order.
*
* @throws Throwable
*/
public function orderItems(array $parameters = []): OrderItems
{
return OrderItems::builder(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/items', $this->id), [], $parameters))
->setBillable($this->billable)
->setParent($this);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
*/
class OrderItem extends BaseResource
{
use HasBillable;
use HasParent;
/**
* Update given order item from given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws Throwable
*/
public function update(array $attributes = []): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/items/%s', $this->parent->id, $this->id), $attributes)))
->setBillable($this->billable)
->setParent($this->parent);
}
/**
* Delete given order item from given order.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/orders/%s/items/%s', $this->parent->id, $this->id))->success();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
class OrderItems extends BaseCollection
{
use HasBillable;
use HasParent;
/**
* Create a new order item for given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $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() => 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);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?OrderItem
{
return (new OrderItem(fn() => $this->parent->anikeenId()
->request('GET', sprintf('v1/orders/%s/items/%s', $this->parent->id, $id))))
->setBillable($this->billable)
->setParent($this->parent);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class Orders extends BaseCollection
{
use HasBillable;
/**
* Creates a new order for the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* The billing and shipping addresses are each persisted as standalone Address entities
* in the database, but are also embedded (deep-copied) into the Order object itself
* rather than merely referenced. This guarantees that the order retains its own snapshot
* of both addresses for future reference.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
* @throws Throwable
*/
public function create(array $attributes = []): Order
{
return (new Order(fn() => $this->billable->anikeenId()
->request('POST', 'v1/orders', $attributes)))
->setBillable($this->billable);
}
/**
* Get given order from the current user.
*
* @throws Throwable
*/
public function find(string $id): ?Order
{
return (new Order(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s', $id))))
->setBillable($this->billable);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
/**
* @property string $id
*/
class PaymentMethod extends BaseResource
{
use HasBillable;
/**
* 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();
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class PaymentMethods extends BaseCollection
{
use HasBillable;
/**
* Check if current user has at least one payment method.
*
* @throws Throwable
*/
public function hasPaymentMethod(): bool
{
return $this->result->count() > 0;
}
/**
* Check if the current user has a default payment method.
*
* @throws Throwable
*/
public function hasDefaultPaymentMethod(): bool
{
return $this->defaultPaymentMethod()?->id !== null;
}
/**
* Get default payment method from the current user.
*
* @throws Throwable
*/
public function defaultPaymentMethod(): PaymentMethod
{
if (!isset($this->defaultPaymentMethodCache)) {
$this->defaultPaymentMethodCache = (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('GET', 'v1/payment-methods/default')))
->setBillable($this->billable);
}
return $this->defaultPaymentMethodCache;
}
/**
* Set the default payment method for the current user.
*
* @throws Throwable
*/
public function setDefault(string $id): PaymentMethod
{
unset($this->defaultPaymentMethodCache);
return (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/payment-methods/%s/default', $id))))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*
* @throws Throwable
*/
public function find(string $id): ?PaymentMethod
{
return (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/payment-methods/%s', $id))))
->setBillable($this->billable);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Anikeen\Id\Resources;
/**
* @property string $id
* @property string $user_id
* @property string $driver
* @property string $uid
* @property string|null $display_name
* @property string|null $avatar
* @property string|null $expires_at
* @property string $created_at
* @property string $updated_at
*/
class SocialiteUser extends BaseResource
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
class SocialiteUsers extends BaseCollection
{
use HasParent;
public function find(string $id): ?SocialiteUser
{
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
*/
class SshKey extends BaseResource
{
use HasParent;
/**
* Deletes a given ssh key for the currently authed user.
*
* @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();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
class SshKeys extends BaseCollection
{
use HasParent;
/**
* Creates ssh key for the currently authed user.
*
* @param string $publicKey The public key to be added
* @param string|null $name The name of the key (optional)
* @throws Throwable
*/
public function create(string $publicKey, ?string $name = null): SshKey
{
return (new SshKey(fn() => $this->parent->post('v1/ssh-keys', [
'public_key' => $publicKey,
'name' => $name,
])))->setParent($this->parent);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?SshKey
{
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))))->setParent($this->parent);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable;
/**
* @property string $id
* @property string $name
* @property string $description
* @property string $unit
* @property float $price
* @property float $vat_rate
* @property array $payload
* @property string $ends_at
* @property string $webhook_url
* @property string $webhook_secret
*/
class Subscription extends BaseResource
{
use HasBillable;
/**
* Update a given subscription from the current user.
*
* @param array{
* group: string,
* name: string,
* description: null|string,
* unit: string,
* price: float,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name (required when set)
* - description: The description (optional)
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit (required when set)
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws Throwable
*/
public function update(array $attributes): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Force given subscription to check out (trusted apps only).
*
* @throws Throwable
*/
public function checkout(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id))))
->setBillable($this->billable);
}
/**
* Revoke a given running subscription from the current user.
*
* @throws Throwable
*/
public function revoke(bool $refund = false): self
{
$attributes = [
'refund' => $refund,
];
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Pause a given running subscription from the current user.
*
* @throws Throwable
*/
public function pause(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id))))
->setBillable($this->billable);
}
/**
* Resume a given running subscription from the current user.
*
* @throws Throwable
*/
public function resume(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/resume', $this->id))))
->setBillable($this->billable);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ResourceException;
use Throwable;
class Subscriptions extends BaseCollection
{
use HasBillable;
/**
* Create a new subscription for the current user.
*
* @param array{
* group: null|string,
* name: string,
* description: null|string,
* unit: string,
* price: float,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name
* - description: The description (optional)
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws Throwable
*/
public function create(array $attributes): Subscription
{
return (new Subscription(fn() => $this->billable->anikeenId()
->request('POST', 'v1/subscriptions', $attributes)))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*
* @throws ResourceException
*/
public function find(string $id): ?Subscription
{
return (new Subscription(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/subscriptions/%s', $id))))
->setBillable($this->billable);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Transaction extends BaseResource
{
use HasBillable;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Transactions extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?Transaction
{
return (new Transaction(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/transactions/%s', $id))))
->setBillable($this->billable);
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ class Result
/** /**
* Rate limit info from headers * Rate limit info from headers
*/ */
public function rateLimit(string $key = null): array|int|string|null public function rateLimit(?string $key = null): array|int|string|null
{ {
if (!$this->response) { if (!$this->response) {
return null; return null;
@@ -8,6 +8,6 @@ class AnikeenIdExtendSocialite
{ {
public function handle(SocialiteWasCalled $socialiteWasCalled): void public function handle(SocialiteWasCalled $socialiteWasCalled): void
{ {
$socialiteWasCalled->extendSocialite('anikeen-id', Provider::class); $socialiteWasCalled->extendSocialite('anikeen', Provider::class);
} }
} }
+22 -4
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Socialite; namespace Anikeen\Id\Socialite;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Enums\Scope; use Anikeen\Id\Enums\Scope;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Laravel\Socialite\Two\ProviderInterface; use Laravel\Socialite\Two\ProviderInterface;
use SocialiteProviders\Manager\OAuth2\AbstractProvider; use SocialiteProviders\Manager\OAuth2\AbstractProvider;
@@ -14,7 +16,7 @@ class Provider extends AbstractProvider implements ProviderInterface
/** /**
* Unique Provider Identifier. * Unique Provider Identifier.
*/ */
const IDENTIFIER = 'ANIKEEN_ID'; const IDENTIFIER = 'ANIKEEN';
/** /**
* {@inheritdoc} * {@inheritdoc}
@@ -26,13 +28,21 @@ class Provider extends AbstractProvider implements ProviderInterface
*/ */
protected $scopeSeparator = ' '; protected $scopeSeparator = ' ';
/**
* Get the base URL for the API.
*/
protected function getBaseUrl(): string
{
return app(AnikeenId::class)->getBaseUrl();
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function getAuthUrl($state): string protected function getAuthUrl($state): string
{ {
return $this->buildAuthUrlFromBase( return $this->buildAuthUrlFromBase(
'https://id.anikeen.com/oauth/authorize', $state $this->getBaseUrl() . '/oauth/authorize', $state
); );
} }
@@ -41,7 +51,7 @@ class Provider extends AbstractProvider implements ProviderInterface
*/ */
protected function getTokenUrl(): string protected function getTokenUrl(): string
{ {
return 'https://id.anikeen.com/oauth/token'; return $this->getBaseUrl() . '/oauth/token';
} }
/** /**
@@ -52,7 +62,7 @@ class Provider extends AbstractProvider implements ProviderInterface
protected function getUserByToken($token) protected function getUserByToken($token)
{ {
$response = $this->getHttpClient()->get( $response = $this->getHttpClient()->get(
'https://id.anikeen.com/api/v1/user', [ $this->getBaseUrl() . '/api/v1/user', [
'headers' => [ 'headers' => [
'Accept' => 'application/json', 'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token, 'Authorization' => 'Bearer ' . $token,
@@ -85,4 +95,12 @@ class Provider extends AbstractProvider implements ProviderInterface
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
]); ]);
} }
/**
* Returns the user logout url for the provider.
*/
public function getLogoutUrl(string $redirect = null): string
{
return app(AnikeenId::class)->getBaseUrl() . '/logout?redirect=' . urlencode($redirect ?: '/');
}
} }
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code.
*/
final class AppRequest
{
/**
* @throws ApiException
*/
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result
{
$result = self::attempt($method, $path, $payload, $query);
if ($result->status === 401) {
$tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query);
}
}
if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null;
throw new ApiException(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
return $result;
}
/**
* @throws ApiException
*/
private static function attempt(string $method, string $path, ?array $payload, array $query): Result
{
try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query);
} catch (ApiException $e) {
throw $e;
} catch (GuzzleException|Throwable $e) {
// network errors / timeouts (ConnectException isn't a RequestException) and token errors
throw new ApiException(sprintf('Anikeen ID is not reachable: %s', $e->getMessage()), 0, null, $e);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
return $owner;
}
private function page(array $orders, int $page = 1, int $last = 1): array
{
return ['data' => $orders, 'meta' => ['current_page' => $page, 'last_page' => $last, 'total' => count($orders)], 'links' => ['next' => null]];
}
private function requestQuery(int $index): array
{
parse_str($this->sent[$index]['request']->getUri()->getQuery(), $query);
return $query;
}
public function test_pending_orders_of_a_user(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => '01JUSER', 'status' => 'pending', 'gross_total' => 11.9, 'next_payment_attempt_at' => null, 'payment_attempts' => 4]]));
$orders = $this->owner()->appOrders()->pending();
$this->assertSame('o1', $orders[0]->id);
$this->assertSame(4, $orders[0]->payment_attempts);
$request = $this->sent[0]['request'];
$this->assertSame('GET /api/v1/client/orders Bearer app-token', $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'));
$this->assertSame(['status' => 'pending', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $this->requestQuery(0));
}
public function test_pending_orders_of_all_users_follow_pages(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => 'a']], 1, 2));
$this->respond(200, $this->page([['id' => 'o2', 'user_id' => 'b']], 2, 2));
$orders = app(AnikeenId::class)->appOrders()->pending(['group' => 'team-1']);
$this->assertSame(['o1', 'o2'], array_map(fn ($o) => $o->id, $orders));
$this->assertSame(['group' => 'team-1', 'status' => 'pending', 'per_page' => '100', 'page' => '2'], $this->requestQuery(1));
}
public function test_find_returns_null_for_unknown_and_other_users_orders(): void
{
$this->respond(404, ['message' => 'Order not found.', 'error' => 'order_not_found']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$orders = $this->owner()->appOrders();
$this->assertNull($orders->find('unknown'));
$this->assertNull($orders->find('o1'));
$this->assertSame('o1', $orders->find('o1')->id);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class AppSubscriptionsTest extends TestCase
{
private function owner(?string $anikeenId = '01JUSER'): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->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_a_running_service_is_checked_out_from_its_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
$subscription->checkout(new \DateTimeImmutable('2026-05-19 11:33:58', new \DateTimeZone('UTC')));
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
}
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]);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
/**
* Every package method has to call an endpoint that exists in Anikeen ID, with the right token.
*/
class EndpointsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->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));
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Providers\AnikeenIdServiceProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
protected MockHandler $http;
/** @var list<array{request: \Psr\Http\Message\RequestInterface}> */
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) ?? [];
}
}