# Anikeen ID [![Latest Stable Version](https://img.shields.io/packagist/v/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) PHP Anikeen ID API Client for Laravel 11+ ## Table of contents 1. [Installation](#installation) 2. [Configuration](#configuration) 3. [General](#general) 4. [Examples](#examples) 5. [Documentation](#documentation) 6. [Development](#Development) ## Installation ``` composer require anikeen/id ``` ## Configuration Add environmental variables to your `.env` file: ``` ANIKEEN_ID_KEY= ANIKEEN_ID_SECRET= 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. Add to `config/services.php` file: ```php 'anikeen' => [ 'mode' => env('ANIKEEN_ID_MODE'), 'client_id' => env('ANIKEEN_ID_KEY'), 'client_secret' => env('ANIKEEN_ID_SECRET'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'base_url' => env('ANIKEEN_ID_BASE_URL'), ], ``` ### Event Listener In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: ```php 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->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then $subscription->pause(); // expires when the paid period ends $subscription->resume(); // expired subscriptions start a new period $subscription->revoke(refund: false); // cancel $subscriptions->find($id); // null if unknown or not this user's $subscriptions->all(['group' => $team->id, 'active' => true]); ``` The user's Anikeen ID is read from the billable's `anikeen_id` attribute (`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the subscriptions it created (plus those of clients delegated to it). Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`, `idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`: ```php try { $subscription->revoke(); } catch (ApiException $e) { if ($e->isNotFound() || $e->error === 'already_cancelled') { // already gone } elseif ($e->isTemporary()) { // retry later } else { throw $e; } } ``` ### App orders (trusted apps, no user token) Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): ```php $team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first app(AnikeenId::class)->appOrders()->pending(); // of all users $team->owner->appOrders()->find($orderId); // null if unknown or not this user's ``` Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = no more automatic retries) and `last_payment_error`. ### Checkout sessions (the user confirms several subscriptions at once) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`. Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly; sessions are opt-in. Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices: ```php use Anikeen\Id\Exceptions\ApiException; $session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token 'return_url' => route('builder.done'), // same origin as one of your redirect URIs 'metadata' => ['cart' => $cart->id], // returned as is 'idempotency_key' => "cart:{$cart->id}", // retries return the same session 'subscriptions' => $cart->items->map(fn ($item) => [ 'group' => $team->id, 'name' => $item->product->name, 'description' => $item->label, 'unit' => $item->unit, // hour, day, week, month, year 'price' => $item->priceAfterCoupon(), // net, per unit 'payload' => ['cart_item' => $item->id], ])->all(), ]); $cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]); return redirect($session->confirmationUrl()); // later: https://console.test/builder/done?status=completed&checkout_session=01K… ``` Provision when the webhook arrives (or after checking the session through the API; never trust the query string): ```php use Anikeen\Id\Webhooks\EventType; match ($event['type']) { EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null), default => null, }; app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session $session->refresh()->subscriptions(); // AppSubscription objects $session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed) ``` While a session is open, its subscriptions can't be checked out or repriced by the app (409 `in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`, `cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired). `subscription.activated` / `subscription.cancelled` are still sent per subscription, before the `checkout.*` event of the session. ### Selling as a third-party app (user confirms on Anikeen ID) Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`: ```php use Anikeen\Id\Exceptions\ConfirmationRequiredException; $order = $user->orders()->create([ 'billing_address_id' => $addressId, 'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']], ]); return redirect($order->confirmationUrl(route('shop.done'))); // later: https://your-app.test/shop/done?status=completed&order=01J… ``` `return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`, `pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)` instead of trusting the query string. Subscriptions work the same way (`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`). Unconfirmed bookings are cancelled after 24 hours. Physical goods need a shipping address (`first_name`, `last_name`, `address`, `postal_code`, `city`, `country_iso`). The user can change both addresses on the confirmation page, so read the shipping address from the order (or the `order.completed` webhook) before you ship. An item's `image_url` is copied to Anikeen ID and stays visible on the order and invoice: ```php $order = $user->orders()->create([ 'billing_address_id' => $addressId, 'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address' => 'Main St', 'house_number' => '1', 'postal_code' => '10115', 'city' => 'Berlin', 'country_iso' => 'DE'], 'items' => [ ['type' => 'physical', 'name' => 'T-shirt', 'unit' => 'piece', 'units' => 1, 'gross_price' => '24.90', 'image_url' => 'https://shop.test/img/shirt.png'], ['type' => 'physical-reduced', 'name' => 'Artbook', 'unit' => 'piece', 'units' => 1, 'gross_price' => '19.95'], ], ]); ``` Once a paid order is sent, create a shipment. Anikeen ID adds its delivery note to the user's order documents (with the tracking link), mails it to the user (`'notify_customer' => false` if your shop sends its own mail) and sends `order.shipped` to your webhook endpoints. Without `items`, everything physical that is left is shipped; partial deliveries name the items: ```php $order = $user->orders()->find($orderId); $shipment = $order->shipments()->create([ 'carrier' => 'DHL', 'tracking_number' => '00340434161234567890', 'tracking_url' => 'https://www.dhl.de/…?piececode=00340434161234567890', // 'items' => [['order_item_id' => $itemId, 'units' => 1]], ]); $shipment->number; // LS-2026-0001 $shipment->deliveryNote(); // the delivery note as PDF ``` Only the app that created the order (or a trusted app) can ship it, and only once it is paid. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. ### Webhooks Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. ```php use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Webhooks\EventType; use Anikeen\Id\Webhooks\Webhook; Route::post('webhooks/anikeen', function (Request $request) { try { $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); } catch (SignatureVerificationException) { abort(400); } match ($event['type']) { EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']), EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']), EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']), default => null, }; return response()->noContent(); })->withoutMiddleware(VerifyCsrfToken::class); ``` Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and reload the object through the API when the current state matters. ### 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 use Anikeen\Id\AnikeenId; public function boot(): void { AnikeenId::useAccessTokenField('anikeen_id_access_token'); 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(Request::class), $config['model'], $config['fields'] ?? [], ); }); } ``` reference the guard in the `guards` configuration of your `auth.php` configuration file: ```php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'anikeen', 'provider' => 'anikeen', ], ], ``` reference the provider in the `providers` configuration of your `auth.php` configuration file: ```php 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, 'fields' => ['first_name', 'last_name', 'email'], ], ], ``` ## General #### Setters and Getters ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setClientId('abc123'); $anikeenId->setClientSecret('abc456'); $anikeenId->setToken('abcdef123456'); $anikeenId = $anikeenId->withClientId('abc123'); $anikeenId = $anikeenId->withClientSecret('abc123'); $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 ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setClientId('abc123'); $anikeenId->setToken('abcdef123456'); $result = $anikeenId->getAuthedUser(); $user = $userResult->shift(); ``` ```php $anikeenId->setToken('uvwxyz456789'); $result = $anikeenId->getAuthedUser(); ``` ```php $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser(); ``` #### Facade ```php use Anikeen\Id\Facades\AnikeenId; AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); ``` ## Documentation ## AnikeenId ### Oauth ```php public function retrievingToken(string $grantType, array $attributes): Result ``` ### ManagesPricing ```php public function createOrderPreview(array $attributes = []): Result ``` ### ManagesProducts ```php public function products(): Result public function boxes(): Result ``` ### ManagesShippingLabels ```php public function shippingLabels(array $parameters = []): Result public function shippingLabel(string $id): Result public function shippingLabelPdf(string $id): ?string public function issueShippingLabel(array $attributes): Result public function shipShippingLabel(array $hex): Result public function shippingPriceList(): Result ``` ### ManagesUserBilling ```php public function appOrders(): AppOrders public function appCheckoutSessions(): AppCheckoutSessions public function createOrderForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result ``` ### ManagesSocialiteUsers ```php public function socialiteUsers(): SocialiteUsers ``` ### ManagesSshKeys ```php public function sshKeysByUserId(string $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 appCheckoutSessions(): AppCheckoutSessions public function checkoutSessions(): CheckoutSessions 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) ## Development #### Run Tests ```shell composer test ``` ```shell BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test ``` #### Generate Documentation ```shell composer docs ```