Document preferences, their scopes and order.refunded

This commit is contained in:
envoyr committed 2026-11-06 19:29:20 +00:00
1 parent 802f52ce93
commit eebfe557b1
2 files changed
+355 -10

No files matched your search

+187 -5
View File
@@ -16,11 +16,12 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments)
5. [Trusted apps](#trusted-apps)
6. [API authentication](#api-authentication)
7. [General](#general)
8. [Examples](#examples)
9. [Documentation](#documentation)
10. [Development](#development)
6. [Preferences](#preferences)
7. [API authentication](#api-authentication)
8. [General](#general)
9. [Examples](#examples)
10. [Documentation](#documentation)
11. [Development](#development)
## Installation
@@ -181,6 +182,14 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`.
Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants |
|-------|----------|--------|
| `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) |
| `preferences` | `Scope::PREFERENCES` | read and change them |
| `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app |
### 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:
@@ -549,6 +558,10 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token).
`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a
refund or a partial cancellation, with a credit note); the order carries its `refunds` and
`refunded_total`. A full refund also cancels the order, followed by `order.cancelled`.
### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -918,6 +931,156 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address.
## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
follow the user across devices and apps:
- **`global`** is shared by all apps. Only registered keys and values are accepted: today
`SharedPreference::APPEARANCE` with `Appearance::SYSTEM`, `LIGHT` or `DARK`.
- **App namespaces** (e.g. `acme`) hold one app's own data: onboarding state, a default workspace,
the user's customer ID at a payment provider. Use another namespace on staging (e.g.
`acme-staging`), so test data never mixes with production.
**Registration.** Access never comes from scopes alone: any client can request any scope. Ask
Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) for
- the namespaces your app owns (read, write and the lookup),
- read access to another app's namespace,
- `global` with the app's token (users always read and write their own `global` with their token),
- keys whose values are unique (see the lookup below).
Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`.
**Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or
`Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT`
(`preferences:client`).
**Limits.** A value is at most 2048 bytes of JSON and 4 levels deep; at most 50 keys per user and
namespace (updating a key always works); keys match `^[a-z][a-z0-9_]{0,63}$`. Per minute: 60
requests per user and app with user tokens, 600 per app with the app token and 120 lookups per
app (429 `rate_limited` with `Retry-After`).
**Not a secrets store.** Values are stored as plain JSON and shown to Anikeen's support and to the
user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys
that look like credentials (`secret`, `token`, `password`, `api_key`, …) are rejected with 422
`forbidden_key`.
### The user's shared preferences (user token)
```php
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set
$user->globalPreferences()->values(); // ['appearance' => 'dark']
$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$preference->version; // e.g. 1042
$user->deleteGlobalPreference(SharedPreference::APPEARANCE);
```
User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the
token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes.
### Preferences of any user (trusted apps)
With the app's own token, by the user's Anikeen ID:
```php
$anikeenId = app(AnikeenId::class);
$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination
$preferences->get('onboarding', []); // the value or a default; also has(), find(), values()
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set
$preference->value; // JSON objects as stdClass, so {} stays {}
$preference->version;
$preference->updated_by; // {type: "user"|"app", client_id}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]);
$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding');
```
A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list).
`setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write
created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`.
### Conditional writes and retries
Writes are *last write wins* by default. Every write gives the preference a new `version`.
Versions are opaque: they only go up (also when a key is deleted and set again) and increase per
user, not by one per key. Pass the version you read to write only if nobody changed it in between
(`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`):
```php
use Anikeen\Id\Exceptions\PreconditionFailedException;
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout');
try {
$anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version);
} catch (PreconditionFailedException $e) {
$e->error; // "preference_version_mismatch" (or "preference_exists" for createOnly)
$e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry
}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true);
```
`deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take
`ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version),
even when the precondition would fail, so retrying a write after a timeout is safe.
### Unique keys and the lookup
Anikeen can declare keys of your namespace unique, e.g. `customer_id`. Their values are strings of
1 to 255 characters without surrounding whitespace, held by at most one user: setting another
user's value fails with 409 `preference_value_taken`. Your app can then find the user by value, e.g.
in a payment provider's webhook:
```php
$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it
```
The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for
404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422
`key_not_unique` for a key that isn't declared unique.
### Error codes
Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes:
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` (no or an invalid token; an app token on the user routes) |
| 403 | `missing_scope`, `app_token_required` (a user token on the app routes), `client_not_trusted`, `namespace_read_only`, `lookup_not_allowed` |
| 404 | `namespace_not_found` (unknown or not accessible), `user_not_found` (also deleted users), `preference_not_found`, `not_found` |
| 405 | `method_not_allowed` |
| 409 | `preference_value_taken` |
| 412 | `preference_version_mismatch`, `preference_exists` |
| 422 | `invalid_key`, `forbidden_key`, `unknown_shared_key`, `value_required`, `value_too_large`, `value_too_deep`, `invalid_shared_value`, `invalid_unique_value`, `key_not_unique`, `preference_limit_exceeded` |
| 429 | `rate_limited` |
A `null` value is rejected with 422 `value_required`: delete the key instead.
### Caching
Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list
on the model instance (one request per instance); `setGlobalPreference()` and
`deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill
it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire
after about an hour, since other apps and the user can change shared preferences too:
```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance);
Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour());
```
## API authentication
Use this when your own API accepts access tokens issued by Anikeen ID.
@@ -1084,6 +1247,16 @@ AnikeenId::withToken('abcdef123456')->getAuthedUser();
public function retrievingToken(string $grantType, array $attributes): Result
```
### ManagesPreferences
```php
public function preferencesByUserId(string $userId, string $namespace): Preferences
public function preferenceByUserId(string $userId, string $namespace, string $key): Preference
public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference
public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void
public function findUserIdByPreference(string $namespace, string $key, string $value): ?string
```
### ManagesPricing
```php
@@ -1241,6 +1414,15 @@ public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesUserPreferences
```php
public function globalPreferences(): Preferences
public function globalPreference(string $key, mixed $default = null): mixed
public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference
public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void
```
### ManagesUserSshKeys
```php