Compare commits

..
Author SHA1 Message Date
envoyr 2b47be157b Release 3.2.0 2026-11-06 11:38:47 +00:00
envoyr 2796befc7d Merge pull request #19 from anikeen-com/feature/bill-elapsed-periods
Bill elapsed periods and look up users by email
2026-11-06 11:26:19 +00:00
envoyr 64ccb84987 Document elapsed periods and user accounts 2026-11-06 10:26:51 +00:00
envoyr 7813e8fbaa Look up users by email and document invitations 2026-11-06 10:26:51 +00:00
envoyr 27e868720f Bill elapsed periods on app subscription checkout 2026-11-06 10:26:51 +00:00
envoyr fb71b172f2 Merge pull request #18 from anikeen-com/feature/reseller-scopes
Add product and reseller scopes (3.1.0)
2026-11-05 00:31:01 +01:00
Maurice Preuß 72f761b077 Release 3.1.0 2026-11-05 00:21:52 +01:00
Maurice Preuß e8432df997 Drop the full products scope the Console does not accept
The Console's product APIs accept user, user:read and products:read only
(decision 21 names products:read). A token with just "products" was
refused everywhere despite its consent text.
2026-11-05 00:11:35 +01:00
Maurice Preuß d99512a359 Add product and reseller scopes 2026-11-04 23:27:25 +01:00
envoyr 0ed6934da3 Merge pull request #16 from anikeen-com/fix/dependabot-alerts
Require patched Laravel and php-jwt versions
2026-11-04 22:09:39 +01:00
Maurice Preuß df7e506a5e Require patched Laravel and php-jwt versions
Raise the minimum versions so installs can no longer resolve releases with
known advisories (Dependabot alerts):

