Compare commits

..
26 Commits
Author SHA1 Message Date
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
Maurice PreußandClaude Opus 5.5 dc29eb2844 Fix the anikeen API guard and register its user provider
- Register the guard driver and the anikeen user provider with static closures:
  Laravel binds extend() closures to the AuthManager, so $this->makeGuard() failed.
- Look the token's sub up in the user's Anikeen ID column (anikeen_id by default;
  provider 'column', services.anikeen.user_id_column or useUserIdField()),
  not in the primary key; works with the anikeen, eloquent and database providers.
- JwtParser: key from services.anikeen.public_key(_path) or the bundled key, read
  once per process; misconfiguration is an error, not a 401. Passport tokens have
  no client claim, so it is optional now.
- Optional revocation check against Anikeen ID (verify_revocation), cached per token.
- anikeenTokenCan() without a token, anikeenTokenCanAny(), scope middleware accept
  JSON responses.
- Update the bundled staging public key to the current staging key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
2026-11-03 23:07:58 +01:00
envoyr b074ec3c0c Merge pull request #12 from anikeen-com/feature/monthly-cap
Document monthly_cap, predecessor_id and expected_monthly_price
2026-11-03 12:49:37 +01:00
Maurice PreußandClaude Opus 5.5 c2493b76fd Document monthly_cap, predecessor_id and expected_monthly_price
Subscriptions (user token and app token) and checkout-session subscriptions
take a net monthly cap and the subscription they replace (a resize shares
the cap within the month). Attributes are passed through unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
2026-11-03 12:43:17 +01:00
envoyr 285594e7e9 Merge pull request #11 from anikeen-com/feature/trusted-checkout-sessions
Checkout sessions: one hosted checkout for several subscriptions
2026-11-02 17:36:08 +01:00
Maurice PreußandClaude Opus 5.5 34f6854bdd Fix the checkout session return status docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:35:25 +01:00
Maurice PreußandClaude Opus 5.5 3acd36ca29 Add checkout sessions and webhook event type constants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:26:24 +01:00
envoyr a0470111fa Merge pull request #10 from anikeen-com/docs/shipment-mail
Document the shipment mail and notify_customer
2026-11-01 15:04:09 +01:00
Maurice PreußandClaude Opus 5.5 934b1c5c3f Document the shipment mail and notify_customer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:57:46 +01:00
envoyr 6a568a5354 Merge pull request #9 from anikeen-com/feature/shipments
Shipments: ship orders and download delivery notes
2026-11-01 14:20:32 +01:00
Maurice PreußandClaude Opus 5.5 5349ee81f6 Ship orders: shipments with delivery notes
$order->shipments()->create([...]) records a delivery of a paid order's goods on Anikeen ID,
which adds its delivery note to the user's order documents and sends order.shipped.
$shipment->deliveryNote() downloads the PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:45 +01:00
envoyr 60b4b80f17 Merge pull request #8 from anikeen-com/docs/subscription-description
Document changing the description of running app subscriptions
2026-11-01 13:52:30 +01:00
Maurice PreußandClaude Opus 5.5 1f645b50bc Document changing the description of running app subscriptions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:29:30 +01:00
envoyr 9731dd36ca Merge pull request #7 from anikeen-com/docs/order-shipping-address
Fix saving Billable models after API calls; document shipping addresses and item images
2026-11-01 13:05:01 +01:00
Maurice PreußandClaude Opus 5.5 be59602b30 Declare the billable's API caches, so Eloquent models can still be saved
The caches were undeclared properties: on an Eloquent model they went through
Model::__set into the attributes, and save() failed with "no such column: ordersCache".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:55:51 +01:00
Maurice PreußandClaude Opus 5.5 fd96e559af Document shipping address fields and item images on orders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:36:26 +01:00
42 changed files with 2765 additions and 451 deletions

No files matched your search

