Compare commits

...
Author SHA1 Message Date
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
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
envoyr 7f908f4e6a refactored code
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 04:47:50 +02:00
91 changed files with 4377 additions and 683 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
+354 -93
View File
@@ -4,13 +4,13 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 10+ 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. [Examples](#examples) 4. [Examples](#examples)
5. [Documentation](#documentation) 5. [Documentation](#documentation)
6. [Development](#Development) 6. [Development](#Development)
@@ -21,78 +21,173 @@ PHP Anikeen ID API Client for Laravel 10+
composer require anikeen/id composer require anikeen/id
``` ```
## Event Listener
- Add `SocialiteProviders\Manager\SocialiteWasCalled` event to your `listen[]` array in `app/Providers/EventServiceProvider`.
- Add your listeners (i.e. the ones from the providers) to the `SocialiteProviders\Manager\SocialiteWasCalled[]` that you just created.
- The listener that you add for this provider is `'Anikeen\\Id\\Socialite\\AnikeenIdExtendSocialite@handle',`.
- Note: You do not need to add anything for the built-in socialite providers unless you override them with your own providers.
```
/**
* The event handler mappings for the application.
*
* @var array
*/
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
// add your listeners (aka providers) here
'Anikeen\\Id\\Socialite\\AnikeenIdExtendSocialite@handle',
],
];
```
## Configuration ## Configuration
Copy configuration to config folder: Add environmental variables to your `.env` file:
```
$ php artisan vendor:publish --provider="Anikeen\Id\Providers\AnikeenIdServiceProvider"
```
Add environmental variables to your `.env`
``` ```
ANIKEEN_ID_KEY= ANIKEEN_ID_KEY=
ANIKEEN_ID_SECRET= ANIKEEN_ID_SECRET=
ANIKEEN_ID_REDIRECT_URI=http://localhost 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-id' => [ '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_REDIRECT_URI') 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'),
], ],
``` ```
## Implementing Auth ### Event Listener
This method should typically be called in the `boot` method of your `AuthServiceProvider` class: 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
public function boot(): void
{
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class);
});
}
```
### Registering Middleware
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->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
); );
}); });
} }
@@ -108,8 +203,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',
], ],
], ],
``` ```
@@ -123,39 +218,17 @@ 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',
], ],
], ],
``` ```
## Examples ## General
#### Basic #### Setters and Getters
```php
$anikeenId = new Anikeen\IdAnikeenId();
$anikeenId->setClientId('abc123');
// Get SSH Key by User ID
$result = $anikeenId->getSshKeysByUserId(38);
// Check, if the query was successfull
if ( ! $result->success()) {
die('Ooops: ' . $result->error());
}
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Setters
```php ```php
$anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId = new Anikeen\Id\AnikeenId();
@@ -169,6 +242,72 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456'); $anikeenId = $anikeenId->withToken('abcdef123456');
``` ```
#### Error handling for an unsuccessful query:
```php
$result = $anikeenId->sshKeysByUserId('someInvalidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
```
#### Shift result to get single key data:
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
## Examples
#### Get User SSH Key
```php
$anikeenId = new Anikeen\IdAnikeenId();
$anikeenId->setClientId('abc123');
// Get SSH Key by User ID
$result = $anikeenId->sshKeysByUserId('someValidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Create Order Preview
```php
$anikeenId = new \Anikeen\Id\AnikeenId();
// Create new Order Preview
$result = $anikeenId->createOrderPreview([
'country_iso' => 'de',
'items' => [
[
'type' => 'physical',
'name' => 'Test',
'price' => 2.99,
'unit' => 'onetime',
'units' => 1,
]
]
])->shift();
echo $preview->gross_total;
```
#### OAuth Tokens #### OAuth Tokens
```php ```php
@@ -202,52 +341,174 @@ AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser();
## Documentation ## Documentation
## AnikeenId
### Oauth ### Oauth
```php ```php
public function retrievingToken(string $grantType, array $attributes) public function retrievingToken(string $grantType, array $attributes): Result
``` ```
### SshKeys ### ManagesPricing
```php ```php
public function getSshKeysByUserId(int $id) public function createOrderPreview(array $attributes = []): Result
public function createSshKey(string $publicKey, string $name = NULL)
public function deleteSshKey(int $id)
``` ```
### Users ### ManagesProducts
```php ```php
public function getAuthedUser() public function products(): Result
public function createUser(array $parameters) public function boxes(): Result
public function isEmailExisting(string $email)
``` ```
### Delete ### ManagesShippingLabels
```php ```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
``` ```
### Get ### ManagesUserBilling
```php ```php
public function appOrders(): AppOrders
public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
``` ```
### Post ### ManagesSocialiteUsers
```php ```php
public function socialiteUsers(): SocialiteUsers
``` ```
### Put ### ManagesSshKeys
```php ```php
public function sshKeysByUserId(string $userId): SshKeys
``` ```
### ManagesUsers
```php
public function getAuthedUser(): Result
public function createUser(array $attributes): Result
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result
public function setParent(mixed $parent): self
public function getParent()
```
## Billable
### ManagesAddresses
```php
public function addresses(): Addresses
public function hasDefaultBillingAddress(): bool
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesBalance
```php
public function balance(): float
public function charges(): float
public function billingOverview(): object
public function charge(string|float $amount, string $paymentMethodId, array $options = []): Transaction
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesCountries
```php
public function countries(): Countries
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesInvoices
```php
public function invoices(array $parameters = []): Invoices
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesOrders
```php
public function orders(array $parameters = []): Orders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesPaymentMethods
```php
public function paymentMethods(): PaymentMethods
public function hasPaymentMethod(): ?PaymentMethod
public function defaultPaymentMethod(): ?PaymentMethod
public function hasDefaultPaymentMethod(): bool
public function setDefaultPaymentMethod(string $id): PaymentMethod
public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
public function createSetupIntent(array $options = []): Result
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesProfile
```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesSubscriptions
```php
public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions
public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesTaxation
```php
public function vatRate(): float
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesTransactions
```php
public function transactions(): Transactions
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesUserSshKeys
```php
public function sshKeys(): SshKeys
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
[**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Enums/Scope.php) [**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Enums/Scope.php)
## Development ## Development
+214 -64
View File
@@ -9,8 +9,8 @@ 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. [Examples](#examples) 4. [Examples](#examples)
5. [Documentation](#documentation) 5. [Documentation](#documentation)
6. [Development](#Development) 6. [Development](#Development)
@@ -21,67 +21,173 @@ 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
Copy configuration to config folder: Add environmental variables to your `.env` file:
```
$ php artisan vendor:publish --provider="Anikeen\Id\Providers\AnikeenIdServiceProvider"
```
Add environmental variables to your `.env`
``` ```
ANIKEEN_ID_KEY= ANIKEEN_ID_KEY=
ANIKEEN_ID_SECRET= ANIKEEN_ID_SECRET=
ANIKEEN_ID_REDIRECT_URI=http://localhost 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-id' => [ '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_REDIRECT_URI') 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'),
], ],
``` ```
## Implementing Auth ### Event Listener
This method should typically be called in the `boot` method of your `AuthServiceProvider` class: 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
public function boot(): void
{
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class);
});
}
```
### Registering Middleware
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->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
); );
}); });
} }
@@ -97,8 +203,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',
], ],
], ],
``` ```
@@ -112,39 +218,17 @@ 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',
], ],
], ],
``` ```
## Examples ## General
#### Basic #### Setters and Getters
```php
$anikeenId = new Anikeen\IdAnikeenId();
$anikeenId->setClientId('abc123');
// Get SSH Key by User ID
$result = $anikeenId->getSshKeysByUserId(38);
// Check, if the query was successfull
if ( ! $result->success()) {
die('Ooops: ' . $result->error());
}
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Setters
```php ```php
$anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId = new Anikeen\Id\AnikeenId();
@@ -158,6 +242,72 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456'); $anikeenId = $anikeenId->withToken('abcdef123456');
``` ```
#### Error handling for an unsuccessful query:
```php
$result = $anikeenId->sshKeysByUserId('someInvalidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
```
#### Shift result to get single key data:
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
## Examples
#### Get User SSH Key
```php
$anikeenId = new Anikeen\IdAnikeenId();
$anikeenId->setClientId('abc123');
// Get SSH Key by User ID
$result = $anikeenId->sshKeysByUserId('someValidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Create Order Preview
```php
$anikeenId = new \Anikeen\Id\AnikeenId();
// Create new Order Preview
$result = $anikeenId->createOrderPreview([
'country_iso' => 'de',
'items' => [
[
'type' => 'physical',
'name' => 'Test',
'price' => 2.99,
'unit' => 'onetime',
'units' => 1,
]
]
])->shift();
echo $preview->gross_total;
```
#### OAuth Tokens #### OAuth Tokens
```php ```php
+12 -10
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": {
-8
View File
@@ -1,8 +0,0 @@
<?php
return [
'client_key' => env('ANIKEEN_ID_KEY'),
'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect_url' => env('ANIKEEN_ID_REDIRECT_URI'),
'base_url' => env('ANIKEEN_ID_BASE_URL'),
];
+96 -62
View File
@@ -3,83 +3,117 @@
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str;
$markdown = collect(class_uses(AnikeenId::class)) // short type name incl. union, intersection and nullable types
->map(function ($trait) { function typeName(ReflectionType $type): string
{
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
$separator = $type instanceof ReflectionUnionType ? '|' : '&';
$title = str_replace('Trait', '', Arr::last(explode('\\', $trait))); return collect($type->getTypes())->map(fn (ReflectionType $t) => typeName($t))->implode($separator);
$methods = [];
$reflection = new ReflectionClass($trait);
collect($reflection->getMethods())
->reject(function (ReflectionMethod $method) {
return $method->isAbstract();
})
->reject(function (ReflectionMethod $method) {
return $method->isPrivate() || $method->isProtected();
})
->reject(function (ReflectionMethod $method) {
return $method->isConstructor();
})
->each(function (ReflectionMethod $method) use (&$methods, $title, $trait) {
$declaration = collect($method->getModifiers())->map(function (int $modifier) {
return $modifier == ReflectionMethod::IS_PUBLIC ? 'public ' : '';
})->join(' ');
$declaration .= 'function ';
$declaration .= $method->getName();
$declaration .= '(';
$declaration .= collect($method->getParameters())->map(function (ReflectionParameter $parameter) {
$parameterString = Arr::last(explode('\\', $parameter->getType()->getName()));
$parameterString .= ' ';
$parameterString .= '$';
$parameterString .= $parameter->getName();
if ($parameter->isDefaultValueAvailable()) {
$parameterString .= ' = ';
$parameterString .= str_replace(PHP_EOL, '', var_export($parameter->getDefaultValue(), true));
} }
return $parameterString; $name = Arr::last(explode('\\', $type->getName()));
})->join(', '); return ($type->allowsNull() && !in_array($name, ['mixed', 'null'], true) ? '?' : '') . $name;
}
$declaration .= ')'; // Liste der Klassen, die ausgewertet werden sollen
$classes = [
AnikeenId::class,
Billable::class,
];
$methods[] = $declaration; $allMarkdown = collect($classes)
}); ->map(function (string $class) {
$className = Arr::last(explode('\\', $class));
$markdown = "## {$className}\n\n";
return [$title, $methods]; // alle Traits der Klasse, außer denen aus ApiOperations
$traits = collect(class_uses($class) ?: [])
->reject(function (string $trait) {
return Str::contains($trait, 'ApiOperations\\');
}) })
->map(function ($args) { ->all();
list($title, $methods) = $args; if (empty($traits)) {
$markdown .= '_Keine Traits gefunden._';
return $markdown;
}
$markdown = '### ' . $title; // für jeden Trait die Methoden extrahieren
$markdown .= PHP_EOL . PHP_EOL; $markdown .= collect($traits)
$markdown .= '```php'; ->map(function (string $trait) {
$markdown .= PHP_EOL; $title = str_replace('Trait', '', Arr::last(explode('\\', $trait)));
$reflection = new ReflectionClass($trait);
$markdown .= collect($methods)->each(function ($method) { $methods = collect($reflection->getMethods())
return $method; ->reject->isAbstract()
})->implode(PHP_EOL); ->reject->isPrivate()
->reject->isProtected()
->reject->isConstructor()
->map(function (ReflectionMethod $method) {
// Methodendeklaration starten
$decl = 'public function ' . $method->getName() . '(';
$markdown .= PHP_EOL; // Parameter-Typen und Default-Werte
$markdown .= '```'; $decl .= collect($method->getParameters())
->map(function (ReflectionParameter $p) {
// Typ-Hint
$typeHint = '';
if ($p->hasType()) {
$typeHint = typeName($p->getType()) . ' ';
}
// Parameter-Name
$param = $typeHint . '$' . $p->getName();
// Default-Wert
if ($p->isDefaultValueAvailable()) {
$default = $p->getDefaultValue();
if (is_array($default) && empty($default)) {
// leeres Array → Short-Syntax
$param .= ' = []';
} elseif ($default === null) {
// NULL → null (kleingeschrieben)
$param .= ' = null';
} else {
// sonst var_export, Newlines entfernen
$def = var_export($default, true);
$param .= ' = ' . str_replace(PHP_EOL, '', $def);
}
}
return $param;
})
->implode(', ');
$decl .= ')';
// Rückgabetyp, falls vorhanden
if ($method->hasReturnType()) {
$decl .= ': ' . typeName($method->getReturnType());
}
return $decl;
})
->all();
// Markdown-Block für diesen Trait
$md = "### {$title}\n\n```php\n";
$md .= implode("\n", $methods) . "\n```\n";
return $md;
})
->implode("\n");
return $markdown; return $markdown;
})->join(PHP_EOL . PHP_EOL); })
->implode("\n\n");
$markdown = str_replace("array (\n)", '[]', $markdown);
$content = file_get_contents(__DIR__ . '/../README.stub');
$content = str_replace('<!-- GENERATED-DOCS -->', $markdown, $content);
// README zusammenbauen und schreiben
$stub = file_get_contents(__DIR__ . '/../README.stub');
$content = str_replace('<!-- GENERATED-DOCS -->', $allMarkdown, $stub);
file_put_contents(__DIR__ . '/../README.md', $content); file_put_contents(__DIR__ . '/../README.md', $content);
+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-----
+8 -21
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>
<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/v1/"/>
<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> </phpunit>
+170 -88
View File
@@ -2,6 +2,13 @@
namespace Anikeen\Id; namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels;
use Anikeen\Id\Concerns\ManagesSocialiteUsers;
use Anikeen\Id\Concerns\ManagesSshKeys;
use Anikeen\Id\Concerns\ManagesUserBilling;
use Anikeen\Id\Concerns\ManagesUsers;
use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException; use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Exceptions\RequestRequiresRedirectUriException; use Anikeen\Id\Exceptions\RequestRequiresRedirectUriException;
@@ -15,14 +22,20 @@ use Illuminate\Contracts\Auth\Authenticatable;
class AnikeenId class AnikeenId
{ {
use Traits\OauthTrait; use OauthTrait;
use Traits\SshKeysTrait; use ManagesPricing;
use Traits\UsersTrait; use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys;
use ManagesUsers;
use ApiOperations\Delete; use ApiOperations\Delete;
use ApiOperations\Get; use ApiOperations\Get;
use ApiOperations\Post; use ApiOperations\Post;
use ApiOperations\Put; use ApiOperations\Put;
use ApiOperations\Request;
/** /**
* The name for API token cookies. * The name for API token cookies.
@@ -41,7 +54,17 @@ class AnikeenId
*/ */
public static bool $unserializesCookies = false; public static bool $unserializesCookies = false;
private static string $baseUrl = 'https://id.anikeen.com/api/'; /**
* The key for the access token.
*/
private static string $accessTokenField = 'anikeen_id_access_token';
/**
* The key for the access token.
*/
private static string $refreshTokenField = 'anikeen_id_refresh_token';
private static string $userIdField = 'anikeen_id';
/** /**
* Guzzle is used to make http requests. * Guzzle is used to make http requests.
@@ -55,13 +78,11 @@ class AnikeenId
/** /**
* Anikeen ID OAuth token. * Anikeen ID OAuth token.
*
*/ */
protected ?string $token = null; protected ?string $token = null;
/** /**
* Anikeen ID client id. * Anikeen ID client id.
*
*/ */
protected ?string $clientId = null; protected ?string $clientId = null;
@@ -75,36 +96,109 @@ 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.
*/ */
public function __construct() public function __construct()
{ {
if ($clientId = config('anikeen_id.client_id')) { if ($clientId = config('services.anikeen.client_id')) {
$this->setClientId($clientId); $this->setClientId($clientId);
} }
if ($clientSecret = config('anikeen_id.client_secret')) { if ($clientSecret = config('services.anikeen.client_secret')) {
$this->setClientSecret($clientSecret); $this->setClientSecret($clientSecret);
} }
if ($redirectUri = config('anikeen_id.redirect_url')) { if ($redirectUri = config('services.anikeen.redirect')) {
$this->setRedirectUri($redirectUri); $this->setRedirectUri($redirectUri);
} }
if ($redirectUri = config('anikeen_id.base_url')) { if (self::getMode() === 'staging' && !config('services.anikeen.base_url')) {
self::setBaseUrl($redirectUri); self::setBaseUrl($this->stagingBaseUrl);
}
if ($baseUrl = config('services.anikeen.base_url')) {
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
* @param string $baseUrl
*
* @internal only for internal and debug purposes.
*/
public static function setBaseUrl(string $baseUrl): void
{ {
self::$baseUrl = $baseUrl; $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;
}
/**
* Attribute of the billable model that holds the user's Anikeen ID (used by app requests,
* e.g. appSubscriptions(), which act on behalf of a user without the user's token).
*/
public static function useUserIdField(string $userIdField): void
{
self::$userIdField = $userIdField;
}
public static function getUserIdField(): string
{
return self::$userIdField;
}
/**
* A copy that uses the app's own access token (client credentials) instead of a user's token.
* The shared instance keeps its token, so app rights never leak into other requests.
*
* @throws Exceptions\RequestFreshAccessTokenException
*/
public function asApp(): static
{
return (clone $this)->withToken(app(Contracts\AppTokenRepository::class)->getAccessToken());
}
/**
* Replace the HTTP client, e.g. with one using a Guzzle MockHandler in tests.
*/
public function setHttpClient(Client $client): static
{
$this->client = $client;
return $this;
}
public static function getMode(): string
{
return config('services.anikeen.mode') ?: 'production';
}
public static function useRefreshTokenField(string $refreshTokenField): void
{
self::$refreshTokenField = $refreshTokenField;
}
public static function getRefreshTokenField(): string
{
return self::$refreshTokenField;
} }
/** /**
@@ -113,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) public static function cookie(?string $cookie = null): string|static
{ {
if (is_null($cookie)) { if (is_null($cookie)) {
return static::$cookie; return static::$cookie;
@@ -127,7 +221,7 @@ class AnikeenId
/** /**
* Set the current user for the application with the given scopes. * Set the current user for the application with the given scopes.
*/ */
public static function actingAs(Authenticatable|Traits\HasAnikeenTokens $user, array $scopes = [], string $guard = 'api'): Authenticatable public static function actingAs(Authenticatable|HasAnikeenTokens $user, array $scopes = [], string $guard = 'api'): Authenticatable
{ {
$user->withAnikeenAccessToken((object)[ $user->withAnikeenAccessToken((object)[
'scopes' => $scopes 'scopes' => $scopes
@@ -250,63 +344,6 @@ class AnikeenId
return $this; return $this;
} }
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function get(string $path = '', array $parameters = [], Paginator $paginator = null): Result
{
return $this->query('GET', $path, $parameters, $paginator);
}
/**
* Build query & execute.
*
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function query(string $method = 'GET', string $path = '', array $parameters = [], Paginator $paginator = null, mixed $jsonBody = null): Result
{
/** @noinspection DuplicatedCode */
if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor();
}
try {
$response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$jsonBody),
'query' => Query::build($parameters),
'json' => $jsonBody ?: null,
]);
$result = new Result($response, null, $paginator);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception, $paginator);
}
$result->anikeenId = $this;
return $result;
}
/**
* Build headers for request.
*
* @throws RequestRequiresClientIdException
*/
private function buildHeaders(bool $json = false): array
{
$headers = [
'Client-ID' => $this->getClientId(),
'Accept' => 'application/json',
];
if ($this->token) {
$headers['Authorization'] = 'Bearer ' . $this->token;
}
if ($json) {
$headers['Content-Type'] = 'application/json';
}
return $headers;
}
/** /**
* Get client id. * Get client id.
* *
@@ -330,42 +367,87 @@ class AnikeenId
} }
/** /**
* Build query & execute.
*
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function post(string $path = '', 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
{ {
return $this->query('POST', $path, $parameters, $paginator); if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor();
}
try {
$response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
'query' => Query::build($parameters),
'json' => $payload ?: null,
]);
$result = new Result($response, null, $this);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception, $this);
}
return $result;
}
/**
* Build headers for request.
*
* @throws RequestRequiresClientIdException
*/
private function buildHeaders(bool $json = false, bool $useClientSecret = false): array
{
$headers = [
'Client-ID' => $this->getClientId(),
'Accept' => 'application/json',
];
// public endpoints (products, order preview, …) work without a token
if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->token) {
$headers['Authorization'] = 'Bearer ' . $bearerToken;
}
if ($json) {
$headers['Content-Type'] = 'application/json';
}
return $headers;
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function delete(string $path = '', array $parameters = [], Paginator $paginator = null): Result public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->query('DELETE', $path, $parameters, $paginator); return $this->request('GET', $path, null, $parameters, $paginator);
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function put(string $path = '', array $parameters = [], Paginator $paginator = null): Result public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{ {
return $this->query('PUT', $path, $parameters, $paginator); return $this->request('POST', $path, $payload, $parameters, $paginator);
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function json(string $method, string $path = '', array $body = null): Result public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{ {
if ($body) { return $this->request('PUT', $path, $payload, $parameters, $paginator);
$body = json_encode(['data' => $body]);
} }
return $this->query($method, $path, [], null, $body); /**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
{
return $this->request('DELETE', $path, $payload, $parameters, $paginator);
} }
} }
+9 -1
View File
@@ -2,10 +2,18 @@
namespace Anikeen\Id\ApiOperations; namespace Anikeen\Id\ApiOperations;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait Delete trait Delete
{ {
abstract public function delete(string $path = '', array $parameters = [], Paginator $paginator = null): Result; /**
* Delete a resource from the API.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+9 -1
View File
@@ -2,10 +2,18 @@
namespace Anikeen\Id\ApiOperations; namespace Anikeen\Id\ApiOperations;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait Get trait Get
{ {
abstract public function get(string $path = '', array $parameters = [], Paginator $paginator = null): Result; /**
* Get a resource from the API.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result;
} }
+9 -1
View File
@@ -2,10 +2,18 @@
namespace Anikeen\Id\ApiOperations; namespace Anikeen\Id\ApiOperations;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait Post trait Post
{ {
abstract public function post(string $path = '', array $parameters = [], Paginator $paginator = null): Result; /**
* Make a POST request to the API.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+9 -1
View File
@@ -2,10 +2,18 @@
namespace Anikeen\Id\ApiOperations; namespace Anikeen\Id\ApiOperations;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait Put trait Put
{ {
abstract public function put(string $path = '', array $parameters = [], Paginator $paginator = null): Result; /**
* Make a PUT request to the API.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Anikeen\Id\ApiOperations;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
trait Request
{
/**
* Make a request to the API.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null): Result;
}
+1 -1
View File
@@ -10,7 +10,7 @@ trait Validation
/** /**
* @throws RequestRequiresMissingParametersException * @throws RequestRequiresMissingParametersException
*/ */
public function validateRequired(array $parameters, array $required) public function validateRequired(array $parameters, array $required): void
{ {
if (!Arr::has($parameters, $required)) { if (!Arr::has($parameters, $required)) {
throw RequestRequiresMissingParametersException::fromValidateRequired($parameters, $required); throw RequestRequiresMissingParametersException::fromValidateRequired($parameters, $required);
+1 -1
View File
@@ -25,7 +25,7 @@ class ApiTokenCookieFactory
{ {
$config = $this->config->get('session'); $config = $this->config->get('session');
$expiration = Carbon::now()->addMinutes($config['lifetime']); $expiration = Carbon::now()->addMinutes((int)$config['lifetime']);
return new Cookie( return new Cookie(
AnikeenId::cookie(), AnikeenId::cookie(),
+1 -1
View File
@@ -3,8 +3,8 @@
namespace Anikeen\Id\Auth; namespace Anikeen\Id\Auth;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\HasAnikeenTokens;
use Anikeen\Id\Helpers\JwtParser; use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Traits\HasAnikeenTokens;
use Exception; use Exception;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key; use Firebase\JWT\Key;
+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.
}
} }
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesAddresses;
use Anikeen\Id\Concerns\ManagesBalance;
use Anikeen\Id\Concerns\ManagesCountries;
use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions;
use Anikeen\Id\Concerns\ManagesUserSshKeys;
use Throwable;
trait Billable
{
use ManagesAddresses;
use ManagesBalance;
use ManagesCountries;
use ManagesInvoices;
use ManagesOrders;
use ManagesPaymentMethods;
use ManagesProfile;
use ManagesSubscriptions;
use ManagesTaxation;
use ManagesTransactions;
use ManagesUserSshKeys;
/**
* Get the currently authenticated user.
*
* @throws Throwable
*/
public function getUserData(): object
{
if (!isset($this->userDataCache)) {
$this->userDataCache = $this->anikeenId()->request('GET', 'v1/user')->data;
}
return $this->userDataCache;
}
/**
* Get the AnikeenId class.
*
* @throws Throwable
*/
public function anikeenId(): AnikeenId
{
return app(AnikeenId::class)->withToken($this->{AnikeenId::getAccessTokenField()});
}
}
+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();
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Transaction;
use Throwable;
trait ManagesBalance
{
use HasBillable;
/**
* Get balance from the current user.
*
* @throws Throwable
*/
public function balance(): float
{
return $this->getUserData()->current_balance;
}
/**
* Get charges from the current user.
*
* @throws Throwable
*/
public function charges(): float
{
return $this->getUserData()->current_charges;
}
/**
* Billing overview of the current user: current_balance, current_charges, vat_rate,
* has_billing_address, has_default_payment_method, open_orders.
*
* @throws Throwable
*/
public function billingOverview(): object
{
return $this->anikeenId()->request('GET', 'v1/billing')->data;
}
/**
* Charge given amount (deposit) from one of the current user's payment methods.
*
* @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals).
* @param string $paymentMethodId Stripe payment method ID (pm_…).
* @param array $options Ignored; Stripe options can no longer be passed through.
* @throws Throwable
*/
public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction
{
return (new Transaction(fn() => $this->anikeenId()
->request('POST', '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;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Invoices;
use Throwable;
trait ManagesInvoices
{
use HasBillable;
/**
* Get invoices from the current user.
*
* @throws Throwable
*/
public function invoices(array $parameters = []): Invoices
{
if (!isset($this->invoicesCache)) {
$this->invoicesCache = Invoices::builder(fn() => $this->anikeenId()
->request('GET', 'v1/invoices', [], $parameters))
->setBillable($this);
}
return $this->invoicesCache;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Orders;
use Throwable;
trait ManagesOrders
{
use HasBillable;
/**
* Get orders from the current user.
*
* @throws Throwable
*/
public function orders(array $parameters = []): Orders
{
if (!isset($this->ordersCache)) {
$this->ordersCache = Orders::builder(fn() => $this->anikeenId()
->request('GET', 'v1/orders', [], $parameters))
->setBillable($this);
}
return $this->ordersCache;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\PaymentMethod;
use Anikeen\Id\Resources\PaymentMethods;
use Anikeen\Id\Result;
use Throwable;
trait ManagesPaymentMethods
{
use HasBillable;
/**
* Get payment methods from the current user.
*
* @throws Throwable
*/
public function paymentMethods(): PaymentMethods
{
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.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::defaultPaymentMethod()
*/
public function defaultPaymentMethod(): ?PaymentMethod
{
return $this->paymentMethods()->defaultPaymentMethod();
}
/**
* Check if the current user has a default payment method.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::hasDefaultPaymentMethod()
*/
public function hasDefaultPaymentMethod(): bool
{
return $this->paymentMethods()->hasDefaultPaymentMethod();
}
/**
* Set the default payment method for the current user.
*
* @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.
* @throws Throwable
*/
public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
{
return $this->anikeenId()
->request('POST', 'v1/billing/portal', [
'return_url' => $returnUrl,
'options' => $options,
])
->data
->url;
}
/**
* Create a new setup intent.
*
* @param array $options Additional options for the setup intent.
* @throws Throwable
*/
public function createSetupIntent(array $options = []): Result
{
return $this->anikeenId()
->request('POST', 'v1/payment-methods', [
'options' => $options,
]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Result;
use Throwable;
trait ManagesPricing
{
use Post;
/**
* Make a new order preview (will not be stored into the database).
*
* VAT is calculated based on the billing address and shown in the order response.
*
* @param array{
* country_iso: string,
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - country_iso: 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 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);
}
}␍
+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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\Resources\SshKeys;
use Throwable;
trait ManagesSshKeys
{
use Get;
/**
* SSH keys of a user, read with the app's own token (trusted apps, scope ssh-keys:read),
* e.g. to install them on a new server.
*
* @throws Throwable
*/
public function sshKeysByUserId(string $userId): SshKeys
{
$app = $this->asApp();
return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
->setParent($app);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
trait ManagesSubscriptions
{
use HasBillable;
/**
* Get subscriptions from the current user.
*
* @throws Throwable
*/
public function subscriptions(): Subscriptions
{
if (!isset($this->subscriptionsCache)) {
$this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/subscriptions'))
->setBillable($this);
}
return $this->subscriptionsCache;
}
/**
* The user's subscriptions, managed with the app's own token instead of the user's
* (trusted apps only, scope subscriptions:client). Use this in jobs and anywhere the user's
* login may have expired.
*/
public function appSubscriptions(): AppSubscriptions
{
return new AppSubscriptions($this);
}
/**
* 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.
*/
public function appOrders(): AppOrders
{
return new AppOrders($this);
}
}␍
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Concerns;
use Throwable;
trait ManagesTaxation
{
use HasBillable;
/**
* Get VAT for the current user.
*
* @throws Throwable
*/
public function vatRate(): float
{
return $this->getUserData()->vat_rate;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Transactions;
use Throwable;
trait ManagesTransactions
{
use HasBillable;
/**
* Get transactions from the current user.
*
* @throws Throwable
*/
public function transactions(): Transactions
{
if (!isset($this->transactionsCache)) {
$this->transactionsCache = Transactions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/transactions'))
->setBillable($this);
}
return $this->transactionsCache;
}
}
+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);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Result;
use Throwable;
trait ManagesUsers
{
use Get, Post;
use HasParent;
/**
* Get currently authed user with Bearer Token
*
* @throws Throwable
*/
public function getAuthedUser(): Result
{
return $this->get('v1/user');
}
/**
* Creates a new user on behalf of the current user.
*
* @param array{
* first_name: null|string,
* last_name: null|string,
* username: null|string,
* email: string,
* password: null|string
* } $attributes The user data
* - first_name: The first name (optional)
* - last_name: The last name (optional)
* - username: The username (optional)
* - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided)
* @throws Throwable
*/
public function createUser(array $attributes): Result
{
// 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.
*
* @param string $email The email to check.
* @throws Throwable
*/
public function isEmailExisting(string $email): Result
{
// `data.exists` tells whether an account uses the email (trusted apps, app token)
return $this->asApp()->post('v1/admin/users/check', [
'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
{
//
}
@@ -1,6 +1,6 @@
<?php <?php
namespace Anikeen\Id\Traits; namespace Anikeen\Id;
use stdClass; use stdClass;
@@ -9,7 +9,7 @@ trait HasAnikeenTokens
/** /**
* The current access token for the authentication user. * The current access token for the authentication user.
*/ */
protected ?stdClass $accessToken; protected ?stdClass $accessToken = null;
/** /**
* Get the current access token being used by the user. * Get the current access token being used by the user.
+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');
} }
} }
+67 -17
View File
@@ -7,70 +7,120 @@ use stdClass;
class Paginator class Paginator
{ {
/** /**
* Next desired action (first, after, before). * Next desired action: 'first', 'after', 'before'.
*
* @var string|null
*/ */
public ?string $action = null; public ?string $action = null;
/** /**
* AnikeenId response pagination cursor. * Raw pagination links from API ('first','last','prev','next').
*
* @var stdClass|null
*/ */
private ?stdClass $pagination; private ?stdClass $links;
/**
* Raw pagination meta from API (current_page, last_page, etc.).
*
* @var stdClass|null
*/
private ?stdClass $meta;
/** /**
* Constructor. * Constructor.
* *
* @param null|stdClass $pagination AnikeenId response pagination cursor * @param stdClass|null $links Pagination links object
* @param stdClass|null $meta Pagination meta object
*/ */
public function __construct(?stdClass $pagination = null) public function __construct(?stdClass $links = null, ?stdClass $meta = null)
{ {
$this->pagination = $pagination; $this->links = $links;
$this->meta = $meta;
} }
/** /**
* Create Paginator from Result object. * Create Paginator from a Result instance.
*/ */
public static function from(Result $result): self public static function from(Result $result): self
{ {
return new self($result->pagination); return new self($result->links, $result->meta);
} }
/** /**
* Return the current active cursor. * Return the cursor value (page number) based on the last set action.
*/ */
public function cursor(): string public function cursor(): string
{ {
return $this->pagination->cursor; switch ($this->action) {
case 'first':
return '1';
case 'after':
// Try parsing from 'next' link
if ($this->links && !empty($this->links->next)) {
return $this->parsePageFromUrl($this->links->next);
}
// Fallback to current_page + 1
return isset($this->meta->current_page)
? (string)($this->meta->current_page + 1)
: '1';
case 'before':
if ($this->links && !empty($this->links->prev)) {
return $this->parsePageFromUrl($this->links->prev);
}
// Fallback to current_page - 1
return isset($this->meta->current_page)
? (string)($this->meta->current_page - 1)
: '1';
default:
// Default to current page
return isset($this->meta->current_page)
? (string)$this->meta->current_page
: '1';
}
} }
/** /**
* Set the Paginator to fetch the next set of results. * Parse the 'page' query parameter from a URL.
*/
private function parsePageFromUrl(string $url): string
{
$parts = parse_url($url);
if (empty($parts['query'])) {
return '1';
}
parse_str($parts['query'], $vars);
return $vars['page'] ?? '1';
}
/**
* Fetch the first page.
*/ */
public function first(): self public function first(): self
{ {
$this->action = 'first'; $this->action = 'first';
return $this; return $this;
} }
/** /**
* Set the Paginator to fetch the first set of results. * Fetch the next page (after).
*/ */
public function next(): self public function next(): self
{ {
$this->action = 'after'; $this->action = 'after';
return $this; return $this;
} }
/** /**
* Set the Paginator to fetch the last set of results. * Fetch the previous page (before).
*/ */
public function back(): self public function back(): self
{ {
$this->action = 'before'; $this->action = 'before';
return $this; return $this;
} }
} }
+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.
+53 -10
View File
@@ -2,35 +2,62 @@
namespace Anikeen\Id\Http\Middleware; namespace Anikeen\Id\Http\Middleware;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\ApiTokenCookieFactory; use Anikeen\Id\ApiTokenCookieFactory;
use Anikeen\Id\Facades\AnikeenId;
use Closure; use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class CreateFreshApiToken class CreateFreshApiToken
{ {
/**
* The API token cookie factory instance.
*
* @var ApiTokenCookieFactory
*/
protected $cookieFactory;
/** /**
* The authentication guard. * The authentication guard.
* *
* @var string * @var string
*/ */
protected string $guard; protected $guard;
/** /**
* Create a new middleware instance. * Create a new middleware instance.
* *
* @param ApiTokenCookieFactory $cookieFactory
* @return void * @return void
*/ */
public function __construct(protected ApiTokenCookieFactory $cookieFactory) public function __construct(ApiTokenCookieFactory $cookieFactory)
{ {
// $this->cookieFactory = $cookieFactory;
}
/**
* Specify the guard for the middleware.
*
* @param string|null $guard
* @return string
*/
public static function using($guard = null)
{
$guard = is_null($guard) ? '' : ':' . $guard;
return static::class . $guard;
} }
/** /**
* Handle an incoming request. * Handle an incoming request.
*
* @param Request $request
* @param Closure $next
* @param string|null $guard
* @return mixed
*/ */
public function handle(Request $request, Closure $next, string $guard = null): mixed public function handle($request, Closure $next, $guard = null)
{ {
$this->guard = $guard; $this->guard = $guard;
@@ -47,8 +74,12 @@ class CreateFreshApiToken
/** /**
* Determine if the given request should receive a fresh token. * Determine if the given request should receive a fresh token.
*
* @param Request $request
* @param Response $response
* @return bool
*/ */
protected function shouldReceiveFreshToken(Request $request, Response $response): bool protected function shouldReceiveFreshToken($request, $response)
{ {
return $this->requestShouldReceiveFreshToken($request) && return $this->requestShouldReceiveFreshToken($request) &&
$this->responseShouldReceiveFreshToken($response); $this->responseShouldReceiveFreshToken($response);
@@ -56,25 +87,37 @@ class CreateFreshApiToken
/** /**
* Determine if the request should receive a fresh token. * Determine if the request should receive a fresh token.
*
* @param Request $request
* @return bool
*/ */
protected function requestShouldReceiveFreshToken(Request $request): bool protected function requestShouldReceiveFreshToken($request)
{ {
return $request->isMethod('GET') && $request->user($this->guard); return $request->isMethod('GET') && $request->user($this->guard);
} }
/** /**
* Determine if the response should receive a fresh token. * Determine if the response should receive a fresh token.
*
* @param Response $response
* @return bool
*/ */
protected function responseShouldReceiveFreshToken(Response $response): bool protected function responseShouldReceiveFreshToken($response)
{ {
return !$this->alreadyContainsToken($response); return ($response instanceof Response ||
$response instanceof JsonResponse) &&
!$this->alreadyContainsToken($response);
} }
/** /**
* Determine if the given response already contains an API token. * Determine if the given response already contains an API token.
*
* This avoids us overwriting a just "refreshed" token. * This avoids us overwriting a just "refreshed" token.
*
* @param Response $response
* @return bool
*/ */
protected function alreadyContainsToken(Response $response): bool protected function alreadyContainsToken($response)
{ {
foreach ($response->headers->getCookies() as $cookie) { foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === AnikeenId::cookie()) { if ($cookie->getName() === AnikeenId::cookie()) {
+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]);
}
}
@@ -1,9 +1,8 @@
<?php <?php
namespace Anikeen\Id\Traits; namespace Anikeen\Id;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\RequestException;
@@ -24,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;
} }
} }
@@ -21,9 +21,7 @@ class AnikeenIdServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
$this->publishes([ //
dirname(__DIR__, 3) . '/config/anikeen-id.php' => config_path('anikeen-id.php'),
], 'config');
} }
/** /**
@@ -31,7 +29,6 @@ class AnikeenIdServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->mergeConfigFrom(dirname(__DIR__, 3) . '/config/anikeen-id.php', 'anikeen-id');
$this->app->singleton(Contracts\AppTokenRepository::class, Repository\AppTokenRepository::class); $this->app->singleton(Contracts\AppTokenRepository::class, Repository\AppTokenRepository::class);
$this->app->singleton(AnikeenId::class, function () { $this->app->singleton(AnikeenId::class, function () {
return new AnikeenId; return new AnikeenId;
@@ -46,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();
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $vat_rate
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status "active" or "inactive"
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
* @property bool $is_past_due
* @property int $failed_renewal_retries
* @property ?array $payload
* @property ?string $idempotency_key
*/
class AppSubscription implements JsonSerializable
{
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* Change a subscription that wasn't checked out yet.
*
* @throws ApiException
*/
public function update(array $attributes): self
{
return $this->send('PUT', '', $attributes);
}
/**
* Activate the subscription and bill its first period.
*
* @throws ApiException 409 "already_active", "billing_address_missing", …
*/
public function checkout(): self
{
return $this->send('PUT', '/checkout');
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}
+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);
}
}
+114 -79
View File
@@ -9,67 +9,112 @@ use stdClass;
class Result class Result
{ {
/** /**
* Query successful. * Was the API call successful?
*/ */
public bool $success = false; public bool $success = false;
/** /**
* Query result data. * Response data: either an array of items (paginated) or a single object (non-paginated)
*/ */
public array $data = []; public mixed $data = [];
/** /**
* Total amount of result data. * Total number of items: uses meta.total, root total, or falls back to count/data existence
*/ */
public int $total = 0; public int $total = 0;
/** /**
* Status Code. * HTTP status code
*/ */
public int $status = 0; public int $status = 0;
/** /**
* AnikeenId response pagination cursor. * Pagination links (first, last, prev, next) as stdClass or null
*/ */
public ?stdClass $pagination; public ?stdClass $links = null;
/** /**
* Original AnikeenId instance. * Pagination meta (current_page, last_page etc.) as stdClass or null
* */
* @var AnikeenId public ?stdClass $meta = null;
/**
* Paginator helper to retrieve next/prev pages
*/
public ?Paginator $paginator = null;
/**
* Reference to the original AnikeenId client
*/ */
public AnikeenId $anikeenId; public AnikeenId $anikeenId;
public function __construct(public ?ResponseInterface $response, public ?Exception $exception = null, public ?Paginator $paginator = null) /**
{ * Constructor
*
* @param ResponseInterface|null $response
* @param Exception|null $exception
* @param AnikeenId $anikeenId
*/
public function __construct(
public ?ResponseInterface $response,
public ?Exception $exception,
AnikeenId $anikeenId
) {
$this->anikeenId = $anikeenId;
$this->success = $exception === null; $this->success = $exception === null;
$this->status = $response ? $response->getStatusCode() : 500; $this->status = $response ? $response->getStatusCode() : 500;
$jsonResponse = $response ? @json_decode($response->getBody()->getContents(), false) : null;
if ($jsonResponse !== null) { $raw = $response ? (string) $response->getBody() : null;
$this->setProperty($jsonResponse, 'data'); $json = $raw ? @json_decode($raw, false) : null;
$this->setProperty($jsonResponse, 'total');
$this->setProperty($jsonResponse, 'pagination'); if ($json !== null) {
// Pagination info
$this->links = $json->links ?? null;
$this->meta = $json->meta ?? null;
// Determine data shape
if (isset($json->data)) {
if ($this->links !== null || $this->meta !== null) {
// Paginated: always array
$this->data = is_array($json->data) ? $json->data : [$json->data];
} else {
// Non-paginated: single object
$this->data = $json->data;
}
} else {
// No 'data' key: treat entire payload
if ($this->links !== null || $this->meta !== null) {
// Paginated but missing data key: fallback to empty array
$this->data = [];
} else {
$this->data = $json;
}
}
// Total items
if (isset($json->meta->total)) {
$this->total = (int) $json->meta->total;
} elseif (isset($json->total)) {
$this->total = (int) $json->total;
} else {
// count array or single object
if (is_array($this->data)) {
$this->total = count($this->data);
} elseif ($this->data !== null) {
$this->total = 1;
}
}
// Initialize paginator only if pagination present
if ($this->links !== null || $this->meta !== null) {
$this->paginator = Paginator::from($this); $this->paginator = Paginator::from($this);
} }
} }
/**
* Sets a class attribute by given JSON Response Body.
*/
private function setProperty(stdClass $jsonResponse, string $responseProperty, string $attribute = null): void
{
$classAttribute = $attribute ?? $responseProperty;
if (property_exists($jsonResponse, $responseProperty)) {
$this->{$classAttribute} = $jsonResponse->{$responseProperty};
} elseif ($responseProperty === 'data') {
$this->{$classAttribute} = $jsonResponse;
}
} }
/** /**
* Returns whether the query was successfully. * Was the request successful?
*/ */
public function success(): bool public function success(): bool
{ {
@@ -77,47 +122,46 @@ class Result
} }
/** /**
* Returns the last HTTP or API error. * Get last error message
*/ */
public function error(): string public function error(): string
{ {
// TODO Switch Exception response parsing to this->data if ($this->exception === null || !method_exists($this->exception, 'getResponse')) {
if ($this->exception === null || !$this->exception->hasResponse()) {
return 'Anikeen ID API Unavailable'; return 'Anikeen ID API Unavailable';
} }
$exception = (string)$this->exception->getResponse()->getBody(); $resp = $this->exception->getResponse();
$exception = @json_decode($exception); $body = $resp ? (string) $resp->getBody() : null;
if (property_exists($exception, 'message') && !empty($exception->message)) { $err = $body ? @json_decode($body) : null;
return $exception->message; if (isset($err->message) && $err->message !== '') {
return $err->message;
} }
return $this->exception->getMessage(); return $this->exception->getMessage();
} }
/** /**
* Shifts the current result (Use for single user/video etc. query). * For paginated data: shift first element; for single object: return it
*/ */
public function shift(): mixed public function shift(): mixed
{ {
if (!empty($this->data)) { if (is_array($this->data)) {
$data = $this->data; return array_shift($this->data);
return array_shift($data);
} }
return $this->data;
return null;
} }
/** /**
* Return the current count of items in dataset. * Count of items in data
*/ */
public function count(): int public function count(): int
{ {
if (is_array($this->data)) {
return count($this->data); return count($this->data);
} }
return $this->data !== null ? 1 : 0;
}
/** /**
* Set the Paginator to fetch the next set of results. * Fetch next page paginator
*/ */
public function next(): ?Paginator public function next(): ?Paginator
{ {
@@ -125,7 +169,7 @@ class Result
} }
/** /**
* Set the Paginator to fetch the last set of results. * Fetch previous page paginator
*/ */
public function back(): ?Paginator public function back(): ?Paginator
{ {
@@ -133,70 +177,61 @@ class Result
} }
/** /**
* Get rate limit information. * 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;
} }
$rateLimit = [ $info = [
'limit' => (int) $this->response->getHeaderLine('X-RateLimit-Limit'), 'limit' => (int) $this->response->getHeaderLine('X-RateLimit-Limit'),
'remaining' => (int) $this->response->getHeaderLine('X-RateLimit-Remaining'), 'remaining' => (int) $this->response->getHeaderLine('X-RateLimit-Remaining'),
'reset' => (int) $this->response->getHeaderLine('Retry-After'), 'reset' => (int) $this->response->getHeaderLine('Retry-After'),
]; ];
if ($key === null) { return $key ? ($info[$key] ?? null) : $info;
return $rateLimit;
}
return $rateLimit[$key];
} }
/** /**
* Insert users in data response. * Insert related users into each data item (for arrays)
*/ */
public function insertUsers(string $identifierAttribute = 'user_id', string $insertTo = 'user'): self public function insertUsers(string $identifierAttribute = 'user_id', string $insertTo = 'user'): self
{ {
$data = $this->data; if (!is_array($this->data)) {
$userIds = collect($data)->map(function ($item) use ($identifierAttribute) {
return $item->{$identifierAttribute};
})->toArray();
if (count($userIds) === 0) {
return $this; return $this;
} }
$users = collect($this->anikeenId->getUsersByIds($userIds)->data); $ids = array_map(fn($item) => $item->{$identifierAttribute} ?? null, $this->data);
$dataWithUsers = collect($data)->map(function ($item) use ($users, $identifierAttribute, $insertTo) { $ids = array_filter($ids);
$item->$insertTo = $users->where('id', $item->{$identifierAttribute})->first(); if (empty($ids)) {
return $this;
return $item; }
}); $users = $this->anikeenId->getUsersByIds($ids)->data;
$this->data = $dataWithUsers->toArray(); foreach ($this->data as &$item) {
$item->{$insertTo} = collect($users)->firstWhere('id', $item->{$identifierAttribute});
}
return $this; return $this;
} }
/** /**
* Set the Paginator to fetch the first set of results. * Fetch first page paginator
*/ */
public function first(): ?Paginator public function first(): ?Paginator
{ {
return $this->paginator?->first(); return $this->paginator?->first();
} }
/**
* Original response
*/
public function response(): ?ResponseInterface public function response(): ?ResponseInterface
{ {
return $this->response; return $this->response;
} }
public function dump(): void
{
dump($this->data());
}
/** /**
* Get the response data, also available as public attribute. * Access raw data
*/ */
public function data(): array public function data(): mixed
{ {
return $this->data; return $this->data;
} }
@@ -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);
}
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace Anikeen\Id\Traits;
use Anikeen\Id\ApiOperations\Delete;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Result;
trait SshKeysTrait
{
use Get, Post, Delete;
/**
* Get currently authed user with Bearer Token
*/
public function getSshKeysByUserId(int $id): Result
{
return $this->get("v1/users/$id/ssh-keys/json", [], null);
}
/**
* Creates ssh key for the currently authed user
*/
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
*/
public function deleteSshKey(int $id): Result
{
return $this->delete("v1/ssh-keys/$id", []);
}
}
-39
View File
@@ -1,39 +0,0 @@
<?php
namespace Anikeen\Id\Traits;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\Result;
trait UsersTrait
{
use Get;
/**
* Get currently authed user with Bearer Token
*/
public function getAuthedUser(): Result
{
return $this->get('v1/user');
}
/**
* Creates a new user on behalf of the current user.
*/
public function createUser(array $parameters): Result
{
return $this->post('v1/users', $parameters);
}
/**
* Checks if the given email exists.
*/
public function isEmailExisting(string $email): Result
{
return $this->post('v1/users/check', [
'email' => $email,
]);
}
}
+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);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?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_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) ?? [];
}
}