- laravel/framework / illuminate/*: ^12.69|^13.0 (TokenGuard auth bypass,
  CRLF injection in the email rule, signed URL path confusion, debug page XSS).
  Laravel 11 is end of life and gets no fixes, so support for it is dropped.
- firebase/php-jwt: ^7.0 (weak encryption in 6.x).
- orchestra/testbench: ^10.0|^11.0 to match.

Tests pass with both the lowest and the highest allowed versions.
2026-11-04 22:05:30 +01:00
envoyr 61385df92f Merge pull request #15 from anikeen-com/feature/user-api-exceptions
Throw ApiException for requests with the user's token too
2026-11-04 18:44:38 +01:00
Maurice PreußandClaude Opus 5.5 57ce0b4a79 Throw ApiException for requests with the user's token too
Requests with the user's token threw a bare ResourceException with a
message like "… for resource [Anikeen\Id\Resources\Order]" and no
error code, while app requests threw ApiException. Both throw
ApiException now: status (also getCode()), Anikeen ID's error code and
message, isNotFound(), isConflict(), isTemporary(). Failed lists throw
CollectionException, now a subclass of ApiException. A request without
a response (connection refused, timeout) throws ApiException with
status 0 instead of a Guzzle exception.

ApiException extends ResourceException, so existing catch blocks keep
working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:28:50 +01:00
envoyr a4345e72ec Merge pull request #14 from anikeen-com/docs/remove-live-test-hint
Document payments end to end, add subscription status helpers
2026-11-04 17:56:25 +01:00
Maurice PreußandClaude Opus 5.5 ce820cd198 Add subscription status helpers
status() returns draft, trial, active, paused, cancelling, expired or
cancelled (SubscriptionStatus constants), derived from the fields the
same way Anikeen ID derives its status, so it also works with older
responses. isActive() is true while the subscription runs and is paid.
Both Subscription and AppSubscription have them. Document the states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:15:48 +01:00
Maurice PreußandClaude Opus 5.5 2faccac0f4 Document Stripe test cards on staging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:09:47 +01:00
Maurice PreußandClaude Opus 5.5 e3e59fb8a4 Document payments end to end
Add a Payments guide that follows a typical third-party shop: sign-in
scopes, user table and token storage, creating orders and subscriptions,
the confirmation and return flow, statuses, prices and VAT, shipments,
webhooks with Laravel 11 CSRF exclusion, error handling and testing.

Move the trusted-app sections and API authentication after it, add the
webhook secret to the config, fix the broken General/Examples snippets
and the Scope enum link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:06:23 +01:00
Maurice PreußandClaude Opus 5.5 26716e7bba Add CLAUDE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:00:29 +01:00
Maurice PreußandClaude Opus 5.5 2acbef72c8 Drop the live API test hint from the README
The tests use a mocked HTTP client and ignore these variables. Also move
the shipping address and shipment docs into README.stub, so
composer docs no longer drops them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:00:24 +01:00
envoyr 396eb889c4 Merge pull request #13 from anikeen-com/fix/guard-registration
Fix the anikeen API guard and register its user provider
2026-11-03 23:13:21 +01:00
20 changed files with 1744 additions and 354 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Changelog
## 3.2.0 (2026-11-06)
Elapsed-period billing and user lookup; they need an Anikeen ID release that supports them.
- `AppSubscription::checkout()` takes `billElapsedPeriods` and `maxNetAmount`: a subscription
started more than one period ago is billed for its elapsed periods too, up to the given net
amount (409 `elapsed_amount_exceeded` with `net_amount`, `elapsed_periods_not_allowed`). Calls
without the new arguments send the same request as before.
- `ApiException::body()` returns the JSON body of Anikeen ID's error response, e.g. its
`net_amount`.
- Add `AnikeenId::findUserByEmail()` for trusted apps: looks up an account by email address
(regardless of case) without creating one, null when there is none.
- `createUser()` documents `name`, `password_confirmation` and `send_invitation` (a newly created
account gets an invitation mail to set a password) and the response fields `created` and
`invitation_sent`.
## 3.1.0 (2026-11-05)
- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs
of the Anikeen Console.
- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and
`reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`,
`Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console.
- Document the scope schema and the new scopes in the README.
Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags.
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
Guidance for Claude Code (and other contributors) working in this repository.
## What this is
`anikeen/id` is the public PHP client for the Anikeen ID API, shipped as a Laravel package
(Laravel 11–13, PHP 8.2+). It covers OAuth/Socialite login, an API token guard, and the billing
API (subscriptions, orders, checkout sessions, shipments, invoices, webhooks). This is a public
repo published on Packagist: never commit secrets, internal hostnames or customer data.
## Commands
```shell
composer install
composer test # vendor/bin/phpunit
vendor/bin/phpunit --filter AppSubscriptionsTest
composer docs # regenerate README.md from README.stub
```
CI (`.github/workflows/tests.yml`) runs PHPUnit on PHP 8.3 and 8.4.
## Layout
- `src/Id/AnikeenId.php` – the API client (singleton, facade `AnikeenId`). Reads
`config('services.anikeen.*')`; `mode=staging` switches to the staging base URL unless
`base_url` is set. `request()` wraps Guzzle and returns a `Result`.
- `src/Id/Billable.php` – trait for the app's user model; composes the `Concerns/Manages*` traits.
Its API caches are declared properties on purpose (undeclared ones would be treated as Eloquent
attributes and break `save()`). Add a new cache property there when a concern caches.
- `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client;
traits on `Billable` act for one user.
- `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via
`::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for
lists), no response `::unreachable()`, `confirmation_required` a `ConfirmationRequiredException`.
All extend `ResourceException`.
- `src/Id/Resources/App*` – "app" variants that use the app's own client-credentials token
(`AnikeenId::asApp()`, `Support/AppRequest`) instead of the user's token, for trusted apps.
They must not send any request until a method is called.
- `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen`
guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled
`oauth-public*.key`), scope middleware.
- `src/Id/Webhooks/` – signature verification (`Webhook::constructEvent`) and `EventType`
constants.
- `src/Support/Query.php` – query-string builder.
- `generator/generate-docs.php` – reflects the public methods of `AnikeenId` and `Billable` traits
into the README.
## Conventions
- **README:** edit `README.stub`, never `README.md` directly, then run `composer docs`. The
`<!-- GENERATED-DOCS -->` marker is replaced with the method reference. Commit both files.
- **New endpoints:** add a method to the matching `Concerns/Manages*` trait (or a resource class),
document request attributes with an `array{…}` PHPDoc shape and a short description per key,
and add an example to `README.stub` when it is a new feature.
- **Tests:** extend `Anikeen\Id\Tests\TestCase` (Orchestra Testbench). No live API: queue
responses with `$this->respond($status, $body)` (Guzzle `MockHandler`) and assert the sent
requests via `$this->sent` / `$this->sentJson($i)` – method, path, `Authorization` header and
JSON body. The app token is faked as `app-token`.
- **Compatibility:** this is a library used by several apps. Keep public method signatures
backwards compatible; add optional parameters instead of changing existing ones.
- Code, comments, docs and commit messages in English. Commit subjects are short, imperative,
sentence case (e.g. "Ship orders: shipments with delivery notes"). Work happens on feature
branches merged via PR into `main`.
+511 -184
View File
@@ -4,16 +4,23 @@
[![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+
PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
## Table of contents
1. [Installation](#installation)
2. [Configuration](#configuration)
3. [General](#general)
4. [Examples](#examples)
5. [Documentation](#documentation)
6. [Development](#Development)
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments)
5. [Trusted apps](#trusted-apps)
6. [API authentication](#api-authentication)
7. [General](#general)
8. [Examples](#examples)
9. [Documentation](#documentation)
10. [Development](#development)
## Installation
@@ -23,12 +30,20 @@ composer require anikeen/id
## Configuration
Add environmental variables to your `.env` file:
Register your app in Anikeen ID first. You need:
- an OAuth client with your callback URL as redirect URI (e.g. `https://shop.test/auth/callback`),
- a webhook endpoint (e.g. `https://shop.test/webhooks/anikeen`) if you sell something; Anikeen ID
shows its signing secret `whsec_…`,
- optionally a logo, shown to your users on the login and confirmation pages.
Add the credentials to your `.env` file:
```
ANIKEEN_ID_KEY=
ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
ANIKEEN_ID_WEBHOOK_SECRET=
```
To switch from `production` to `staging` use following variable:
@@ -48,12 +63,15 @@ Add to `config/services.php` file:
'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'),
'webhook_secret' => env('ANIKEEN_ID_WEBHOOK_SECRET'),
],
```
## Sign in with 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` boot method:
Since 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
@@ -64,21 +82,19 @@ public function boot(): void
}
```
### Registering Middleware
### User model
Append it to the global middleware stack in your application's `bootstrap/app.php` file:
Your users table needs the user's Anikeen ID and their tokens:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
Schema::table('users', function (Blueprint $table) {
$table->string('anikeen_id')->nullable()->unique();
$table->text('anikeen_id_access_token')->nullable();
$table->text('anikeen_id_refresh_token')->nullable();
});
```
### Implementing Billable
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
Add the `Billable` trait to the user model. Keep the tokens encrypted and out of JSON:
```php
use Anikeen\Id\Billable;
@@ -87,11 +103,397 @@ class User extends Authenticatable
{
use Billable;
// Your model code...
protected $hidden = ['password', 'remember_token', 'anikeen_id_access_token', 'anikeen_id_refresh_token'];
protected function casts(): array
{
return [
'anikeen_id_access_token' => 'encrypted',
'anikeen_id_refresh_token' => 'encrypted',
];
}
}
```
then, you can use the `Billable` trait methods in your user model.
All user methods (`$user->orders()`, `$user->subscriptions()`, …) call the API with the user's
access token from `anikeen_id_access_token`.
### Login and callback
Ask for the scopes your app needs (see [Scope](src/Id/Enums/Scope.php)). To sell orders and
subscriptions, a shop typically asks for:
```php
use Laravel\Socialite\Facades\Socialite;
Route::get('auth/redirect', fn () => Socialite::driver('anikeen')
->scopes(['user:read', 'addresses:read', 'orders', 'subscriptions'])
->redirect());
Route::get('auth/callback', function () {
$account = Socialite::driver('anikeen')->user();
$user = User::updateOrCreate(['anikeen_id' => $account->getId()], [
'name' => $account->getName() ?: $account->getNickname(),
'email' => $account->getEmail(),
'anikeen_id_access_token' => $account->token,
'anikeen_id_refresh_token' => $account->refreshToken,
]);
Auth::login($user, remember: true);
return redirect()->intended('/');
});
```
Access tokens expire. Get a new one with the refresh token (or send the user through the login
again):
```php
$result = app(\Anikeen\Id\AnikeenId::class)->refreshToken($user->anikeen_id_refresh_token);
if ($result->success()) {
$user->update([
'anikeen_id_access_token' => $result->data()->access_token,
'anikeen_id_refresh_token' => $result->data()->refresh_token,
]);
}
```
### Scopes
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
`resource:client` access to what the app or token created itself. All of them are constants on
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
these scopes for its product and reseller APIs:
| Scope | Constant | Grants |
|-------|----------|--------|
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`.
### Change the default access token / refresh token field name
If your access / refresh token fields differ 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');
}
```
## Payments
### Which way fits?
| You want to sell | Use | Token | Who starts the payment |
|---|---|---|---|
| One-off purchases (digital or physical goods) | [Orders](#selling-an-order) | user | the user, on Anikeen ID |
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID |
| Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) |
Most apps are third-party apps: they create an order or subscription as a draft, send the user to
Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks
for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps).
The flow, as in a typical shop:
1. The user signs in with Anikeen ID ([above](#sign-in-with-anikeen-id)).
2. Your app creates the order: `$user->orders()->create([...])`.
3. Redirect the user to `$order->confirmationUrl($returnUrl)`.
4. The user confirms and pays on Anikeen ID, then comes back to `$returnUrl?status=…&order=…`.
5. Your app reads the order through the API (never trust the query string) and shows the result.
6. The `order.completed` webhook arrives; fulfil the order there.
### Selling an order
```php
use Anikeen\Id\Exceptions\ResourceException;
$order = $user->orders()->create([
'billing_address_id' => $user->getUserData()->billing_address_id,
'items' => [
['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99'],
],
]);
$shopOrder = ShopOrder::create(['user_id' => $user->id, 'anikeen_id' => $order->id, 'status' => $order->status]); // your own order model
return redirect()->away($order->confirmationUrl(route('orders.return', $shopOrder)));
// later: https://shop.test/orders/1/return?status=completed&order=01J…
```
When the user has no billing address on Anikeen ID yet, pass one instead of the ID. A name and
the country are enough; the user completes it on the confirmation page:
```php
'billing_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'country_iso' => 'DE', 'email' => $user->email],
```
Back in your app, read the order to know its state:
```php
Route::get('orders/{shopOrder}/return', function (ShopOrder $shopOrder) {
$order = auth()->user()->orders()->find($shopOrder->anikeen_id);
$shopOrder->update(['status' => $order->status]);
return redirect()->route('orders.show', $shopOrder);
});
```
The order's `status`:
| `status` | Meaning |
|---|---|
| `draft` | Created, not confirmed by the user yet |
| `pending` | Confirmed, the payment is still processing |
| `completed` | Paid |
| `cancelled` | The user cancelled, or it wasn't confirmed within 24 hours |
`return_url` must have the origin of one of your app's redirect URIs. The `status` in its query
string (`completed`, `pending` or `cancelled`) is only a hint for the message you show.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
### Prices and VAT
Anikeen ID calculates VAT from the user's billing address and the item `type`:
| `type` | For | VAT in Germany |
|---|---|---|
| `digital` | Downloads, streams, services | 19 % |
| `physical` | Goods you ship | 19 % |
| `physical-reduced` | Goods with the reduced rate (books, event tickets, …) | 7 % |
Give each item either `gross_price` (VAT included, what the user pays) or `price` (net), per unit.
`unit` is `piece` or `onetime` for orders; `units` is the quantity. Subscription prices are always
net per unit (`hour`, `day`, `week`, `month`, `year`).
To show a total before the user confirms, ask for a preview:
```php
$preview = app(\Anikeen\Id\AnikeenId::class)->createOrderPreview([
'country_iso' => 'DE',
'items' => [['type' => 'physical', 'name' => 'T-shirt', 'gross_price' => '24.90', 'unit' => 'piece', 'units' => 1]],
])->shift();
$preview->gross_total;
```
### Physical goods and shipments
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
```
To know what is left to ship, compare the order's items with its shipments:
```php
$items = $order->orderItems()->toArray(); // id, type, name, units, …
$shipments = $order->shipments()->toArray(); // each with items: [{order_item_id, units}]
```
Only the app that created the order (or a trusted app) can ship it, and only once it is paid.
### Subscriptions
Subscriptions work like orders: create a draft, let the user confirm it, read it back.
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'description' => 'All streams, every month',
'unit' => 'month',
'price' => 6.714286, // net per unit, 7.99 with 19 % VAT
]);
return redirect()->away($subscription->confirmationUrl(route('pass.return')));
// later: https://shop.test/pass/return?status=completed&subscription=01J…
```
After the return, read the subscription and check its state:
```php
$subscription = $user->subscriptions()->find($id);
if ($subscription->isActive()) {
// grant access
}
```
`status()` returns one of the `Anikeen\Id\Enums\SubscriptionStatus` constants (the API sends the
same value as `status`):
| `status()` | Meaning | `isActive()` |
|---|---|---|
| `draft` | Created, not confirmed by the user yet | no |
| `trial` | Running, in its trial period | yes |
| `active` | Running and billed | yes |
| `paused` | Runs until the paid period ends, then expires (`resume()` to continue) | yes |
| `cancelling` | Runs until a scheduled cancellation takes effect | yes |
| `expired` | Paused and ran out; `resume()` starts a new period | no |
| `cancelled` | Cancelled for good | no |
There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`,
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
### Webhooks
Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of
your app (see [`EventType`](src/Id/Webhooks/EventType.php) for all of them). Verify each request
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three
days.
Exclude the route from CSRF protection in `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})
```
```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);
}
if (! ProcessedEvent::firstOrCreate(['id' => $event['id']])->wasRecentlyCreated) {
return response()->noContent(); // already handled
}
$object = $event['data']['object'];
match ($event['type']) {
EventType::ORDER_COMPLETED => Shop::fulfil($object['id'], $object['shipping_address']),
EventType::ORDER_CANCELLED => Shop::release($object['id']),
EventType::SUBSCRIPTION_ACTIVATED => Pass::grant($object['id']),
EventType::SUBSCRIPTION_CANCELLED => Pass::revoke($object['id']),
EventType::CHECKOUT_COMPLETED => Panel::provision($object['subscription_ids']),
default => null,
};
return response()->noContent();
});
```
`data.object` is the order, subscription or checkout session as the API returns it. 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. The admin panel can send a
`webhook.test` event to check your endpoint.
### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
well as with the app's (`app*()`). It carries:
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$order = $user->orders()->create($attributes);
} catch (ApiException $e) {
if ($e->isTemporary()) {
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
}
report($e);
return back()->with('error', $e->getMessage());
}
```
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
keep working.
### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
Payments on staging run in Stripe's test mode: pay with Stripe's
[test cards](https://docs.stripe.com/testing), e.g. `4242 4242 4242 4242` with any future expiry
date and any CVC. No money is charged.
Point `ANIKEEN_ID_BASE_URL` at your own Anikeen ID instance if you run one locally.
In unit tests, swap the HTTP client for a Guzzle `MockHandler`:
```php
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(201, ['Content-Type' => 'application/json'], json_encode(['data' => ['id' => '01JORDER', 'status' => 'draft']])),
]);
$this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new \GuzzleHttp\Client([
'handler' => \GuzzleHttp\HandlerStack::create($mock),
'base_uri' => 'https://id.test/api/',
])));
```
## Trusted apps
Apps operated by Anikeen can be marked as trusted. They can charge without asking the user and
use their own token (client credentials), so billing keeps working when the user's login expired.
### App subscriptions (trusted apps, no user token)
@@ -112,6 +514,11 @@ $subscription = $subscriptions->create([
$subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->checkout( // running for longer: also bill the periods that have ended
startsAt: $domain->registered_at,
billElapsedPeriods: true,
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
);
$subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel
@@ -124,10 +531,44 @@ 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).
#### Starting from an earlier date
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
the customer came to your app. If the first period from that date is still running, it is billed
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
`checkout()` throws an `InvalidArgumentException` without sending anything):
- every period that has ended plus the running one is billed now, as one line with the full
service period. It lands on the user's collective order of the current month and is invoiced and
charged with the next monthly bill; nothing is back-dated.
- the start may be at most about three years ago (422 otherwise).
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
you can show it and retry.
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
before, replaces another one, or is billed by the hour or day with a monthly cap).
```php
try {
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
} catch (ApiException $e) {
if ($e->error === 'elapsed_amount_exceeded') {
$actual = $e->body()->net_amount; // ask again with the real amount
} else {
throw $e;
}
}
```
#### Error codes
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()`:
`idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
`elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
`isTemporary()`:
```php
try {
@@ -166,7 +607,7 @@ period, confirms (all subscriptions start, or none) or cancels, and returns to `
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:
Example: a cart with several products and coupon prices:
```php
use Anikeen\Id\Exceptions\ApiException;
@@ -214,113 +655,45 @@ While a session is open, its subscriptions can't be checked out or repriced by t
`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)
### User accounts (trusted apps, no user token)
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`:
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
are matched regardless of case.
```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
$anikeenId = app(AnikeenId::class);
$order = $user->orders()->create([
'billing_address_id' => $addressId,
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']],
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
$account?->data->id; // also name, email, email_verified, has_billing_address
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
'name' => 'Erika Mustermann',
'email' => 'erika@example.com',
'send_invitation' => true, // a new account gets a mail with a link to set a password
]);
return redirect($order->confirmationUrl(route('shop.done')));
// later: https://your-app.test/shop/done?status=completed&order=01J…
$result->data->created; // false: the account already existed (and is unchanged)
$result->data->invitation_sent; // only for an account created by this call without a password
```
`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.
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
other than an unknown address.
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:
## API authentication
Use this when your own API accepts access tokens issued by Anikeen ID.
### Registering Middleware
To call your API from your own frontend with the user's session, append the middleware to the
web middleware group in your application's `bootstrap/app.php` file:
```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');
}
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
```
### Implementing Auth (API tokens)
@@ -408,70 +781,36 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456');
```
#### Error handling for an unsuccessful query:
#### Results and resources
Low-level methods on `AnikeenId` (e.g. `getAuthedUser()`, `createOrderPreview()`,
`refreshToken()`) return a `Result`. Check it before you use it:
```php
$result = $anikeenId->sshKeysByUserId('someInvalidId');
$result = $anikeenId->getAuthedUser();
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
$user = $result->shift(); // the object, or the first item of a list
```
#### Shift result to get single key data:
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ApiException` instead (see [Errors](#errors)).
## Examples
#### Get User SSH Key
#### Get User SSH Keys
Uses the app's own token (client credentials):
```php
$anikeenId = new Anikeen\IdAnikeenId();
$sshKeys = app(Anikeen\Id\AnikeenId::class)->sshKeysByUserId($anikeenUserId);
$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());
foreach ($sshKeys->toArray() as $sshKey) {
echo $sshKey->name;
}
// 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
@@ -479,22 +818,13 @@ echo $preview->gross_total;
```php
$anikeenId = new Anikeen\Id\AnikeenId();
$anikeenId->setClientId('abc123');
$anikeenId->setToken('abcdef123456');
$result = $anikeenId->getAuthedUser();
$user = $userResult->shift();
$user = $anikeenId->getAuthedUser()->shift();
```
```php
$anikeenId->setToken('uvwxyz456789');
$result = $anikeenId->getAuthedUser();
```
```php
$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
$user = $anikeenId->withToken('uvwxyz456789')->getAuthedUser()->shift();
```
#### Facade
@@ -502,7 +832,7 @@ $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
```php
use Anikeen\Id\Facades\AnikeenId;
AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser();
AnikeenId::withToken('abcdef123456')->getAuthedUser();
```
## Documentation
@@ -565,6 +895,7 @@ public function sshKeysByUserId(string $userId): SshKeys
```php
public function getAuthedUser(): Result
public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?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
@@ -678,7 +1009,7 @@ 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/Id/Enums/Scope.php)
## Development
@@ -688,10 +1019,6 @@ public function getBillable(): Billable
composer test
```
```shell
BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test
```
#### Generate Documentation
```shell
+508 -145
View File
@@ -4,16 +4,23 @@
[![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+
PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
## Table of contents
1. [Installation](#installation)
2. [Configuration](#configuration)
3. [General](#general)
4. [Examples](#examples)
5. [Documentation](#documentation)
6. [Development](#Development)
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments)
5. [Trusted apps](#trusted-apps)
6. [API authentication](#api-authentication)
7. [General](#general)
8. [Examples](#examples)
9. [Documentation](#documentation)
10. [Development](#development)
## Installation
@@ -23,12 +30,20 @@ composer require anikeen/id
## Configuration
Add environmental variables to your `.env` file:
Register your app in Anikeen ID first. You need:
- an OAuth client with your callback URL as redirect URI (e.g. `https://shop.test/auth/callback`),
- a webhook endpoint (e.g. `https://shop.test/webhooks/anikeen`) if you sell something; Anikeen ID
shows its signing secret `whsec_…`,
- optionally a logo, shown to your users on the login and confirmation pages.
Add the credentials to your `.env` file:
```
ANIKEEN_ID_KEY=
ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
ANIKEEN_ID_WEBHOOK_SECRET=
```
To switch from `production` to `staging` use following variable:
@@ -48,12 +63,15 @@ Add to `config/services.php` file:
'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'),
'webhook_secret' => env('ANIKEEN_ID_WEBHOOK_SECRET'),
],
```
## Sign in with 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` boot method:
Since 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
@@ -64,21 +82,19 @@ public function boot(): void
}
```
### Registering Middleware
### User model
Append it to the global middleware stack in your application's `bootstrap/app.php` file:
Your users table needs the user's Anikeen ID and their tokens:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
Schema::table('users', function (Blueprint $table) {
$table->string('anikeen_id')->nullable()->unique();
$table->text('anikeen_id_access_token')->nullable();
$table->text('anikeen_id_refresh_token')->nullable();
});
```
### Implementing Billable
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
Add the `Billable` trait to the user model. Keep the tokens encrypted and out of JSON:
```php
use Anikeen\Id\Billable;
@@ -87,11 +103,397 @@ class User extends Authenticatable
{
use Billable;
// Your model code...
protected $hidden = ['password', 'remember_token', 'anikeen_id_access_token', 'anikeen_id_refresh_token'];
protected function casts(): array
{
return [
'anikeen_id_access_token' => 'encrypted',
'anikeen_id_refresh_token' => 'encrypted',
];
}
}
```
then, you can use the `Billable` trait methods in your user model.
All user methods (`$user->orders()`, `$user->subscriptions()`, …) call the API with the user's
access token from `anikeen_id_access_token`.
### Login and callback
Ask for the scopes your app needs (see [Scope](src/Id/Enums/Scope.php)). To sell orders and
subscriptions, a shop typically asks for:
```php
use Laravel\Socialite\Facades\Socialite;
Route::get('auth/redirect', fn () => Socialite::driver('anikeen')
->scopes(['user:read', 'addresses:read', 'orders', 'subscriptions'])
->redirect());
Route::get('auth/callback', function () {
$account = Socialite::driver('anikeen')->user();
$user = User::updateOrCreate(['anikeen_id' => $account->getId()], [
'name' => $account->getName() ?: $account->getNickname(),
'email' => $account->getEmail(),
'anikeen_id_access_token' => $account->token,
'anikeen_id_refresh_token' => $account->refreshToken,
]);
Auth::login($user, remember: true);
return redirect()->intended('/');
});
```
Access tokens expire. Get a new one with the refresh token (or send the user through the login
again):
```php
$result = app(\Anikeen\Id\AnikeenId::class)->refreshToken($user->anikeen_id_refresh_token);
if ($result->success()) {
$user->update([
'anikeen_id_access_token' => $result->data()->access_token,
'anikeen_id_refresh_token' => $result->data()->refresh_token,
]);
}
```
### Scopes
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
`resource:client` access to what the app or token created itself. All of them are constants on
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
these scopes for its product and reseller APIs:
| Scope | Constant | Grants |
|-------|----------|--------|
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`.
### Change the default access token / refresh token field name
If your access / refresh token fields differ 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');
}
```
## Payments
### Which way fits?
| You want to sell | Use | Token | Who starts the payment |
|---|---|---|---|
| One-off purchases (digital or physical goods) | [Orders](#selling-an-order) | user | the user, on Anikeen ID |
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID |
| Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) |
Most apps are third-party apps: they create an order or subscription as a draft, send the user to
Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks
for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps).
The flow, as in a typical shop:
1. The user signs in with Anikeen ID ([above](#sign-in-with-anikeen-id)).
2. Your app creates the order: `$user->orders()->create([...])`.
3. Redirect the user to `$order->confirmationUrl($returnUrl)`.
4. The user confirms and pays on Anikeen ID, then comes back to `$returnUrl?status=…&order=…`.
5. Your app reads the order through the API (never trust the query string) and shows the result.
6. The `order.completed` webhook arrives; fulfil the order there.
### Selling an order
```php
use Anikeen\Id\Exceptions\ResourceException;
$order = $user->orders()->create([
'billing_address_id' => $user->getUserData()->billing_address_id,
'items' => [
['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99'],
],
]);
$shopOrder = ShopOrder::create(['user_id' => $user->id, 'anikeen_id' => $order->id, 'status' => $order->status]); // your own order model
return redirect()->away($order->confirmationUrl(route('orders.return', $shopOrder)));
// later: https://shop.test/orders/1/return?status=completed&order=01J…
```
When the user has no billing address on Anikeen ID yet, pass one instead of the ID. A name and
the country are enough; the user completes it on the confirmation page:
```php
'billing_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'country_iso' => 'DE', 'email' => $user->email],
```
Back in your app, read the order to know its state:
```php
Route::get('orders/{shopOrder}/return', function (ShopOrder $shopOrder) {
$order = auth()->user()->orders()->find($shopOrder->anikeen_id);
$shopOrder->update(['status' => $order->status]);
return redirect()->route('orders.show', $shopOrder);
});
```
The order's `status`:
| `status` | Meaning |
|---|---|
| `draft` | Created, not confirmed by the user yet |
| `pending` | Confirmed, the payment is still processing |
| `completed` | Paid |
| `cancelled` | The user cancelled, or it wasn't confirmed within 24 hours |
`return_url` must have the origin of one of your app's redirect URIs. The `status` in its query
string (`completed`, `pending` or `cancelled`) is only a hint for the message you show.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
### Prices and VAT
Anikeen ID calculates VAT from the user's billing address and the item `type`:
| `type` | For | VAT in Germany |
|---|---|---|
| `digital` | Downloads, streams, services | 19 % |
| `physical` | Goods you ship | 19 % |
| `physical-reduced` | Goods with the reduced rate (books, event tickets, …) | 7 % |
Give each item either `gross_price` (VAT included, what the user pays) or `price` (net), per unit.
`unit` is `piece` or `onetime` for orders; `units` is the quantity. Subscription prices are always
net per unit (`hour`, `day`, `week`, `month`, `year`).
To show a total before the user confirms, ask for a preview:
```php
$preview = app(\Anikeen\Id\AnikeenId::class)->createOrderPreview([
'country_iso' => 'DE',
'items' => [['type' => 'physical', 'name' => 'T-shirt', 'gross_price' => '24.90', 'unit' => 'piece', 'units' => 1]],
])->shift();
$preview->gross_total;
```
### Physical goods and shipments
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
```
To know what is left to ship, compare the order's items with its shipments:
```php
$items = $order->orderItems()->toArray(); // id, type, name, units, …
$shipments = $order->shipments()->toArray(); // each with items: [{order_item_id, units}]
```
Only the app that created the order (or a trusted app) can ship it, and only once it is paid.
### Subscriptions
Subscriptions work like orders: create a draft, let the user confirm it, read it back.
```php
$subscription = $user->subscriptions()->create([
'name' => 'Pixel Stream Pass',
'description' => 'All streams, every month',
'unit' => 'month',
'price' => 6.714286, // net per unit, 7.99 with 19 % VAT
]);
return redirect()->away($subscription->confirmationUrl(route('pass.return')));
// later: https://shop.test/pass/return?status=completed&subscription=01J…
```
After the return, read the subscription and check its state:
```php
$subscription = $user->subscriptions()->find($id);
if ($subscription->isActive()) {
// grant access
}
```
`status()` returns one of the `Anikeen\Id\Enums\SubscriptionStatus` constants (the API sends the
same value as `status`):
| `status()` | Meaning | `isActive()` |
|---|---|---|
| `draft` | Created, not confirmed by the user yet | no |
| `trial` | Running, in its trial period | yes |
| `active` | Running and billed | yes |
| `paused` | Runs until the paid period ends, then expires (`resume()` to continue) | yes |
| `cancelling` | Runs until a scheduled cancellation takes effect | yes |
| `expired` | Paused and ran out; `resume()` starts a new period | no |
| `cancelled` | Cancelled for good | no |
There are shortcuts for each: `isDraft()`, `isPaused()`, `isCancelling()`, `isExpired()`,
`isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods.
### Webhooks
Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of
your app (see [`EventType`](src/Id/Webhooks/EventType.php) for all of them). Verify each request
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three
days.
Exclude the route from CSRF protection in `bootstrap/app.php`:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})
```
```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);
}
if (! ProcessedEvent::firstOrCreate(['id' => $event['id']])->wasRecentlyCreated) {
return response()->noContent(); // already handled
}
$object = $event['data']['object'];
match ($event['type']) {
EventType::ORDER_COMPLETED => Shop::fulfil($object['id'], $object['shipping_address']),
EventType::ORDER_CANCELLED => Shop::release($object['id']),
EventType::SUBSCRIPTION_ACTIVATED => Pass::grant($object['id']),
EventType::SUBSCRIPTION_CANCELLED => Pass::revoke($object['id']),
EventType::CHECKOUT_COMPLETED => Panel::provision($object['subscription_ids']),
default => null,
};
return response()->noContent();
});
```
`data.object` is the order, subscription or checkout session as the API returns it. 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. The admin panel can send a
`webhook.test` event to check your endpoint.
### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
well as with the app's (`app*()`). It carries:
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$order = $user->orders()->create($attributes);
} catch (ApiException $e) {
if ($e->isTemporary()) {
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
}
report($e);
return back()->with('error', $e->getMessage());
}
```
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
keep working.
### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
Payments on staging run in Stripe's test mode: pay with Stripe's
[test cards](https://docs.stripe.com/testing), e.g. `4242 4242 4242 4242` with any future expiry
date and any CVC. No money is charged.
Point `ANIKEEN_ID_BASE_URL` at your own Anikeen ID instance if you run one locally.
In unit tests, swap the HTTP client for a Guzzle `MockHandler`:
```php
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(201, ['Content-Type' => 'application/json'], json_encode(['data' => ['id' => '01JORDER', 'status' => 'draft']])),
]);
$this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new \GuzzleHttp\Client([
'handler' => \GuzzleHttp\HandlerStack::create($mock),
'base_uri' => 'https://id.test/api/',
])));
```
## Trusted apps
Apps operated by Anikeen can be marked as trusted. They can charge without asking the user and
use their own token (client credentials), so billing keeps working when the user's login expired.
### App subscriptions (trusted apps, no user token)
@@ -112,6 +514,11 @@ $subscription = $subscriptions->create([
$subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->checkout( // running for longer: also bill the periods that have ended
startsAt: $domain->registered_at,
billElapsedPeriods: true,
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
);
$subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel
@@ -124,10 +531,44 @@ 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).
#### Starting from an earlier date
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
the customer came to your app. If the first period from that date is still running, it is billed
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
`checkout()` throws an `InvalidArgumentException` without sending anything):
- every period that has ended plus the running one is billed now, as one line with the full
service period. It lands on the user's collective order of the current month and is invoiced and
charged with the next monthly bill; nothing is back-dated.
- the start may be at most about three years ago (422 otherwise).
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
you can show it and retry.
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
before, replaces another one, or is billed by the hour or day with a monthly cap).
```php
try {
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
} catch (ApiException $e) {
if ($e->error === 'elapsed_amount_exceeded') {
$actual = $e->body()->net_amount; // ask again with the real amount
} else {
throw $e;
}
}
```
#### Error codes
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()`:
`idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
`elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
`isTemporary()`:
```php
try {
@@ -166,7 +607,7 @@ period, confirms (all subscriptions start, or none) or cancels, and returns to `
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:
Example: a cart with several products and coupon prices:
```php
use Anikeen\Id\Exceptions\ApiException;
@@ -214,76 +655,45 @@ While a session is open, its subscriptions can't be checked out or repriced by t
`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)
### User accounts (trusted apps, no user token)
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`:
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
are matched regardless of case.
```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
$anikeenId = app(AnikeenId::class);
$order = $user->orders()->create([
'billing_address_id' => $addressId,
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']],
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
$account?->data->id; // also name, email, email_verified, has_billing_address
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
'name' => 'Erika Mustermann',
'email' => 'erika@example.com',
'send_invitation' => true, // a new account gets a mail with a link to set a password
]);
return redirect($order->confirmationUrl(route('shop.done')));
// later: https://your-app.test/shop/done?status=completed&order=01J…
$result->data->created; // false: the account already existed (and is unchanged)
$result->data->invitation_sent; // only for an account created by this call without a password
```
`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.
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
other than an unknown address.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
### Webhooks
## API authentication
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.
Use this when your own API accepts access tokens issued by Anikeen ID.
### Registering Middleware
To call your API from your own frontend with the user's session, append the middleware to the
web middleware group in your application's `bootstrap/app.php` file:
```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');
}
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
]);
})
```
### Implementing Auth (API tokens)
@@ -371,70 +781,36 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456');
```
#### Error handling for an unsuccessful query:
#### Results and resources
Low-level methods on `AnikeenId` (e.g. `getAuthedUser()`, `createOrderPreview()`,
`refreshToken()`) return a `Result`. Check it before you use it:
```php
$result = $anikeenId->sshKeysByUserId('someInvalidId');
$result = $anikeenId->getAuthedUser();
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
}
$user = $result->shift(); // the object, or the first item of a list
```
#### Shift result to get single key data:
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ApiException` instead (see [Errors](#errors)).
## Examples
#### Get User SSH Key
#### Get User SSH Keys
Uses the app's own token (client credentials):
```php
$anikeenId = new Anikeen\IdAnikeenId();
$sshKeys = app(Anikeen\Id\AnikeenId::class)->sshKeysByUserId($anikeenUserId);
$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());
foreach ($sshKeys->toArray() as $sshKey) {
echo $sshKey->name;
}
// 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
@@ -442,22 +818,13 @@ echo $preview->gross_total;
```php
$anikeenId = new Anikeen\Id\AnikeenId();
$anikeenId->setClientId('abc123');
$anikeenId->setToken('abcdef123456');
$result = $anikeenId->getAuthedUser();
$user = $userResult->shift();
$user = $anikeenId->getAuthedUser()->shift();
```
```php
$anikeenId->setToken('uvwxyz456789');
$result = $anikeenId->getAuthedUser();
```
```php
$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
$user = $anikeenId->withToken('uvwxyz456789')->getAuthedUser()->shift();
```
#### Facade
@@ -465,14 +832,14 @@ $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
```php
use Anikeen\Id\Facades\AnikeenId;
AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser();
AnikeenId::withToken('abcdef123456')->getAuthedUser();
```
## Documentation
<!-- GENERATED-DOCS -->
[**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/Id/Enums/Scope.php)
## Development
@@ -482,10 +849,6 @@ AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser();
composer test
```
```shell
BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test
```
#### Generate Documentation
```shell
+5 -5
View File
@@ -15,15 +15,15 @@
"require": {
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/support": "^12.69|^13.0",
"illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0"
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"autoload": {
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SubscriptionStatus;
/**
* The state of a subscription, derived from its fields the same way Anikeen ID derives `status`
* (so it also works with responses that only carry the fields).
*/
trait HasSubscriptionStatus
{
/**
* One of the SubscriptionStatus constants: draft, trial, active, paused, cancelling, expired
* or cancelled.
*/
public function status(): string
{
$active = (bool) $this->active;
$started = !empty($this->starts_at);
return match (true) {
(bool) $this->cancelled => SubscriptionStatus::CANCELLED,
!$active && !$started => SubscriptionStatus::DRAFT,
$active && !empty($this->cancels_at) => SubscriptionStatus::CANCELLING,
$active && !$this->autorenew => SubscriptionStatus::PAUSED,
$active && !empty($this->trial_ends_at) && strtotime($this->trial_ends_at) > time() => SubscriptionStatus::TRIAL,
$active => SubscriptionStatus::ACTIVE,
!$this->autorenew && $started => SubscriptionStatus::EXPIRED,
default => SubscriptionStatus::INACTIVE,
};
}
/**
* Not checked out or confirmed yet (third-party apps: send the user to confirmationUrl()).
*/
public function isDraft(): bool
{
return $this->status() === SubscriptionStatus::DRAFT;
}
/**
* Running and paid: active, in trial, paused or with a scheduled cancellation. Grant access
* while this is true.
*/
public function isActive(): bool
{
return in_array($this->status(), [SubscriptionStatus::TRIAL, SubscriptionStatus::ACTIVE, SubscriptionStatus::PAUSED, SubscriptionStatus::CANCELLING], true);
}
/**
* Paused: runs until the paid period ends, then expires (resume() to continue).
*/
public function isPaused(): bool
{
return $this->status() === SubscriptionStatus::PAUSED;
}
/**
* Running until a scheduled cancellation takes effect.
*/
public function isCancelling(): bool
{
return $this->status() === SubscriptionStatus::CANCELLING;
}
/**
* Paused and ran out: resume() starts a new period.
*/
public function isExpired(): bool
{
return $this->status() === SubscriptionStatus::EXPIRED;
}
/**
* Cancelled for good.
*/
public function isCancelled(): bool
{
return $this->status() === SubscriptionStatus::CANCELLED;
}
}
+45 -7
View File
@@ -5,7 +5,9 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable;
trait ManagesUsers
@@ -24,20 +26,32 @@ trait ManagesUsers
}
/**
* Creates a new user on behalf of the current user.
* Finds the account of an email address or creates it (trusted apps, app token). The email
* address is matched regardless of case; a new account stores it in lower case, unverified.
* An existing account is returned unchanged (only a missing name is filled in).
*
* @param array{
* first_name: null|string,
* last_name: null|string,
* username: null|string,
* name?: null|string,
* first_name?: null|string,
* last_name?: null|string,
* username?: null|string,
* email: string,
* password: null|string
* password?: null|string,
* password_confirmation?: null|string,
* send_invitation?: bool
* } $attributes The user data
* - first_name: The first name (optional)
* - last_name: The last name (optional)
* - name: The display name (optional)
* - first_name: The first name (optional, joined with last_name into name)
* - last_name: The last name (optional, joined with first_name into name)
* - username: The username (optional)
* - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided)
* - password_confirmation: Defaults to password
* - send_invitation: Send the user an invitation mail with a link to set a password
* (optional, only when this call created the account without a
* password, never for an existing account)
* @return Result `data` is the user plus `created` (this call created the account) and
* `invitation_sent` (the invitation mail was sent); 201 when created, 200 when it existed
* @throws Throwable
*/
public function createUser(array $attributes): Result
@@ -57,6 +71,30 @@ trait ManagesUsers
return $this->asApp()->post('v1/admin/users', $attributes);
}
/**
* Looks up the account of an email address without creating or changing anything (trusted
* apps, app token). The email address is matched regardless of case.
*
* `data` is `object{id: string, name: ?string, email: string, email_verified: bool,
* has_billing_address: bool}`.
*
* @return Result|null null when no account uses the email address
* @throws ApiException when the request fails for another reason
*/
public function findUserByEmail(string $email): ?Result
{
try {
return AppRequest::send('POST', 'v1/admin/users/lookup', ['email' => $email]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no account", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/**
* Find the user of a social login or create it (trusted apps, app token).
*
+11
View File
@@ -36,5 +36,16 @@ class Scope
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
// Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage),
// read-only. Changing products needs `user`; there is no full `products` scope.
const PRODUCTS_READ = 'products:read';
// Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not
// open these endpoints, a token must carry the reseller scope itself.
const RESELLER_CUSTOMERS = 'reseller-customers';
const RESELLER_CUSTOMERS_READ = 'reseller-customers:read';
const RESELLER_GRANTS = 'reseller-grants';
const RESELLER_ORDERS = 'reseller-orders';
const ADMIN = 'admin';
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The state of a subscription, see Subscription::status() and AppSubscription::status().
*/
class SubscriptionStatus
{
/** Created, not checked out or confirmed by the user yet. */
const DRAFT = 'draft';
/** Running and billed, in its trial period. */
const TRIAL = 'trial';
/** Running and billed. */
const ACTIVE = 'active';
/** Paused: runs until its paid period ends, then expires. */
const PAUSED = 'paused';
/** Running until a scheduled cancellation takes effect. */
const CANCELLING = 'cancelling';
/** Paused and ran out: can be resumed (new period) or cancelled. */
const EXPIRED = 'expired';
/** Cancelled for good. */
const CANCELLED = 'cancelled';
/** None of the above. */
const INACTIVE = 'inactive';
}
+44
View File
@@ -2,6 +2,8 @@
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Psr\Http\Message\ResponseInterface;
use Throwable;
/**
@@ -19,6 +21,48 @@ class ApiException extends ResourceException
parent::__construct($message, $status, $previous);
}
/**
* The exception for a failed request: Anikeen ID's message and error code, the HTTP status
* (0 when there was no response).
*/
public static function fromResult(Result $result): static
{
$body = is_object($result->data) ? $result->data : null;
return new static(
rtrim((string) ($body->message ?? $result->error()), '.'),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
/**
* The exception for a request that got no response (network error, timeout).
*/
public static function unreachable(Throwable $previous): static
{
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
}
/**
* The JSON body of Anikeen ID's error response, null without one. Some errors carry details,
* e.g. `net_amount` of "elapsed_amount_exceeded".
*/
public function body(): ?object
{
$previous = $this->getPrevious();
$response = $previous !== null && method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
if (!$response instanceof ResponseInterface) {
return null;
}
$body = json_decode((string) $response->getBody());
return is_object($body) ? $body : null;
}
public function isNotFound(): bool
{
return $this->status === 404;
+5 -5
View File
@@ -2,10 +2,10 @@
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class CollectionException extends Exception
/**
* A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
* Anikeen ID's error code.
*/
class CollectionException extends ApiException
{
}
+41 -4
View File
@@ -2,9 +2,11 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use InvalidArgumentException;
use JsonSerializable;
/**
@@ -24,7 +26,9 @@ use JsonSerializable;
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status "active" or "inactive"
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
@@ -36,6 +40,8 @@ use JsonSerializable;
*/
class AppSubscription implements JsonSerializable
{
use HasSubscriptionStatus;
public function __construct(protected object $data)
{
}
@@ -68,17 +74,48 @@ class AppSubscription implements JsonSerializable
* $startsAt (in the past) books a service that is already running, e.g. a domain registered
* earlier: the first period runs from $startsAt, is billed now and renews when it ends.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", …
* A $startsAt older than one period is refused ("period_already_ended") unless
* $billElapsedPeriods is set: then the periods that have already ended are billed too, together
* with the running one, as long as they come to at most $maxNetAmount (net, up to two
* decimals) and $startsAt lies within the maximum age Anikeen ID accepts (about three years).
* The lines land on the current month's collective order, which is charged on the 1st of the
* next month. $billElapsedPeriods needs both $startsAt and $maxNetAmount.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended",
* "elapsed_periods_not_allowed" (the elapsed periods can't be billed for this subscription),
* "elapsed_amount_exceeded" (they come to more than $maxNetAmount; the response body has
* the booked `net_amount`), …; 422 invalid input (e.g. a start older than the maximum age)
* @throws InvalidArgumentException $billElapsedPeriods without $startsAt or $maxNetAmount
*/
public function checkout(DateTimeInterface|string|null $startsAt = null): self
{
public function checkout(
DateTimeInterface|string|null $startsAt = null,
bool $billElapsedPeriods = false,
float|string|null $maxNetAmount = null,
): self {
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
if (!$billElapsedPeriods) {
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
if ($startsAt === null || $startsAt === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a start date ($startsAt).');
}
if ($maxNetAmount === null || $maxNetAmount === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a maximum net amount ($maxNetAmount).');
}
return $this->send('PUT', '/checkout', [
'starts_at' => $startsAt,
'bill_elapsed_periods' => true,
// floats from calculations may carry binary noise (e.g. 59.970000000000006)
'max_net_amount' => is_float($maxNetAmount) ? round($maxNetAmount, 2) : $maxNetAmount,
]);
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
+7 -1
View File
@@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result;
use Closure;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Response;
use JsonSerializable;
use Throwable;
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
*/
protected function __construct(callable $callable)
{
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw CollectionException::unreachable($e);
}
if (!$this->result->success()) {
throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
throw CollectionException::fromResult($this->result);
}
}
+10 -3
View File
@@ -2,9 +2,10 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable;
#[\AllowDynamicProperties]
@@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable
public Result $result;
/**
* @throws ResourceException
* @throws ApiException the request failed (status, error code; a ResourceException)
* @throws ConfirmationRequiredException the user has to confirm it on Anikeen ID
*/
public function __construct(callable $callable)
{
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw ApiException::unreachable($e);
}
if (!$this->result->success()) {
if (($this->result->data->error ?? null) === 'confirmation_required') {
@@ -28,7 +35,7 @@ abstract class BaseResource implements JsonSerializable
);
}
throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
throw ApiException::fromResult($this->result);
}
foreach ($this->result->data as $key => $value) {
+9
View File
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable;
@@ -18,13 +19,21 @@ use Throwable;
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property float $vat_rate
* @property array $payload
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $starts_at
* @property string $ends_at
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property null|string $confirmation_url where the user confirms it (third-party apps)
*/
class Subscription extends BaseResource
{
use HasBillable;
use HasConfirmationUrl;
use HasSubscriptionStatus;
/**
* Update a given subscription from the current user.
+81
View File
@@ -87,6 +87,87 @@ class AppSubscriptionsTest extends TestCase
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
}
public function test_a_checkout_without_arguments_sends_no_body(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1])->checkout();
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame('', (string) $request->getBody());
$this->assertFalse($request->hasHeader('Content-Type'));
}
public function test_a_checkout_from_a_date_sends_only_the_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout('2026-05-19T11:33:58+00:00');
$this->assertSame('{"starts_at":"2026-05-19T11:33:58+00:00"}', (string) $this->sent[1]['request']->getBody());
}
public function test_elapsed_periods_are_billed_up_to_the_ceiling(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout(new \DateTimeImmutable('2024-05-19 11:33:58', new \DateTimeZone('UTC')), billElapsedPeriods: true, maxNetAmount: '15.57');
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame(['starts_at' => '2024-05-19T11:33:58+00:00', 'bill_elapsed_periods' => true, 'max_net_amount' => '15.57'], $this->sentJson(1));
}
public function test_a_float_ceiling_is_sent_with_two_decimals(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => 19.99])
->checkout('2024-05-19', true, 19.99 * 3);
$this->assertSame(['starts_at' => '2024-05-19', 'bill_elapsed_periods' => true, 'max_net_amount' => 59.97], $this->sentJson(1));
}
public function test_billing_elapsed_periods_needs_a_start_and_a_ceiling(): void
{
$this->respond(201, $this->subscription());
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
foreach ([[null, '10.00'], ['2024-05-19', null], ['', '10.00'], ['2024-05-19', '']] as [$startsAt, $maxNetAmount]) {
try {
$subscription->checkout($startsAt, billElapsedPeriods: true, maxNetAmount: $maxNetAmount);
$this->fail('Expected an InvalidArgumentException.');
} catch (InvalidArgumentException) {
}
}
$this->assertCount(1, $this->sent);
}
public function test_an_exceeded_ceiling_carries_the_net_amount(): void
{
$this->respond(201, $this->subscription());
$this->respond(409, ['message' => 'The elapsed periods come to 20.76 net, more than 15.57.', 'error' => 'elapsed_amount_exceeded', 'net_amount' => '20.76']);
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
try {
$subscription->checkout('2024-05-19', billElapsedPeriods: true, maxNetAmount: '15.57');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('elapsed_amount_exceeded', $e->error);
$this->assertSame('20.76', $e->body()->net_amount);
}
}
public function test_errors_carry_status_and_error_code(): void
{
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
+47
View File
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Illuminate\Database\Eloquent\Model;
/**
@@ -90,6 +91,52 @@ class EndpointsTest extends TestCase
$this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0));
}
public function test_create_user_can_send_an_invitation(): void
{
$this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]);
$result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]);
$this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0));
$this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0));
$this->assertTrue($result->data->created);
$this->assertTrue($result->data->invitation_sent);
}
public function test_find_user_by_email_uses_the_lookup_endpoint(): void
{
$this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]);
$result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com');
$this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0));
$this->assertSame('01JUSER', $result->data->id);
$this->assertFalse($result->data->has_billing_address);
}
public function test_find_user_by_email_returns_null_without_an_account(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com'));
}
public function test_find_user_by_email_throws_other_failures(): void
{
$this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup
$this->respond(500, ['message' => 'Server Error']);
foreach ([404, 500] as $status) {
try {
app(AnikeenId::class)->findUserByEmail('erika@example.com');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame($status, $e->status);
}
}
}
public function test_email_check_and_socialite_users(): void
{
$this->respond(200, ['data' => ['exists' => true]]);
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Scope;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
class ScopeTest extends TestCase
{
public static function newScopes(): array
{
return [
'products:read' => [Scope::PRODUCTS_READ, 'products:read'],
'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'],
'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'],
'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'],
'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'],
];
}
#[DataProvider('newScopes')]
public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void
{
$this->assertSame($expected, $constant);
}
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
{
// the Console's product APIs accept `user`, `user:read` and `products:read` only
$this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants());
}
public function test_every_scope_follows_the_resource_and_level_schema(): void
{
$scopes = array_values((new ReflectionClass(Scope::class))->getConstants());
$this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique');
foreach ($scopes as $scope) {
// "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope);
}
}
public function test_each_reseller_resource_is_its_own_group(): void
{
$groups = collect((new ReflectionClass(Scope::class))->getConstants())
->filter(fn (string $scope) => str_starts_with($scope, 'reseller-'))
->groupBy(fn (string $scope) => explode(':', $scope)[0])
->map(fn ($scopes) => $scopes->sort()->values()->all())
->all();
$this->assertSame([
'reseller-customers' => ['reseller-customers', 'reseller-customers:read'],
'reseller-grants' => ['reseller-grants'],
'reseller-orders' => ['reseller-orders'],
], $groups);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\SubscriptionStatus;
use Anikeen\Id\Resources\AppSubscription;
use Illuminate\Database\Eloquent\Model;
use PHPUnit\Framework\Attributes\DataProvider;
class SubscriptionStatusTest extends TestCase
{
private const BASE = ['id' => '01JSUB', 'active' => false, 'autorenew' => true, 'cancelled' => false, 'starts_at' => null, 'cancels_at' => null, 'trial_ends_at' => null];
public static function states(): array
{
$started = ['starts_at' => '2026-10-01T00:00:00+00:00'];
return [
'draft' => [[], SubscriptionStatus::DRAFT, false],
'active' => [['active' => true, ...$started], SubscriptionStatus::ACTIVE, true],
'trial' => [['active' => true, 'trial_ends_at' => date(DATE_ATOM, time() + 86400), ...$started], SubscriptionStatus::TRIAL, true],
'trial over' => [['active' => true, 'trial_ends_at' => '2026-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::ACTIVE, true],
'paused' => [['active' => true, 'autorenew' => false, ...$started], SubscriptionStatus::PAUSED, true],
'cancelling' => [['active' => true, 'cancels_at' => '2027-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::CANCELLING, true],
'expired' => [['autorenew' => false, ...$started], SubscriptionStatus::EXPIRED, false],
'cancelled' => [['autorenew' => false, 'cancelled' => true, ...$started], SubscriptionStatus::CANCELLED, false],
];
}
#[DataProvider('states')]
public function test_the_status_follows_the_fields(array $fields, string $status, bool $running): void
{
$data = [...self::BASE, ...$fields];
$this->respond(200, ['data' => [$data]]); // subscriptions() loads the list
$this->respond(200, ['data' => $data]);
$owner = new class extends Model {
use Billable;
};
$owner->anikeen_id_access_token = 'user-token';
$subscription = $owner->subscriptions()->find('01JSUB');
$app = new AppSubscription((object) $data);
foreach ([$subscription, $app] as $resource) {
$this->assertSame($status, $resource->status());
$this->assertSame($running, $resource->isActive());
$this->assertSame($status === SubscriptionStatus::DRAFT, $resource->isDraft());
$this->assertSame($status === SubscriptionStatus::PAUSED, $resource->isPaused());
$this->assertSame($status === SubscriptionStatus::CANCELLING, $resource->isCancelling());
$this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired());
$this->assertSame($status === SubscriptionStatus::CANCELLED, $resource->isCancelled());
}
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserApiExceptionsTest extends TestCase
{
private function user(): Model
{
$user = new class extends Model {
use Billable;
};
$user->anikeen_id_access_token = 'user-token';
return $user;
}
public function test_a_refused_request_carries_status_and_error_code(): void
{
$this->respond(200, ['data' => []]); // orders() loads the list
$this->respond(422, ['message' => 'The billing address is missing.', 'error' => 'billing_address_missing']);
try {
$this->user()->orders()->create(['items' => []]);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertInstanceOf(ResourceException::class, $e, 'existing catch (ResourceException) blocks keep working');
$this->assertSame(422, $e->status);
$this->assertSame(422, $e->getCode());
$this->assertSame('billing_address_missing', $e->error);
$this->assertSame('The billing address is missing', $e->getMessage());
$this->assertFalse($e->isTemporary());
}
}
public function test_an_unknown_id_is_not_found(): void
{
$this->respond(200, ['data' => []]);
$this->respond(404, ['message' => 'Not found.']);
try {
$this->user()->orders()->find('01JMISSING');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertNull($e->error);
}
}
public function test_a_failed_list_is_an_api_exception_too(): void
{
$this->respond(503, ['message' => 'Maintenance.']);
try {
$this->user()->invoices();
$this->fail('Expected a CollectionException.');
} catch (CollectionException $e) {
$this->assertInstanceOf(ApiException::class, $e);
$this->assertSame(503, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_no_response_is_temporary_with_status_zero(): void
{
$this->respond(200, ['data' => []]);
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/orders/01J')));
try {
$this->user()->orders()->find('01J');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
$this->assertInstanceOf(ConnectException::class, $e->getPrevious());
}
}
public function test_confirmation_required_stays_its_own_exception(): void
{
$this->respond(200, ['data' => []]);
$this->respond(200, ['data' => ['id' => '01JORDER', 'status' => 'draft']]);
$this->respond(403, ['message' => 'Confirm it on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.test/confirm/01JORDER']);
$this->expectException(ConfirmationRequiredException::class);
$this->user()->orders()->find('01JORDER')->checkout();
}
}