+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`.
+517 -162
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) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 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 ## Table of contents
1. [Installation](#installation) 1. [Installation](#installation)
2. [Configuration](#configuration) 2. [Configuration](#configuration)
3. [General](#general) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Examples](#examples) 4. [Payments](#payments)
5. [Documentation](#documentation) 5. [Trusted apps](#trusted-apps)
6. [Development](#Development) 6. [API authentication](#api-authentication)
7. [General](#general)
8. [Examples](#examples)
9. [Documentation](#documentation)
10. [Development](#development)
## Installation ## Installation
@@ -23,12 +30,20 @@ composer require anikeen/id
## Configuration ## 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_KEY=
ANIKEEN_ID_SECRET= ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
ANIKEEN_ID_WEBHOOK_SECRET=
``` ```
To switch from `production` to `staging` use following variable: 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'), 'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'), 'base_url' => env('ANIKEEN_ID_BASE_URL'),
'webhook_secret' => env('ANIKEEN_ID_WEBHOOK_SECRET'),
], ],
``` ```
## Sign in with Anikeen ID
### Event Listener ### 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 ```php
public function boot(): void 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 ```php
->withMiddleware(function (Middleware $middleware) { Schema::table('users', function (Blueprint $table) {
$middleware->web(append: [ $table->string('anikeen_id')->nullable()->unique();
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, $table->text('anikeen_id_access_token')->nullable();
]); $table->text('anikeen_id_refresh_token')->nullable();
}) });
``` ```
### Implementing Billable Add the `Billable` trait to the user model. Keep the tokens encrypted and out of JSON:
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
```php ```php
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
@@ -87,11 +103,376 @@ class User extends Authenticatable
{ {
use Billable; 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,
]);
}
```
### 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) ### App subscriptions (trusted apps, no user token)
@@ -157,100 +538,85 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
### Selling as a third-party app (user confirms on Anikeen ID) ### Checkout sessions (the user confirms several subscriptions at once)
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`: all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
sessions are opt-in.
Example: a cart with several products and coupon prices:
```php ```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException; use Anikeen\Id\Exceptions\ApiException;
$order = $user->orders()->create([ $session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
'billing_address_id' => $addressId, 'return_url' => route('builder.done'), // same origin as one of your redirect URIs
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']], 'metadata' => ['cart' => $cart->id], // returned as is
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
'subscriptions' => $cart->items->map(fn ($item) => [
'group' => $team->id,
'name' => $item->product->name,
'description' => $item->label,
'unit' => $item->unit, // hour, day, week, month, year
'price' => $item->priceAfterCoupon(), // net, per unit
'payload' => ['cart_item' => $item->id],
])->all(),
]); ]);
return redirect($order->confirmationUrl(route('shop.done'))); $cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
// later: https://your-app.test/shop/done?status=completed&order=01J…
return redirect($session->confirmationUrl());
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
``` ```
`return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`, Provision when the webhook arrives (or after checking the session through the API; never trust the
`pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)` query string):
instead of trusting the query string. Subscriptions work the same way
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
Unconfirmed bookings are cancelled after 24 hours.
`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.*` and `subscription.*` 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 ```php
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) { match ($event['type']) {
try { EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
} catch (SignatureVerificationException) {
abort(400);
}
match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
default => null, default => null,
}; };
return response()->noContent(); app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
})->withoutMiddleware(VerifyCsrfToken::class); $session->refresh()->subscriptions(); // AppSubscription objects
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
``` ```
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and While a session is open, its subscriptions can't be checked out or repriced by the app (409
reload the object through the API when the current state matters. `in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session.
### 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: ## 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 ```php
use Anikeen\Id\AnikeenId; ->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
public function boot(): void \Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
{ ]);
AnikeenId::useAccessTokenField('anikeen_id_access_token'); })
AnikeenId::useRefreshTokenField('anikeen_id_refresh_token');
}
``` ```
### Implementing Auth ### Implementing Auth (API tokens)
This method should typically be called in the `boot` method of your `AppServiceProvider` class: The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them
in `config/auth.php`:
```php
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdUserProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
public function boot(): void
{
Auth::provider('anikeen', function ($app, array $config) {
return new AnikeenIdUserProvider(
$app->make(AnikeenId::class),
$app->make(Request::class),
$config['model'],
$config['fields'] ?? [],
);
});
}
```
reference the guard in the `guards` configuration of your `auth.php` configuration file:
```php ```php
'guards' => [ 'guards' => [
@@ -264,11 +630,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
'provider' => 'anikeen', 'provider' => 'anikeen',
], ],
], ],
```
reference the provider in the `providers` configuration of your `auth.php` configuration file:
```php
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
@@ -278,11 +640,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi
'anikeen' => [ 'anikeen' => [
'driver' => 'anikeen', 'driver' => 'anikeen',
'model' => App\Models\User::class, 'model' => App\Models\User::class,
'fields' => ['first_name', 'last_name', 'email'], 'column' => 'anikeen_id', // optional, see below
], ],
], ],
``` ```
The guard accepts `Authorization: Bearer <access token>` with an access token issued by Anikeen ID
(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's
public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the
user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's
`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of
unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request
unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests.
The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available:
```php
$request->user()->anikeenTokenCan('user'); // has the scope (or `*`)
$request->user()->anikeenTokenCanAny(['user', 'user:read']);
```
or as middleware (`403` without the scope):
```php
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
use Anikeen\Id\Http\Middleware\CheckScopes;
Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope
```
Options in `config/services.php` (`anikeen`):
| Key | Default | |
|-----|---------|-|
| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) |
| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used |
| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID |
| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable |
| `revocation_cache_ttl` | `300` | seconds that answer is cached per token |
The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token.
## General ## General
#### Setters and Getters #### Setters and Getters
@@ -299,70 +698,36 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456'); $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 ```php
$result = $anikeenId->sshKeysByUserId('someInvalidId'); $result = $anikeenId->getAuthedUser();
// Check, if the query was successfully
if (!$result->success()) { if (!$result->success()) {
die('Ooops: ' . $result->error()); die('Ooops: ' . $result->error());
} }
$user = $result->shift(); // the object, or the first item of a list
``` ```
#### Shift result to get single key data: Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ApiException` instead (see [Errors](#errors)).
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
## Examples ## Examples
#### Get User SSH Key #### Get User SSH Keys
Uses the app's own token (client credentials):
```php ```php
$anikeenId = new Anikeen\IdAnikeenId(); $sshKeys = app(Anikeen\Id\AnikeenId::class)->sshKeysByUserId($anikeenUserId);
$anikeenId->setClientId('abc123'); foreach ($sshKeys->toArray() as $sshKey) {
echo $sshKey->name;
// Get SSH Key by User ID
$result = $anikeenId->sshKeysByUserId('someValidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
} }
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Create Order Preview
```php
$anikeenId = new \Anikeen\Id\AnikeenId();
// Create new Order Preview
$result = $anikeenId->createOrderPreview([
'country_iso' => 'de',
'items' => [
[
'type' => 'physical',
'name' => 'Test',
'price' => 2.99,
'unit' => 'onetime',
'units' => 1,
]
]
])->shift();
echo $preview->gross_total;
``` ```
#### OAuth Tokens #### OAuth Tokens
@@ -370,22 +735,13 @@ echo $preview->gross_total;
```php ```php
$anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId = new Anikeen\Id\AnikeenId();
$anikeenId->setClientId('abc123');
$anikeenId->setToken('abcdef123456'); $anikeenId->setToken('abcdef123456');
$result = $anikeenId->getAuthedUser(); $user = $anikeenId->getAuthedUser()->shift();
$user = $userResult->shift();
``` ```
```php ```php
$anikeenId->setToken('uvwxyz456789'); $user = $anikeenId->withToken('uvwxyz456789')->getAuthedUser()->shift();
$result = $anikeenId->getAuthedUser();
```
```php
$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
``` ```
#### Facade #### Facade
@@ -393,7 +749,7 @@ $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
```php ```php
use Anikeen\Id\Facades\AnikeenId; use Anikeen\Id\Facades\AnikeenId;
AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); AnikeenId::withToken('abcdef123456')->getAuthedUser();
``` ```
## Documentation ## Documentation
@@ -434,6 +790,7 @@ public function shippingPriceList(): Result
```php ```php
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appCheckoutSessions(): AppCheckoutSessions
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
``` ```
@@ -536,6 +893,8 @@ public function getBillable(): Billable
```php ```php
public function subscriptions(): Subscriptions public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions public function appSubscriptions(): AppSubscriptions
public function appCheckoutSessions(): AppCheckoutSessions
public function checkoutSessions(): CheckoutSessions
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
@@ -566,7 +925,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 ## Development
@@ -576,10 +935,6 @@ public function getBillable(): Billable
composer test composer test
``` ```
```shell
BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test
```
#### Generate Documentation #### Generate Documentation
```shell ```shell
+514 -162
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) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 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 ## Table of contents
1. [Installation](#installation) 1. [Installation](#installation)
2. [Configuration](#configuration) 2. [Configuration](#configuration)
3. [General](#general) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Examples](#examples) 4. [Payments](#payments)
5. [Documentation](#documentation) 5. [Trusted apps](#trusted-apps)
6. [Development](#Development) 6. [API authentication](#api-authentication)
7. [General](#general)
8. [Examples](#examples)
9. [Documentation](#documentation)
10. [Development](#development)
## Installation ## Installation
@@ -23,12 +30,20 @@ composer require anikeen/id
## Configuration ## 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_KEY=
ANIKEEN_ID_SECRET= ANIKEEN_ID_SECRET=
ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback
ANIKEEN_ID_WEBHOOK_SECRET=
``` ```
To switch from `production` to `staging` use following variable: 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'), 'client_secret' => env('ANIKEEN_ID_SECRET'),
'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'),
'base_url' => env('ANIKEEN_ID_BASE_URL'), 'base_url' => env('ANIKEEN_ID_BASE_URL'),
'webhook_secret' => env('ANIKEEN_ID_WEBHOOK_SECRET'),
], ],
``` ```
## Sign in with Anikeen ID
### Event Listener ### 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 ```php
public function boot(): void 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 ```php
->withMiddleware(function (Middleware $middleware) { Schema::table('users', function (Blueprint $table) {
$middleware->web(append: [ $table->string('anikeen_id')->nullable()->unique();
\Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, $table->text('anikeen_id_access_token')->nullable();
]); $table->text('anikeen_id_refresh_token')->nullable();
}) });
``` ```
### Implementing Billable Add the `Billable` trait to the user model. Keep the tokens encrypted and out of JSON:
To implement the `Billable` trait, you need to add the `Billable` trait to your user model.
```php ```php
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
@@ -87,11 +103,376 @@ class User extends Authenticatable
{ {
use Billable; 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,
]);
}
```
### 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) ### App subscriptions (trusted apps, no user token)
@@ -157,100 +538,85 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
### Selling as a third-party app (user confirms on Anikeen ID) ### Checkout sessions (the user confirms several subscriptions at once)
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`: all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
sessions are opt-in.
Example: a cart with several products and coupon prices:
```php ```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException; use Anikeen\Id\Exceptions\ApiException;
$order = $user->orders()->create([ $session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
'billing_address_id' => $addressId, 'return_url' => route('builder.done'), // same origin as one of your redirect URIs
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']], 'metadata' => ['cart' => $cart->id], // returned as is
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
'subscriptions' => $cart->items->map(fn ($item) => [
'group' => $team->id,
'name' => $item->product->name,
'description' => $item->label,
'unit' => $item->unit, // hour, day, week, month, year
'price' => $item->priceAfterCoupon(), // net, per unit
'payload' => ['cart_item' => $item->id],
])->all(),
]); ]);
return redirect($order->confirmationUrl(route('shop.done'))); $cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
// later: https://your-app.test/shop/done?status=completed&order=01J…
return redirect($session->confirmationUrl());
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
``` ```
`return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`, Provision when the webhook arrives (or after checking the session through the API; never trust the
`pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)` query string):
instead of trusting the query string. Subscriptions work the same way
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
Unconfirmed bookings are cancelled after 24 hours.
`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.*` and `subscription.*` 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 ```php
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) { match ($event['type']) {
try { EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
} catch (SignatureVerificationException) {
abort(400);
}
match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
default => null, default => null,
}; };
return response()->noContent(); app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
})->withoutMiddleware(VerifyCsrfToken::class); $session->refresh()->subscriptions(); // AppSubscription objects
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
``` ```
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and While a session is open, its subscriptions can't be checked out or repriced by the app (409
reload the object through the API when the current state matters. `in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session.
### 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: ## 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 ```php
use Anikeen\Id\AnikeenId; ->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
public function boot(): void \Anikeen\Id\Http\Middleware\CreateFreshApiToken::class,
{ ]);
AnikeenId::useAccessTokenField('anikeen_id_access_token'); })
AnikeenId::useRefreshTokenField('anikeen_id_refresh_token');
}
``` ```
### Implementing Auth ### Implementing Auth (API tokens)
This method should typically be called in the `boot` method of your `AppServiceProvider` class: The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them
in `config/auth.php`:
```php
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Providers\AnikeenIdUserProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
public function boot(): void
{
Auth::provider('anikeen', function ($app, array $config) {
return new AnikeenIdUserProvider(
$app->make(AnikeenId::class),
$app->make(Request::class),
$config['model'],
$config['fields'] ?? [],
);
});
}
```
reference the guard in the `guards` configuration of your `auth.php` configuration file:
```php ```php
'guards' => [ 'guards' => [
@@ -264,11 +630,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati
'provider' => 'anikeen', 'provider' => 'anikeen',
], ],
], ],
```
reference the provider in the `providers` configuration of your `auth.php` configuration file:
```php
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
@@ -278,11 +640,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi
'anikeen' => [ 'anikeen' => [
'driver' => 'anikeen', 'driver' => 'anikeen',
'model' => App\Models\User::class, 'model' => App\Models\User::class,
'fields' => ['first_name', 'last_name', 'email'], 'column' => 'anikeen_id', // optional, see below
], ],
], ],
``` ```
The guard accepts `Authorization: Bearer <access token>` with an access token issued by Anikeen ID
(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's
public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the
user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's
`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of
unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request
unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests.
The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available:
```php
$request->user()->anikeenTokenCan('user'); // has the scope (or `*`)
$request->user()->anikeenTokenCanAny(['user', 'user:read']);
```
or as middleware (`403` without the scope):
```php
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
use Anikeen\Id\Http\Middleware\CheckScopes;
Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope
```
Options in `config/services.php` (`anikeen`):
| Key | Default | |
|-----|---------|-|
| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) |
| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used |
| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID |
| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable |
| `revocation_cache_ttl` | `300` | seconds that answer is cached per token |
The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token.
## General ## General
#### Setters and Getters #### Setters and Getters
@@ -299,70 +698,36 @@ $anikeenId = $anikeenId->withClientSecret('abc123');
$anikeenId = $anikeenId->withToken('abcdef123456'); $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 ```php
$result = $anikeenId->sshKeysByUserId('someInvalidId'); $result = $anikeenId->getAuthedUser();
// Check, if the query was successfully
if (!$result->success()) { if (!$result->success()) {
die('Ooops: ' . $result->error()); die('Ooops: ' . $result->error());
} }
$user = $result->shift(); // the object, or the first item of a list
``` ```
#### Shift result to get single key data: Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ApiException` instead (see [Errors](#errors)).
```php
$result = $anikeenId->sshKeysByUserId('someValidId');
$sshKey = $result->shift();
echo $sshKey->name;
```
## Examples ## Examples
#### Get User SSH Key #### Get User SSH Keys
Uses the app's own token (client credentials):
```php ```php
$anikeenId = new Anikeen\IdAnikeenId(); $sshKeys = app(Anikeen\Id\AnikeenId::class)->sshKeysByUserId($anikeenUserId);
$anikeenId->setClientId('abc123'); foreach ($sshKeys->toArray() as $sshKey) {
echo $sshKey->name;
// Get SSH Key by User ID
$result = $anikeenId->sshKeysByUserId('someValidId');
// Check, if the query was successfully
if (!$result->success()) {
die('Ooops: ' . $result->error());
} }
// Shift result to get single key data
$sshKey = $result->shift();
echo $sshKey->name;
```
#### Create Order Preview
```php
$anikeenId = new \Anikeen\Id\AnikeenId();
// Create new Order Preview
$result = $anikeenId->createOrderPreview([
'country_iso' => 'de',
'items' => [
[
'type' => 'physical',
'name' => 'Test',
'price' => 2.99,
'unit' => 'onetime',
'units' => 1,
]
]
])->shift();
echo $preview->gross_total;
``` ```
#### OAuth Tokens #### OAuth Tokens
@@ -370,22 +735,13 @@ echo $preview->gross_total;
```php ```php
$anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId = new Anikeen\Id\AnikeenId();
$anikeenId->setClientId('abc123');
$anikeenId->setToken('abcdef123456'); $anikeenId->setToken('abcdef123456');
$result = $anikeenId->getAuthedUser(); $user = $anikeenId->getAuthedUser()->shift();
$user = $userResult->shift();
``` ```
```php ```php
$anikeenId->setToken('uvwxyz456789'); $user = $anikeenId->withToken('uvwxyz456789')->getAuthedUser()->shift();
$result = $anikeenId->getAuthedUser();
```
```php
$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
``` ```
#### Facade #### Facade
@@ -393,14 +749,14 @@ $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser();
```php ```php
use Anikeen\Id\Facades\AnikeenId; use Anikeen\Id\Facades\AnikeenId;
AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); AnikeenId::withToken('abcdef123456')->getAuthedUser();
``` ```
## Documentation ## Documentation
<!-- GENERATED-DOCS --> <!-- 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 ## Development
@@ -410,10 +766,6 @@ AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser();
composer test composer test
``` ```
```shell
BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test
```
#### Generate Documentation #### Generate Documentation
```shell ```shell
+5 -5
View File
@@ -15,15 +15,15 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0", "illuminate/support": "^12.69|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0", "illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8", "guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6", "socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0" "firebase/php-jwt": "^7.0"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0", "laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0", "orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0" "phpunit/phpunit": "^11.0|^12.0"
}, },
"autoload": { "autoload": {
+12 -12
View File
@@ -1,14 +1,14 @@
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAi4Ta8r01zKaGSnGi1EiD MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArGn4rtfKjlLtJsh61eW/
uMFWRXBlK4y/ZIfWBpElmS2ygv4mGeP3hT4Flm696Z2UMy56KC+c7CC/PQCiutLk cZdLC6ECbSUKVTZlLLvRoYr7Li6RKRDiIj7KEy+lRL4miiMtadXNx6d89KZwr8SL
5NUphyX/t+0QS5Dqpw6FB33fLTNNY7GqSmGIUE4os8XYZRSyDQRgtOgq3R3vJkoV DQXDbP//F3HEXVJBtr6j8p8HbJh1pinLJeKtcmeJfOIwVLyQ3sPdewkQoFtwkQyF
7zoavTJmSCQlG5Qf0T//iMmzQ+b+6VZm1CJSz5nGx94u1DuXNyP5Epkk0wuHrtwy uZWLeh6TCfAtcQs49I6C0rYgLDQrfTzvQ195mX1HkgArndNDXBiECMYCCulmPpzv
kADR2lmydNodJzqpSD+8yQqnAhOZNtNF4qwQ3g13fRvHycBp3G2nlCfOn2g5PmYD yq5MWS5+/uQjKmPToo5lPfKxbWD6lyLII9ZkGp7wFilSonpUN1LmwRJlgQqh+1Hb
KYBKqvTq4PQH4E+K3pbbMz6zf/T6Dw7zTfksqHR4hqMgN6byRRxmwuBczIumcu9b celGHTIXiP3lDNIG3sR/H5v6vwGeqfu9MBQ2Ksrz259O1aojqRQ+d9k1dj8pcbuC
y7xbgoIGIVZXgJliALPFi+zTPTN7c8MedFs/xCBHCmzWYTCZfHgr8RPRewD19tCG ZurlI4++JOsmecc4B3g8tvlsevlb0UbtW/JHKckgK/JL9PO+04WJD+hSJrEhokLq
NSny5R0vlArpuZCTTgedPESDeGU4eNEddg4yXFzKlpE2nNuvzZ1Ohruc5ETOSU19 aB5Ceg6FKwqoM0l7dtoHRka8vh/q/UU3oawUESoZ5l3TjWI4b0jZzsJqE4cyKgK3
RTCBUBkjeL6ESZRd/yKGjbVx4dEYxZdIz4yBl+hZ2ZOIyG7L3zPrccAWrPpG56xr S3tBooj4rM4dOh4y7crD1PcsJG/IF2bg4YEAO8q8Fz6TmOzCM20oBhaLrFJOnYph
E5IDBXxLFhaJ5LlyEAGQehB0ShEuCdkr88Xz7ba9PHpGqY83l4//ULrqPIZPAa4Z Xhhl7ZPHqCCIt6HSDbwlFJw1Iw/2nygnkAZ0iJV/EQl0M+zlDGEv41SWHQfsLTK6
E3AWHT1ZtXNPeA4SzZ9Y9Oij4M3chyHxqM0lL3kYP+dstZehTujStfElDIx2Ni10 HBSM1QSEbuMz0sF/TpolYEvYso+nCDjDzQ4MFdhGxH6OVyuSySm6nG0fK/Bio5Ow
73tILu4edYS0FxsL19m8gbsCAwEAAQ== 6SNz0wIg3RagjzTbljHsT4cCAwEAAQ==
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
+104 -55
View File
@@ -9,38 +9,39 @@ use Exception;
use Firebase\JWT\JWT; use Firebase\JWT\JWT;
use Firebase\JWT\Key; use Firebase\JWT\Key;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\GuardHelpers; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Cookie\CookieValuePrefix; use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use stdClass; use stdClass;
use Throwable; use Throwable;
/**
* Resolves the user of a request from an Anikeen ID access token (JWT, "Authorization: Bearer …")
* or from the encrypted token cookie of CreateFreshApiToken.
*
* A bearer token is verified with the JwtParser (signature, expiry). Its `sub` is the user's
* Anikeen ID, looked up by the provider (AnikeenIdUserProvider::retrieveByAnikeenId(), column
* `anikeen_id` by default). The token is attached to the user: `$user->anikeenTokenCan('scope')`.
*
* With `services.anikeen.verify_revocation` the token is also checked against Anikeen ID
* (GET v1/user, cached per token for `services.anikeen.revocation_cache_ttl` seconds), so
* revoked tokens stop working before they expire.
*/
class TokenGuard class TokenGuard
{ {
use GuardHelpers; public function __construct(
protected UserProvider $provider,
private Encrypter $encrypter; private Encrypter $encrypter,
private JwtParser $jwtParser
private JwtParser $jwtParser; ) {
//
public function __construct(UserProvider $provider, Encrypter $encrypter, JwtParser $jwtParser)
{
$this->provider = $provider;
$this->encrypter = $encrypter;
$this->jwtParser = $jwtParser;
} }
/** /**
* Get the user for the incoming request. * Get the user for the incoming request.
*
* @throws BindingResolutionException
* @throws Throwable
*/ */
public function user(Request $request): ?Authenticatable public function user(Request $request): ?Authenticatable
{ {
@@ -55,9 +56,6 @@ class TokenGuard
/** /**
* Authenticate the incoming request via the Bearer token. * Authenticate the incoming request via the Bearer token.
*
* @throws BindingResolutionException
* @throws Throwable
*/ */
protected function authenticateViaBearerToken(Request $request): ?Authenticatable protected function authenticateViaBearerToken(Request $request): ?Authenticatable
{ {
@@ -65,51 +63,97 @@ class TokenGuard
return null; return null;
} }
// If the access token is valid we will retrieve the user according to the user ID // client credentials tokens have no user
// associated with the token. We will use the provider implementation which may if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') {
// be used to retrieve users from Eloquent. Next, we'll be ready to continue. return null;
/** @var Authenticatable|HasAnikeenTokens $user */ }
$user = $this->provider->retrieveById(
$request->attributes->get('oauth_user_id') ?: null
);
return $user?->withAnikeenAccessToken($token); if (!$this->tokenIsActive($token, (string) $request->bearerToken())) {
return null;
}
/** @var Authenticatable|HasAnikeenTokens|null $user */
$user = $this->retrieveUser((string) $token->sub);
return $user && method_exists($user, 'withAnikeenAccessToken')
? $user->withAnikeenAccessToken($token)
: $user;
} }
/** /**
* Authenticate and get the incoming request via the Bearer token. * Verify the Bearer token and expose its claims as request attributes.
*
* @throws BindingResolutionException
* @throws Throwable
*/ */
protected function validateRequestViaBearerToken(Request $request): ?stdClass protected function validateRequestViaBearerToken(Request $request): ?stdClass
{ {
try { try {
$decoded = $this->jwtParser->decode($request); $decoded = $this->jwtParser->decode($request);
} catch (AuthenticationException) {
return null;
}
$request->attributes->set('oauth_access_token_id', $decoded->jti); $decoded->scopes = array_values(array_filter((array) ($decoded->scopes ?? []), 'is_string'));
$request->attributes->set('oauth_client_id', $decoded->aud);
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted); $request->attributes->set('oauth_access_token_id', $decoded->jti ?? null);
$request->attributes->set('oauth_user_id', $decoded->sub); $request->attributes->set('oauth_client_id', $decoded->aud ?? null);
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted ?? null);
$request->attributes->set('oauth_user_id', $decoded->sub ?? null);
$request->attributes->set('oauth_scopes', $decoded->scopes); $request->attributes->set('oauth_scopes', $decoded->scopes);
return $decoded; return $decoded;
} catch (AuthenticationException $e) {
$request->headers->set('Authorization', '', true);
Container::getInstance()->make(
ExceptionHandler::class
)->report($e);
return null;
} }
/**
* Whether Anikeen ID still accepts the token (only with services.anikeen.verify_revocation).
*/
protected function tokenIsActive(stdClass $token, string $jwt): bool
{
if (!config('services.anikeen.verify_revocation')) {
return true;
}
$key = 'anikeen-id:token-active:' . hash('sha256', $jwt);
if (($cached = Cache::get($key)) !== null) {
return (bool) $cached;
}
try {
$result = (clone app(AnikeenId::class))->withToken($jwt)->getAuthedUser();
} catch (Throwable) {
return false; // Anikeen ID unreachable: fail closed, ask again next time
}
$status = $result->response?->getStatusCode();
if ($result->success()) {
$active = true;
} elseif (in_array($status, [401, 403], true)) {
$active = false;
} else {
return false; // server error: fail closed without caching
}
$ttl = (int) config('services.anikeen.revocation_cache_ttl', 300);
if (isset($token->exp)) {
$ttl = max(1, min($ttl, (int) $token->exp - time()));
}
Cache::put($key, $active, $ttl);
return $active;
}
/**
* Find the local user of an Anikeen ID.
*/
protected function retrieveUser(string $anikeenId): ?Authenticatable
{
return $this->provider->retrieveByAnikeenId($anikeenId);
} }
/** /**
* Authenticate the incoming request via the token cookie. * Authenticate the incoming request via the token cookie.
*/ */
protected function authenticateViaCookie(Request $request): mixed protected function authenticateViaCookie(Request $request): ?Authenticatable
{ {
if (!$token = $this->getTokenViaCookie($request)) { if (!$token = $this->getTokenViaCookie($request)) {
return null; return null;
@@ -118,9 +162,11 @@ class TokenGuard
// If this user exists, we will return this user and attach a "transient" token to // If this user exists, we will return this user and attach a "transient" token to
// the user model. The transient token assumes it has all scopes since the user // the user model. The transient token assumes it has all scopes since the user
// is physically logged into the application via the application's interface. // is physically logged into the application via the application's interface.
/** @var Authenticatable|HasAnikeenTokens $user */ /** @var Authenticatable|HasAnikeenTokens|null $user */
if ($user = $this->provider->retrieveById($token['sub'])) { if ($user = $this->retrieveUser((string) $token['sub'])) {
return $user->withAnikeenAccessToken((object)['scopes' => ['*']]); return method_exists($user, 'withAnikeenAccessToken')
? $user->withAnikeenAccessToken((object) ['scopes' => ['*']])
: $user;
} }
return null; return null;
@@ -136,15 +182,18 @@ class TokenGuard
// database. If we can't decrypt the value we'll bail out with a null return. // database. If we can't decrypt the value we'll bail out with a null return.
try { try {
$token = $this->decodeJwtTokenCookie($request); $token = $this->decodeJwtTokenCookie($request);
} catch (Exception $e) { } catch (Exception) {
return null;
}
if (!isset($token['sub'], $token['expiry'])) {
return null; return null;
} }
// We will compare the CSRF token in the decoded API token against the CSRF header // We will compare the CSRF token in the decoded API token against the CSRF header
// sent with the request. If they don't match then this request isn't sent from // sent with the request. If they don't match then this request isn't sent from
// a valid source and we won't authenticate the request for further handling. // a valid source and we won't authenticate the request for further handling.
if (!AnikeenId::$ignoreCsrfToken && (!$this->validCsrf($token, $request) || if (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) {
time() >= $token['expiry'])) {
return null; return null;
} }
@@ -156,7 +205,7 @@ class TokenGuard
*/ */
protected function decodeJwtTokenCookie(Request $request): array protected function decodeJwtTokenCookie(Request $request): array
{ {
return (array)JWT::decode( return (array) JWT::decode(
CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)), CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)),
new Key( new Key(
$this->encrypter->getKey(), $this->encrypter->getKey(),
@@ -186,7 +235,7 @@ class TokenGuard
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized())); $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
} }
return $token; return (string) $token;
} }
/** /**
+25 -2
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Auth; namespace Anikeen\Id\Auth;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider as Base; use Illuminate\Contracts\Auth\UserProvider as Base;
@@ -10,11 +11,33 @@ class UserProvider implements Base
/** /**
* Create a new AnikeenId user provider. * Create a new AnikeenId user provider.
*/ */
public function __construct(protected Base $provider, protected string $providerName) public function __construct(protected Base $provider, protected string $providerName, protected ?string $column = null)
{ {
// //
} }
/**
* The column that holds a user's Anikeen ID: the provider's `column`, else
* `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
*/
public function column(): string
{
return $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
}
/**
* Find the user of an Anikeen ID (the `sub` of an access token).
*/
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
{
if (method_exists($this->provider, 'retrieveByAnikeenId')) {
return $this->provider->retrieveByAnikeenId($anikeenId);
}
// eloquent / database providers: a plain where on the column
return $this->provider->retrieveByCredentials([$this->column() => $anikeenId]);
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
@@ -65,6 +88,6 @@ class UserProvider implements Base
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false) public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{ {
// TODO: Implement rehashPasswordIfRequired() method. $this->provider->rehashPasswordIfRequired($user, $credentials, $force);
} }
} }
+15
View File
@@ -29,6 +29,21 @@ trait Billable
use ManagesTransactions; use ManagesTransactions;
use ManagesUserSshKeys; use ManagesUserSshKeys;
/*
* API results cached per instance. Declared, so Eloquent models don't take them for
* attributes (an undeclared property goes through Model::__set and save() then fails
* with "no such column").
*/
protected mixed $addressesCache = null;
protected mixed $countriesCache = null;
protected mixed $defaultPaymentMethodCache = null;
protected mixed $invoicesCache = null;
protected mixed $ordersCache = null;
protected mixed $paymentMethodsCache = null;
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
/** /**
* Get the currently authenticated user. * Get the currently authenticated user.
* *
+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;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Anikeen\Id\Concerns;
/**
* Reading a checkout session (see AppCheckoutSession and CheckoutSession).
*/
trait IsCheckoutSession
{
/**
* Where the user confirms (or cancels) all of the session's subscriptions, or null once the
* session is no longer open. The user then returns to the session's `return_url` with
* `status` (completed or cancelled when confirming or cancelling; expired only from the
* "back to app" link of a session that had expired) and `checkout_session` (the id). Check the
* status through the API or the checkout.* webhooks; don't trust the query string.
*/
public function confirmationUrl(): ?string
{
return $this->confirmation_url ?: null;
}
public function isOpen(): bool
{
return $this->status === self::STATUS_OPEN;
}
/**
* Confirmed: all of its subscriptions are running.
*/
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
/**
* Cancelled or expired: all of its drafts were cancelled.
*/
public function isCancelled(): bool
{
return in_array($this->status, [self::STATUS_CANCELLED, self::STATUS_EXPIRED], true);
}
/**
* @return list<string>
*/
public function subscriptionIds(): array
{
return array_values((array) ($this->subscription_ids ?? []));
}
}
+20
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\CheckoutSessions;
use Anikeen\Id\Resources\Subscriptions; use Anikeen\Id\Resources\Subscriptions;
use Throwable; use Throwable;
@@ -37,6 +39,24 @@ trait ManagesSubscriptions
return new AppSubscriptions($this); return new AppSubscriptions($this);
} }
/**
* Checkout sessions for the user, created with the app's own token (trusted apps only, scope
* subscriptions:client): the user confirms several subscriptions at once on Anikeen ID.
*/
public function appCheckoutSessions(): AppCheckoutSessions
{
return new AppCheckoutSessions($this);
}
/**
* Checkout sessions with the user's own access token (any app): the user confirms several
* subscriptions at once on Anikeen ID.
*/
public function checkoutSessions(): CheckoutSessions
{
return new CheckoutSessions($this);
}
/** /**
* The user's orders that bill the app's subscriptions, read with the app's own token * The user's orders that bill the app's subscriptions, read with the app's own token
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones. * (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
+11 -1
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Throwable; use Throwable;
@@ -19,6 +20,15 @@ trait ManagesUserBilling
return new AppOrders(); return new AppOrders();
} }
/**
* Checkout sessions of the app for any user, e.g. to look one up from a checkout.* webhook
* (create() then needs a user_id).
*/
public function appCheckoutSessions(): AppCheckoutSessions
{
return new AppCheckoutSessions();
}
/** /**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right * Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
@@ -31,7 +41,7 @@ trait ManagesUserBilling
* billing_address?: array, * billing_address?: array,
* shipping_address_id?: string, * shipping_address_id?: string,
* shipping_address?: array, * shipping_address?: array,
* items: array<array{type: string, name: string, description?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>, * items: array<array{type: string, name: string, description?: string, image_url?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
* checkout?: bool, * checkout?: bool,
* transaction_id?: string, * transaction_id?: string,
* transaction?: array, * transaction?: array,
+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';
}
+25
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Throwable; use Throwable;
/** /**
@@ -19,6 +20,30 @@ class ApiException extends ResourceException
parent::__construct($message, $status, $previous); 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);
}
public function isNotFound(): bool public function isNotFound(): bool
{ {
return $this->status === 404; return $this->status === 404;
+5 -5
View File
@@ -2,10 +2,10 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result; /**
use Exception; * A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
* Anikeen ID's error code.
class CollectionException extends Exception */
class CollectionException extends ApiException
{ {
} }
+16 -2
View File
@@ -26,9 +26,23 @@ trait HasAnikeenTokens
*/ */
public function anikeenTokenCan(string $scope): bool public function anikeenTokenCan(string $scope): bool
{ {
$scopes = $this->accessToken ? $this->accessToken->scopes : []; $scopes = (array) ($this->accessToken->scopes ?? []);
return in_array('*', $scopes) || in_array($scope, $this->accessToken->scopes); return in_array('*', $scopes, true) || in_array($scope, $scopes, true);
}
/**
* Determine if the current API token has any of the given scopes.
*/
public function anikeenTokenCanAny(array $scopes): bool
{
foreach ($scopes as $scope) {
if ($this->anikeenTokenCan($scope)) {
return true;
}
}
return false;
} }
/** /**
+52 -10
View File
@@ -7,32 +7,74 @@ use Firebase\JWT\JWT;
use Firebase\JWT\Key; use Firebase\JWT\Key;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use RuntimeException;
use stdClass; use stdClass;
use Throwable; use Throwable;
/**
* Verifies access tokens issued by Anikeen ID (Passport, RS256): signature against
* Anikeen ID's public key plus the time claims (exp, nbf, iat; 60 seconds leeway).
*
* The key is `services.anikeen.public_key` (PEM, "\n" escapes allowed) or the file at
* `services.anikeen.public_key_path`, else the key shipped with this package for the
* configured mode (production / staging). It is read once per process.
*/
class JwtParser class JwtParser
{ {
/**
* Public keys already read, by source.
*
* @var array<string, string>
*/
private static array $keys = [];
/** /**
* @throws AuthenticationException * @throws AuthenticationException
*/ */
public function decode(Request $request): stdClass public function decode(Request $request): stdClass
{ {
return $this->decodeToken((string) $request->bearerToken());
}
/**
* @throws AuthenticationException
*/
public function decodeToken(string $token): stdClass
{
if ($token === '') {
throw new AuthenticationException('No access token given.');
}
JWT::$leeway = 60; JWT::$leeway = 60;
// outside the try: a missing key is a configuration error, not an invalid token
$key = new Key($this->getOauthPublicKey(), 'RS256');
try { try {
return JWT::decode( return JWT::decode($token, $key);
$request->bearerToken(), } catch (Throwable) {
new Key($this->getOauthPublicKey(), 'RS256') throw new AuthenticationException('The access token is invalid or expired.');
);
} catch (Throwable $exception) {
throw (new AuthenticationException());
} }
} }
private function getOauthPublicKey(): bool|string /**
* Forget the keys read so far (e.g. after changing the configuration in tests).
*/
public static function flushKeys(): void
{ {
return AnikeenId::getMode() === 'staging' self::$keys = [];
? file_get_contents(dirname(__DIR__, 3) . '/oauth-public.staging.key') }
: file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key');
protected function getOauthPublicKey(): string
{
if ($key = config('services.anikeen.public_key')) {
return self::$keys['config:' . md5($key)] ??= str_replace('\\n', "\n", $key);
}
$path = config('services.anikeen.public_key_path') ?: (AnikeenId::getMode() === 'staging'
? dirname(__DIR__, 3) . '/oauth-public.staging.key'
: dirname(__DIR__, 3) . '/oauth-public.key');
return self::$keys['file:' . $path] ??= (is_readable($path) ? (string) file_get_contents($path) : throw new RuntimeException("Anikeen ID public key [$path] is not readable."));
} }
} }
+2 -2
View File
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
use Closure; use Closure;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response;
class CheckForAnyScope extends UseParameters class CheckForAnyScope extends UseParameters
{ {
@@ -17,7 +17,7 @@ class CheckForAnyScope extends UseParameters
*/ */
public function handle(Request $request, Closure $next, ...$scopes): Response public function handle(Request $request, Closure $next, ...$scopes): Response
{ {
if (!$request->user() || !$request->user()->anikeenToken()) { if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
throw new AuthenticationException; throw new AuthenticationException;
} }
+2 -2
View File
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
use Closure; use Closure;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Symfony\Component\HttpFoundation\Response;
class CheckScopes extends UseParameters class CheckScopes extends UseParameters
{ {
@@ -17,7 +17,7 @@ class CheckScopes extends UseParameters
*/ */
public function handle(Request $request, Closure $next, ...$scopes): Response public function handle(Request $request, Closure $next, ...$scopes): Response
{ {
if (!$request->user() || !$request->user()->anikeenToken()) { if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
throw new AuthenticationException; throw new AuthenticationException;
} }
+34 -13
View File
@@ -8,8 +8,11 @@ use Anikeen\Id\Auth\UserProvider;
use Anikeen\Id\Contracts; use Anikeen\Id\Contracts;
use Anikeen\Id\Helpers\JwtParser; use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Repository; use Anikeen\Id\Repository;
use Illuminate\Auth\AuthManager;
use Illuminate\Auth\RequestGuard; use Illuminate\Auth\RequestGuard;
use Illuminate\Support\Facades\Auth; use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request;
use InvalidArgumentException;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AnikeenIdServiceProvider extends ServiceProvider class AnikeenIdServiceProvider extends ServiceProvider
@@ -38,15 +41,28 @@ class AnikeenIdServiceProvider extends ServiceProvider
} }
/** /**
* Register the token guard. * Register the `anikeen` guard driver and user provider.
*
* Laravel binds non-static extend() closures to the AuthManager, so these closures are
* static and use nothing but their arguments.
*/ */
protected function registerGuard(): void protected function registerGuard(): void
{ {
Auth::resolved(function ($auth) { $this->callAfterResolving('auth', static function (AuthManager $auth): void {
$auth->extend('anikeen', function ($app, $name, array $config) { $auth->provider('anikeen', static fn (Application $app, array $config) => new AnikeenIdUserProvider(
return tap($this->makeGuard($config), function ($guard) { $app->make(AnikeenId::class),
$this->app->refresh('request', $guard, 'setRequest'); $app->make('request'),
}); $config['model'] ?? $app['config']->get('auth.providers.users.model'),
$config['fields'] ?? [],
$config['column'] ?? null,
));
$auth->extend('anikeen', static function (Application $app, string $name, array $config): RequestGuard {
$guard = AnikeenIdServiceProvider::makeGuard($app, $config);
$app->refresh('request', $guard, 'setRequest');
return $guard;
}); });
}); });
} }
@@ -54,15 +70,20 @@ class AnikeenIdServiceProvider extends ServiceProvider
/** /**
* Make an instance of the token guard. * Make an instance of the token guard.
*/ */
protected function makeGuard(array $config): RequestGuard public static function makeGuard(Application $app, array $config): RequestGuard
{ {
return new RequestGuard(function ($request) use ($config) { $providerName = $config['provider'] ?? $app['config']->get('auth.defaults.provider', 'users');
$provider = $app['auth']->createUserProvider($providerName)
?? throw new InvalidArgumentException("Authentication user provider [$providerName] is not configured.");
$providerConfig = $app['config']->get("auth.providers.$providerName", []);
return new RequestGuard(static function (Request $request) use ($app, $provider, $providerName, $providerConfig, $config) {
return (new TokenGuard( return (new TokenGuard(
new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']), new UserProvider($provider, $providerName, $config['column'] ?? $providerConfig['column'] ?? null),
$this->app->make('encrypter'), $app->make('encrypter'),
$this->app->make(JwtParser::class) $app->make(JwtParser::class)
))->user($request); ))->user($request);
}, $this->app['request']); }, $app['request'], $provider);
} }
/** /**
+13 -1
View File
@@ -18,7 +18,8 @@ class AnikeenIdUserProvider implements UserProvider
private AnikeenId $anikeenId, private AnikeenId $anikeenId,
private Request $request, private Request $request,
private string $model, private string $model,
private array $fields = [] private array $fields = [],
private ?string $column = null,
) { ) {
$this->accessTokenField = AnikeenId::getAccessTokenField(); $this->accessTokenField = AnikeenId::getAccessTokenField();
} }
@@ -97,6 +98,17 @@ class AnikeenIdUserProvider implements UserProvider
->first(); ->first();
} }
/**
* Find the user of an Anikeen ID (the `sub` of an access token) by the provider's `column`,
* else `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
*/
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
{
$column = $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
return $this->newModelQuery()->where($column, $anikeenId)->first();
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\IsCheckoutSession;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use JsonSerializable;
/**
* A checkout session created with the app's own token, see AppCheckoutSessions.
*
* @property string $id
* @property string $user_id
* @property ?string $oauth_client_id
* @property string $status open, completed, cancelled or expired
* @property ?string $return_url
* @property ?object $metadata
* @property ?string $confirmation_url
* @property list<string> $subscription_ids
* @property list<object> $subscriptions
* @property ?string $expires_at
* @property ?string $completed_at
* @property ?string $cancelled_at
* @property ?string $created_at
*/
class AppCheckoutSession implements JsonSerializable
{
use IsCheckoutSession;
public const STATUS_OPEN = 'open';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_EXPIRED = 'expired';
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* The session's subscriptions (drafts until the user confirms).
*
* @return list<AppSubscription>
*/
public function subscriptions(): array
{
return array_map(fn (object $subscription) => new AppSubscription($subscription), array_values((array) ($this->data->subscriptions ?? [])));
}
/**
* Withdraw an open session (e.g. the cart changed): it and its drafts are cancelled.
*
* @throws ApiException 409 "checkout_session_completed", "checkout_session_cancelled", "checkout_session_expired"
*/
public function cancel(): self
{
return $this->send('POST', '/cancel');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/checkout-sessions/%s%s', $this->id, $action))->data;
return $this;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Checkout sessions created with the app's own token (trusted apps, scope `subscriptions:client`):
* several subscriptions of one user that the user confirms together on Anikeen ID, instead of the
* app checking them out itself. For one user ($user->appCheckoutSessions()) or, to look sessions
* up e.g. from a webhook, for all users (app(AnikeenId::class)->appCheckoutSessions()).
* Nothing is requested until a method is called.
*/
class AppCheckoutSessions
{
public function __construct(protected Billable|Model|null $billable = null)
{
}
/**
* Open a checkout session with new subscriptions (their attributes, like
* AppSubscriptions::create()) and/or existing drafts of the app (`['id' => …]`).
*
* @param array{
* subscriptions: list<array{id: string}|array{
* group?: null|string,
* name: string,
* description?: null|string,
* unit: string,
* price: float|string,
* monthly_cap?: null|float|string,
* predecessor_id?: null|string,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string
* }>,
* return_url?: null|string,
* metadata?: null|array,
* idempotency_key?: null|string,
* user_id?: string
* } $attributes return_url must share scheme, host and port with one of the app's redirect
* URIs; the user comes back to it with `status` and `checkout_session`. A
* repeated idempotency_key returns the first session instead of a new one.
*
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
*/
public function create(array $attributes): AppCheckoutSession
{
if ($this->billable) {
$attributes['user_id'] = (new AppSubscriptions($this->billable))->userId();
} elseif (empty($attributes['user_id'])) {
throw new InvalidArgumentException('A checkout session needs a user: use $user->appCheckoutSessions() or pass user_id.');
}
return new AppCheckoutSession(AppRequest::send('POST', 'v1/client/checkout-sessions', $attributes)->data);
}
/**
* A session of the app; null when it doesn't exist (or belongs to another user).
*
* @throws ApiException
*/
public function find(string $id): ?AppCheckoutSession
{
try {
$session = new AppCheckoutSession(AppRequest::send('GET', sprintf('v1/client/checkout-sessions/%s', $id))->data);
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return !$this->billable || $session->user_id === (new AppSubscriptions($this->billable))->userId() ? $session : null;
}
}
+13 -2
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Resources; namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use DateTimeInterface; use DateTimeInterface;
@@ -17,11 +18,16 @@ use JsonSerializable;
* @property ?string $description * @property ?string $description
* @property string $unit * @property string $unit
* @property float $price * @property float $price
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property ?float $vat_rate * @property ?float $vat_rate
* @property bool $active * @property bool $active
* @property bool $autorenew * @property bool $autorenew
* @property bool $cancelled * @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 $starts_at
* @property ?string $ends_at * @property ?string $ends_at
* @property ?string $accounting_at * @property ?string $accounting_at
@@ -29,9 +35,12 @@ use JsonSerializable;
* @property int $failed_renewal_retries * @property int $failed_renewal_retries
* @property ?array $payload * @property ?array $payload
* @property ?string $idempotency_key * @property ?string $idempotency_key
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any
*/ */
class AppSubscription implements JsonSerializable class AppSubscription implements JsonSerializable
{ {
use HasSubscriptionStatus;
public function __construct(protected object $data) public function __construct(protected object $data)
{ {
} }
@@ -47,7 +56,9 @@ class AppSubscription implements JsonSerializable
} }
/** /**
* Change a subscription that wasn't checked out yet. * Change a subscription (send only what changes). Before checkout everything can change;
* afterwards only the description, which names what the customer pays for and is shown
* on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`.
* *
* @throws ApiException * @throws ApiException
*/ */
+6
View File
@@ -44,12 +44,18 @@ class AppSubscriptions
* description?: null|string, * description?: null|string,
* unit: string, * unit: string,
* price: float|string, * price: float|string,
* monthly_cap?: null|float|string,
* predecessor_id?: null|string,
* vat_rate?: float, * vat_rate?: float,
* payload?: null|array, * payload?: null|array,
* ends_at?: null|string, * ends_at?: null|string,
* idempotency_key?: null|string * idempotency_key?: null|string
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the * } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
* subscription created by the first one instead of a duplicate. * subscription created by the first one instead of a duplicate.
* monthly_cap: net, at most this per calendar month (hour and day units only,
* at most 2 decimals, not below price; null = no cap).
* predecessor_id: the subscription this one replaces (a resize; same user and
* app, checked out, not cancelled): both share the monthly cap within a month.
* *
* @throws ApiException * @throws ApiException
*/ */
+7 -1
View File
@@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Closure; use Closure;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use JsonSerializable; use JsonSerializable;
use Throwable; use Throwable;
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
*/ */
protected function __construct(callable $callable) protected function __construct(callable $callable)
{ {
try {
$this->result = $callable(); $this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw CollectionException::unreachable($e);
}
if (!$this->result->success()) { 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; namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException; use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable; use JsonSerializable;
#[\AllowDynamicProperties] #[\AllowDynamicProperties]
@@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable
public Result $result; 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) public function __construct(callable $callable)
{ {
try {
$this->result = $callable(); $this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw ApiException::unreachable($e);
}
if (!$this->result->success()) { if (!$this->result->success()) {
if (($this->result->data->error ?? null) === 'confirmation_required') { 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) { foreach ($this->result->data as $key => $value) {
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\IsCheckoutSession;
use Throwable;
/**
* A checkout session created with the user's access token, see CheckoutSessions.
*
* @property string $id
* @property string $user_id
* @property ?string $oauth_client_id
* @property string $status open, completed, cancelled or expired
* @property ?string $return_url
* @property ?object $metadata
* @property ?string $confirmation_url
* @property list<string> $subscription_ids
* @property list<object> $subscriptions
* @property ?string $expires_at
* @property ?string $completed_at
* @property ?string $cancelled_at
* @property ?string $created_at
*/
class CheckoutSession extends BaseResource
{
use HasBillable;
use IsCheckoutSession;
public const STATUS_OPEN = 'open';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_EXPIRED = 'expired';
/**
* Withdraw an open session: it and its drafts are cancelled.
*
* @throws Throwable
*/
public function cancel(): self
{
return (new self(fn () => $this->billable->anikeenId()
->request('POST', sprintf('v1/checkout-sessions/%s/cancel', $this->id))))
->setBillable($this->billable);
}
/**
* @throws Throwable
*/
public function refresh(): self
{
return (new self(fn () => $this->billable->anikeenId()
->request('GET', sprintf('v1/checkout-sessions/%s', $this->id))))
->setBillable($this->billable);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use Illuminate\Database\Eloquent\Model;
use Throwable;
/**
* Checkout sessions of the signed-in user, with the user's access token (any app): several
* subscriptions the user confirms together on Anikeen ID. Nothing is requested until a method is
* called.
*/
class CheckoutSessions
{
use HasBillable;
public function __construct(Billable|Model $billable)
{
$this->billable = $billable;
}
/**
* Open a checkout session with new subscriptions and/or existing drafts of the app
* (`['id' => …]`); see AppCheckoutSessions::create() for the attributes (no user_id here,
* vat_rate only for trusted apps).
*
* @throws Throwable
*/
public function create(array $attributes): CheckoutSession
{
return (new CheckoutSession(fn () => $this->billable->anikeenId()
->request('POST', 'v1/checkout-sessions', $attributes)))
->setBillable($this->billable);
}
/**
* A session the app created for the user; null when it doesn't exist.
*
* @throws Throwable
*/
public function find(string $id): ?CheckoutSession
{
try {
return (new CheckoutSession(fn () => $this->billable->anikeenId()
->request('GET', sprintf('v1/checkout-sessions/%s', $id))))
->setBillable($this->billable);
} catch (ResourceException $e) {
if ($e->getCode() === 404) {
return null;
}
throw $e;
}
}
}
+17 -4
View File
@@ -44,11 +44,11 @@ class Order extends BaseResource
* company_name: null|string, * company_name: null|string,
* first_name: string, * first_name: string,
* last_name: string, * last_name: string,
* address: null|string, * address: string,
* address_2: string, * address_2: null|string,
* house_number: null|string, * house_number: null|string,
* city: string, * city: string,
* state: string, * state: null|string,
* postal_code: string, * postal_code: string,
* country_iso: string, * country_iso: string,
* phone_number: null|string, * phone_number: null|string,
@@ -56,7 +56,7 @@ class Order extends BaseResource
* } * }
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* @throws Throwable * @throws Throwable
*/ */
public function update(array $attributes = []): self public function update(array $attributes = []): self
@@ -101,6 +101,19 @@ class Order extends BaseResource
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success(); ->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
} }
/**
* Deliveries of this order's goods; `create()` ships them (see Shipments::create()).
*
* @throws Throwable
*/
public function shipments(): Shipments
{
return Shipments::builder(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments', $this->id)))
->setBillable($this->billable)
->setParent($this);
}
/** /**
* Get order items from given order. * Get order items from given order.
* *
+14 -7
View File
@@ -38,11 +38,11 @@ class Orders extends BaseCollection
* company_name: null|string, * company_name: null|string,
* first_name: string, * first_name: string,
* last_name: string, * last_name: string,
* address: null|string, * address: string,
* address_2: string, * address_2: null|string,
* house_number: null|string, * house_number: null|string,
* city: string, * city: string,
* state: string, * state: null|string,
* postal_code: string, * postal_code: string,
* country_iso: string, * country_iso: string,
* phone_number: null|string, * phone_number: null|string,
@@ -51,15 +51,22 @@ class Orders extends BaseCollection
* items: array<array{ * items: array<array{
* type: string, * type: string,
* name: string, * name: string,
* description: string, * description?: null|string,
* price: float|int, * image_url?: null|string,
* price?: float|int|string,
* gross_price?: float|int|string,
* unit: string, * unit: string,
* units: int * units: int
* }> * }>
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity) * - items: Array of order items:
* - type: digital, physical or physical-reduced (VAT category)
* - price/gross_price: net or gross unit price, one of both
* - unit: hour, day, week, month, year, onetime or piece
* - image_url: http(s) URL of a product image; Anikeen ID copies it, so it stays
* visible on the order and invoice when the shop removes it later
* @throws Throwable * @throws Throwable
*/ */
public function create(array $attributes = []): Order public function create(array $attributes = []): Order
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
* @property string $number delivery note number, e.g. LS-2026-0001
* @property string $order_id
* @property null|string $carrier
* @property null|string $tracking_number
* @property null|string $tracking_url
* @property array $items order_item_id, name, description, units
* @property object $shipping_address
* @property null|string $note
* @property string $shipped_at
*/
class Shipment extends BaseResource
{
use HasBillable;
use HasParent;
/**
* The delivery note as PDF (binary string).
*
* @throws Throwable
*/
public function deliveryNote(): string
{
$result = $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s/delivery-note', $this->order_id, $this->id));
return (string) $result->response?->getBody();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* Deliveries of an order's physical goods. Each one adds a delivery note to the customer's
* order documents on Anikeen ID.
*/
class Shipments extends BaseCollection
{
use HasBillable;
use HasParent;
/**
* Ship goods of a paid order. Only the app that created the order (or a trusted app) can.
*
* Without `items`, everything physical that hasn't been shipped yet is shipped. Items and
* the shipping address are stored as they are now; the user sees them on the delivery note.
*
* @param array{
* carrier?: string,
* tracking_number?: string,
* tracking_url?: string,
* note?: string,
* shipped_at?: string,
* notify_customer?: bool,
* items?: array<array{order_item_id: string, units?: float|int|string}>
* } $attributes
* - carrier: e.g. "DHL"
* - tracking_url: http(s) link to track the parcel, shown to the user
* - shipped_at: when it was sent (default: now, not in the future)
* - notify_customer: Anikeen ID mails the user with the delivery note (default true);
* false if your shop sends its own mail
* - items: physical items of the order and how many of them (default: all left)
*
* Errors (ResourceException with the HTTP status): 409 when the order isn't paid or nothing
* is left to ship, 422 for items that aren't physical items of the order or exceed what's left.
*
* @throws Throwable
*/
public function create(array $attributes = []): Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s/shipments', $this->parent->id), $attributes)))
->setBillable($this->billable)
->setParent($this->parent);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s', $this->parent->id, $id))))
->setBillable($this->billable)
->setParent($this->parent);
}
}
+14
View File
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable; use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl; use Anikeen\Id\Concerns\HasConfirmationUrl;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable; use Throwable;
@@ -13,15 +14,26 @@ use Throwable;
* @property string $description * @property string $description
* @property string $unit * @property string $unit
* @property float $price * @property float $price
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property float $vat_rate * @property float $vat_rate
* @property array $payload * @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 $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) * @property null|string $confirmation_url where the user confirms it (third-party apps)
*/ */
class Subscription extends BaseResource class Subscription extends BaseResource
{ {
use HasBillable; use HasBillable;
use HasConfirmationUrl; use HasConfirmationUrl;
use HasSubscriptionStatus;
/** /**
* Update a given subscription from the current user. * Update a given subscription from the current user.
@@ -32,6 +44,7 @@ class Subscription extends BaseResource
* description: null|string, * description: null|string,
* unit: string, * unit: string,
* price: float, * price: float,
* monthly_cap?: null|float,
* vat_rate: float, * vat_rate: float,
* payload: null|array, * payload: null|array,
* ends_at: null|string * ends_at: null|string
@@ -41,6 +54,7 @@ class Subscription extends BaseResource
* - description: The description (optional) * - description: The description (optional)
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year") * - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit (required when set) * - price: The price per unit (required when set)
* - monthly_cap: Net, at most this per calendar month (hour and day units; optional)
* - vat_rate: The VAT rate (required when set) * - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional) * - payload: The payload (optional)
* - ends_at: The end date (optional) * - ends_at: The end date (optional)
+4
View File
@@ -20,6 +20,8 @@ class Subscriptions extends BaseCollection
* description: null|string, * description: null|string,
* unit: string, * unit: string,
* price: float, * price: float,
* monthly_cap?: null|float,
* predecessor_id?: null|string,
* vat_rate: float, * vat_rate: float,
* payload: null|array, * payload: null|array,
* ends_at: null|string * ends_at: null|string
@@ -29,6 +31,8 @@ class Subscriptions extends BaseCollection
* - description: The description (optional) * - description: The description (optional)
* - unit: The unit (e.g. "hour", "day", "week", "month", "year") * - unit: The unit (e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit * - price: The price per unit
* - monthly_cap: Net, at most this per calendar month (hour and day units, max. 2 decimals; optional)
* - predecessor_id: The subscription this one replaces (a resize); both share the monthly cap (optional)
* - vat_rate: The VAT rate (required when set) * - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional) * - payload: The payload (optional)
* - ends_at: The end date (optional) * - ends_at: The end date (optional)
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Anikeen\Id\Webhooks;
/**
* The `type` of webhook events Anikeen ID sends, e.g.
* `match ($event['type']) { EventType::CHECKOUT_COMPLETED => …, … }`.
*/
final class EventType
{
public const ORDER_CREATED = 'order.created';
public const ORDER_COMPLETED = 'order.completed';
public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
public const ORDER_CANCELLED = 'order.cancelled';
public const ORDER_SHIPPED = 'order.shipped';
public const SUBSCRIPTION_CREATED = 'subscription.created';
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
public const SUBSCRIPTION_PAUSED = 'subscription.paused';
public const SUBSCRIPTION_RESUMED = 'subscription.resumed';
public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
public const SUBSCRIPTION_EXPIRED = 'subscription.expired';
/** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */
public const CHECKOUT_COMPLETED = 'checkout.completed';
/** The user or the app cancelled a checkout session; its drafts were cancelled. */
public const CHECKOUT_CANCELLED = 'checkout.cancelled';
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
public const CHECKOUT_EXPIRED = 'checkout.expired';
public const TEST = 'webhook.test';
/**
* @return list<string>
*/
public static function all(): array
{
return array_values((new \ReflectionClass(self::class))->getConstants());
}
private function __construct()
{
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
class BillableModelTest extends TestCase
{
public function test_cached_api_results_are_not_model_attributes(): void
{
$user = new class extends Model {
use Billable;
protected $guarded = [];
public function cacheAll(): void
{
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
$this->{$name.'Cache'} = 'cached';
}
}
};
$user->email = 'ada@example.com';
$user->cacheAll();
// save() would otherwise write them as columns ("no such column: ordersCache")
$this->assertSame(['email' => 'ada@example.com'], $user->getAttributes());
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSession;
use Anikeen\Id\Resources\AppSubscription;
use Anikeen\Id\Resources\CheckoutSession;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class CheckoutSessionsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function sessionData(array $overrides = []): array
{
return ['data' => [
'object' => 'checkout_session',
'id' => '01JCS',
'user_id' => '01JUSER',
'status' => 'open',
'return_url' => 'https://console.test/builder/done',
'metadata' => ['cart' => '42'],
'confirmation_url' => 'https://id.anikeen.com/checkout/sessions/01JCS',
'subscription_ids' => ['01JS1', '01JS2'],
'subscriptions' => [
['id' => '01JS1', 'user_id' => '01JUSER', 'name' => 'Cloud Server S', 'active' => false, 'payload' => ['product' => 'server-s']],
['id' => '01JS2', 'user_id' => '01JUSER', 'name' => 'Backup', 'active' => false],
],
...$overrides,
]];
}
private function request(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_a_trusted_app_opens_a_session_with_its_own_token(): void
{
$this->respond(201, $this->sessionData());
$session = $this->owner()->appCheckoutSessions()->create([
'return_url' => 'https://console.test/builder/done',
'idempotency_key' => 'cart:42',
'subscriptions' => [
['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']],
['id' => '01JS2'],
],
]);
$this->assertSame('POST /api/v1/client/checkout-sessions Bearer app-token', $this->request(0));
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame([['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']], ['id' => '01JS2']], $this->sentJson(0)['subscriptions']);
$this->assertInstanceOf(AppCheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertTrue($session->isOpen());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$this->assertContainsOnlyInstancesOf(AppSubscription::class, $session->subscriptions());
$this->assertSame('server-s', $session->subscriptions()[0]->payload->product);
$this->assertSame('42', $session->metadata->cart);
}
public function test_the_app_finds_refreshes_and_cancels_sessions(): void
{
$this->respond(200, $this->sessionData(['status' => 'completed', 'confirmation_url' => null]));
$this->respond(200, $this->sessionData(['user_id' => 'someone-else']));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$this->respond(200, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'cancelled', 'confirmation_url' => null]));
$this->respond(409, ['message' => 'The checkout session is cancelled.', 'error' => 'checkout_session_cancelled']);
$sessions = $this->owner()->appCheckoutSessions();
$completed = $sessions->find('01JCS');
$this->assertTrue($completed->isCompleted());
$this->assertNull($completed->confirmationUrl());
$this->assertNull($sessions->find('01JCS'), 'another user\'s session');
$this->assertNull($sessions->find('unknown'));
$session = app(AnikeenId::class)->appCheckoutSessions()->find('01JCS'); // e.g. from a webhook
$session->cancel();
$this->assertTrue($session->isCancelled());
$this->assertSame('POST /api/v1/client/checkout-sessions/01JCS/cancel Bearer app-token', $this->request(4));
try {
$session->cancel();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('checkout_session_cancelled', $e->error);
}
}
public function test_without_a_billable_the_user_id_is_required(): void
{
$this->expectException(InvalidArgumentException::class);
app(AnikeenId::class)->appCheckoutSessions()->create(['subscriptions' => [['id' => '01JS1']]]);
}
public function test_any_app_opens_a_session_with_the_users_token(): void
{
$this->respond(201, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'expired', 'confirmation_url' => null]));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$user = $this->owner();
$session = $user->checkoutSessions()->create(['subscriptions' => [['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]]]);
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
$this->assertArrayNotHasKey('user_id', $this->sentJson(0));
$this->assertInstanceOf(CheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$expired = $user->checkoutSessions()->find('01JCS');
$this->assertTrue($expired->isCancelled());
$this->assertSame(CheckoutSession::STATUS_EXPIRED, $expired->status);
$this->assertNull($user->checkoutSessions()->find('unknown'));
}
public function test_nothing_is_requested_until_a_method_is_called(): void
{
$this->owner()->checkoutSessions();
$this->owner()->appCheckoutSessions();
$this->assertCount(0, $this->sent);
}
public function test_checkout_webhooks_carry_the_session(): void
{
$payload = json_encode(['id' => '01JEV', 'type' => 'checkout.completed', 'created' => time(), 'data' => ['object' => $this->sessionData(['status' => 'completed'])['data']]]);
$timestamp = time();
$signature = 't=' . $timestamp . ',v1=' . hash_hmac('sha256', "{$timestamp}.{$payload}", 'whsec_test');
$event = Webhook::constructEvent($payload, $signature, 'whsec_test');
$this->assertSame(EventType::CHECKOUT_COMPLETED, $event['type']);
$this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']);
$this->assertContains('checkout.expired', EventType::all());
$this->assertContains('subscription.activated', EventType::all());
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model;
class ShipmentsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function order(): \Anikeen\Id\Resources\Order
{
$this->respond(200, ['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0], 'links' => ['next' => null]]);
$this->respond(200, ['data' => ['id' => 'ord1', 'status' => 'completed']]);
return $this->owner()->orders()->find('ord1');
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_an_order_is_shipped_with_carrier_tracking_and_items(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1', 'carrier' => 'DHL']]);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '0034',
'items' => [['order_item_id' => 'item1', 'units' => 1]],
]);
$this->assertSame('LS-2026-0001', $shipment->number);
$this->assertSame('GET /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(2));
$this->assertSame('POST /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(3));
$this->assertSame(['carrier' => 'DHL', 'tracking_number' => '0034', 'items' => [['order_item_id' => 'item1', 'units' => 1]]], $this->sentJson(3));
}
public function test_shipments_are_listed_and_the_delivery_note_is_downloaded(): void
{
$order = $this->order();
$this->respond(200, ['data' => [['id' => 'shp1', 'number' => 'LS-2026-0001']]]);
$this->respond(200, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 test'));
$shipments = $order->shipments();
$this->assertSame('LS-2026-0001', $shipments->toArray()[0]->number);
$pdf = $shipments->find('shp1')->deliveryNote();
$this->assertSame('%PDF-1.7 test', $pdf);
$this->assertSame('GET /api/v1/orders/ord1/shipments/shp1/delivery-note Bearer user-token', $this->sentCall(4));
}
public function test_refusals_keep_their_status(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(409, ['message' => 'Only paid orders can be shipped.', 'error' => 'order_not_paid']);
try {
$order->shipments()->create();
$this->fail('expected a ResourceException');
} catch (ResourceException $e) {
$this->assertSame(409, $e->getCode());
$this->assertStringContainsString('Only paid orders can be shipped', $e->getMessage());
}
}
}
+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());
}
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\HasAnikeenTokens;
use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
use Anikeen\Id\Http\Middleware\CheckScopes;
use Firebase\JWT\JWT;
use Illuminate\Auth\RequestGuard;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class TokenGuardTest extends TestCase
{
private static ?string $privateKey = null;
private static string $publicKey;
protected function setUp(): void
{
parent::setUp();
if (self::$privateKey === null) {
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($key, $private);
self::$privateKey = $private;
self::$publicKey = openssl_pkey_get_details($key)['key'];
}
JwtParser::flushKeys();
config([
'services.anikeen.public_key' => self::$publicKey,
'app.key' => 'base64:'.base64_encode(str_repeat('k', 32)),
'database.default' => 'testing',
'auth.guards.api' => ['driver' => 'anikeen', 'provider' => 'anikeen'],
'auth.providers.anikeen' => ['driver' => 'anikeen', 'model' => GuardUser::class],
]);
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('anikeen_id')->nullable();
$table->string('email');
});
GuardUser::query()->insert([
['id' => 1, 'anikeen_id' => 'ulid-of-alice', 'email' => 'alice@example.com'],
['id' => 2, 'anikeen_id' => '1', 'email' => 'bob@example.com'],
]);
Route::middleware('auth:api')->get('/me', fn () => ['email' => auth()->user()->email, 'scopes' => auth()->user()->anikeenToken()->scopes]);
Route::middleware(['auth:api', CheckScopes::using('user')])->post('/write', fn () => ['ok' => true]);
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get('/read', fn () => ['ok' => true]);
}
/**
* Like a fresh request in production: the guard remembers its user per application.
*/
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
Auth::forgetGuards();
return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content);
}
private function token(array $claims = [], ?string $privateKey = null): string
{
$now = time();
return JWT::encode(array_merge([
'aud' => '019a0000-0000-7000-8000-000000000001',
'jti' => Str::random(40),
'iat' => $now,
'nbf' => $now,
'exp' => $now + 3600,
'sub' => 'ulid-of-alice',
'scopes' => ['user'],
], $claims), $privateKey ?? self::$privateKey, 'RS256');
}
public function test_the_api_guard_resolves(): void
{
$this->assertInstanceOf(RequestGuard::class, Auth::guard('api'));
$this->assertNull(Auth::guard('api')->user());
}
public function test_a_valid_token_resolves_the_user_by_anikeen_id(): void
{
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
->assertOk()
->assertExactJson(['email' => 'alice@example.com', 'scopes' => ['user']]);
}
public function test_the_user_is_not_looked_up_by_primary_key(): void
{
// sub "2" is bob's primary key, but nobody has the Anikeen ID "2"
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '2'])])->assertUnauthorized();
// sub "1" is bob's Anikeen ID (alice's primary key)
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '1'])])
->assertOk()->assertJsonPath('email', 'bob@example.com');
}
public function test_the_lookup_column_is_configurable(): void
{
config(['auth.providers.anikeen.column' => 'email']);
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => 'bob@example.com'])])
->assertOk()->assertJsonPath('email', 'bob@example.com');
}
public function test_the_eloquent_provider_works_too(): void
{
config([
'auth.guards.api.provider' => 'users',
'auth.providers.users' => ['driver' => 'eloquent', 'model' => GuardUser::class],
]);
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
->assertOk()->assertJsonPath('email', 'alice@example.com');
}
public function test_invalid_tokens_answer_401_json(): void
{
$other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($other, $otherPrivate);
$cases = [
'no token' => [],
'garbage' => ['Authorization' => 'Bearer not-a-jwt'],
'expired' => ['Authorization' => 'Bearer '.$this->token(['exp' => time() - 120])],
'not yet valid' => ['Authorization' => 'Bearer '.$this->token(['nbf' => time() + 600])],
'foreign key' => ['Authorization' => 'Bearer '.$this->token([], $otherPrivate)],
'unknown user' => ['Authorization' => 'Bearer '.$this->token(['sub' => 'nobody'])],
'client credentials' => ['Authorization' => 'Bearer '.$this->token(['sub' => ''])],
];
foreach ($cases as $case => $headers) {
$this->getJson('/me', $headers)->assertUnauthorized()->assertJson(['message' => 'Unauthenticated.'], $case);
}
}
public function test_scopes_are_enforced(): void
{
$read = $this->token(['scopes' => ['user:read']]);
$billing = $this->token(['scopes' => ['billing']]);
$all = $this->token(['scopes' => ['*']]);
$this->getJson('/read', ['Authorization' => "Bearer $read"])->assertOk();
$this->postJson('/write', [], ['Authorization' => "Bearer $read"])->assertForbidden();
$this->getJson('/read', ['Authorization' => "Bearer $billing"])->assertForbidden();
$this->postJson('/write', [], ['Authorization' => "Bearer $all"])->assertOk();
$this->postJson('/write', [], ['Authorization' => 'Bearer '.$this->token()])->assertOk();
}
public function test_token_can(): void
{
$user = GuardUser::query()->find(1)->withAnikeenAccessToken((object) ['scopes' => ['user:read']]);
$this->assertTrue($user->anikeenTokenCan('user:read'));
$this->assertFalse($user->anikeenTokenCan('user'));
$this->assertTrue($user->anikeenTokenCanAny(['user', 'user:read']));
$this->assertFalse((new GuardUser())->anikeenTokenCan('user'));
}
public function test_revoked_tokens_are_rejected_when_revocation_is_verified(): void
{
config(['services.anikeen.verify_revocation' => true]);
$active = $this->token();
$revoked = $this->token();
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
// cached: no second request to Anikeen ID
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
$this->assertCount(1, $this->sent);
$this->assertSame("Bearer $active", $this->sent[0]['request']->getHeaderLine('Authorization'));
$this->assertStringEndsWith('v1/user', (string) $this->sent[0]['request']->getUri());
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->getJson('/me', ['Authorization' => "Bearer $revoked"])->assertUnauthorized();
// Anikeen ID down: fail closed, not cached
$other = $this->token();
$this->respond(503);
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertUnauthorized();
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertOk();
}
}
class GuardUser extends Authenticatable
{
use HasAnikeenTokens;
protected $table = 'users';
public $timestamps = false;
protected $guarded = [];
}
+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();
}
}