Compare commits

..
26 Commits
Author SHA1 Message Date
envoyr 31de31aa47 Merge pull request #26 from anikeen-com/release/3.4.0
Release 3.4.0
2026-11-07 11:55:58 +01:00
envoyr 0205696719 Release 3.4.0 2026-11-07 10:55:18 +00:00
envoyr 9c9603591a Merge pull request #25 from anikeen-com/feature/user-and-preference-events
Add user profiles, user events and preference.updated
2026-11-07 01:57:46 +01:00
envoyr 57efac2bf5 Find crop codes under crop and document unreadable avatar files 2026-11-07 00:36:52 +00:00
envoyr 0ccf46d69a Add user profiles and user events to the changelog 2026-11-07 00:34:40 +00:00
envoyr 5ecc535e07 Document profile updates, reading users by ID and the user events 2026-11-07 00:34:40 +00:00
envoyr 93f6d79a0a Let users change their profile and avatar with their token 2026-11-07 00:33:41 +00:00
envoyr 2eceb34faa Read a connected user's profile by ID for trusted apps 2026-11-07 00:33:39 +00:00
envoyr d458d5d7fc Throw ValidationFailedException with per-field codes for 422 responses 2026-11-07 00:33:38 +00:00
envoyr a117a6a3c9 Add the preference.updated, user.updated and user.deleted event types 2026-11-07 00:31:35 +00:00
envoyr 3d7ce65f61 Merge pull request #24 from anikeen-com/feature/app-charges
Add charges and the billing limit for trusted apps
2026-11-06 21:25:38 +00:00
envoyr b35c066b48 Merge pull request #23 from anikeen-com/feature/preferences
Add the preferences API
2026-11-06 21:25:11 +00:00
envoyr be91df6d18 Restore CRLF line endings in ManagesBalance 2026-11-06 20:03:12 +00:00
envoyr 3a78359f59 Add charges and the billing limit to the changelog 2026-11-06 20:01:15 +00:00
envoyr 32103f6921 Document charges and the billing limit 2026-11-06 20:01:15 +00:00
envoyr 7bd7249730 Add charges and the billing limit for trusted apps 2026-11-06 20:00:26 +00:00
envoyr f5d48b7e35 Add the billing limit event types 2026-11-06 20:00:26 +00:00
envoyr 5a46add518 Drop event ordering claim for full refunds 2026-11-06 19:31:19 +00:00
envoyr f5d63df9d2 Add preferences to the changelog 2026-11-06 19:29:21 +00:00
envoyr eebfe557b1 Document preferences, their scopes and order.refunded 2026-11-06 19:29:20 +00:00
envoyr 802f52ce93 Add the preferences API for apps and users 2026-11-06 19:28:23 +00:00
envoyr 90dac3a5a6 Add the order.refunded event type 2026-11-06 19:26:15 +00:00
envoyr 1b64d2822f Add the shared preference keys and appearance values 2026-11-06 19:26:15 +00:00
envoyr 11ff83e40d Add the preference scopes 2026-11-06 19:26:15 +00:00
envoyr d76095cc94 Send extra request headers and throw PreconditionFailedException on 412 2026-11-06 19:25:51 +00:00
envoyr 9c505842cb Merge pull request #22 from anikeen-com/release/3.3.0
Release 3.3.0
2026-11-06 18:08:00 +00:00
36 changed files with 3486 additions and 33 deletions

No files matched your search

+43
View File
@@ -1,5 +1,48 @@
# Changelog
## 3.4.0 (2026-11-07)
Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
need an Anikeen ID release that supports them.
### Added
- Preferences of any user with the app's token (trusted apps, registered by Anikeen per
namespace): `preferencesByUserId()`, `preferenceByUserId()`, `setPreferenceByUserId()`,
`deletePreferenceByUserId()` and `findUserIdByPreference()` (the user holding a value of a unique
key, `null` when nobody does).
- The user's shared preferences with their token: `globalPreferences()`, `globalPreference()`,
`setGlobalPreference()` and `deleteGlobalPreference()` on `Billable`, cached per instance.
- `Resources\Preference` and `Resources\Preferences`; `Enums\SharedPreference` and
`Enums\Appearance`.
- Conditional writes: `ifVersion` (`If-Match`) and `createOnly` (`If-None-Match: *`);
`PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`.
- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`.
- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`).
- Charges on a user's monthly bill for trusted apps: `createChargeForUser()` (with an idempotency
key), `findChargeForUser()` and `voidChargeForUser()`; `Resources\AppCharge`.
- The user's billing limit for trusted apps: `billingLimitForUser()` and `Resources\BillingLimit`
(`allows()` is advisory); the `billing_limit` key of `billingOverview()` is documented.
- `ApiException::isBillingLimitReached()` for 409 `billing_limit_reached`.
- Opt-in webhook events `billing_limit.reached` and `billing_limit.available`
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
`Billable`. They return the updated profile and clear the cached `getUserData()`.
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
unknown, deleted or unconnected user).
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
`user.updated` event.
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
working.
- `AnikeenId::requestMultipart()` for file uploads.
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
## 3.3.0 (2026-11-06)
App billing features; they need an Anikeen ID release that supports them.
+481 -7
View File
@@ -16,11 +16,13 @@ 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. [User profiles](#user-profiles)
7. [Preferences](#preferences)
8. [API authentication](#api-authentication)
9. [General](#general)
10. [Examples](#examples)
11. [Documentation](#documentation)
12. [Development](#development)
## Installation
@@ -181,6 +183,16 @@ 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`.
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
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:
@@ -205,11 +217,14 @@ public function boot(): void
| 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) |
| Single items on the user's monthly bill (e.g. a shipped item) | [Charges](#charges-and-billing-limit-trusted-apps) | 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).
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). For a per-item charge on
the user's monthly bill use `createChargeForUser()`, not `createOrderForUser()`: an order is a
separate one-off purchase, charged on its own.
The flow, as in a typical shop:
@@ -549,6 +564,81 @@ 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).
`billing_limit.reached` and `billing_limit.available` (`EventType::BILLING_LIMIT_REACHED`,
`BILLING_LIMIT_AVAILABLE`) tell trusted apps that a user's billing limit was reached or has room
again; they are opt-in like `subscription.renewed`, see
[Charges and billing limit](#charges-and-billing-limit-trusted-apps).
`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, so `order.cancelled` is sent too; don't rely on the
order of the two events.
#### User and preference events
These events are about the user rather than your app's orders. They are **opt-in**: select them
explicitly for your endpoint, `*` doesn't include them.
| Type | Constant | Sent to | `data` |
|------|----------|---------|--------|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
```json
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
"email": "erika@example.com", "email_verified": true,
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
"changed": ["name", "avatar"]}}
```
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
`email_verified` and the profile picture, from any source (the user's settings, the
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
Events can arrive late or out of order: keep the highest version you have seen and ignore older
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
what you keep only for it), and stop acting for the user. No other event about the user follows
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
the account: treat it as a returning user.
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
account is deleted (`user.deleted` covers that).
```php
use Anikeen\Id\Resources\User;
$object = $event['data']['object'];
if ($event['type'] === EventType::USER_UPDATED) {
$profile = new User($object);
$account = Account::firstWhere('anikeen_id', $profile->id());
if ($account && $profile->version() > $account->profile_version) {
$account->update([
'name' => $profile->name(),
'email' => $profile->email() ?? $account->email, // null while unverified
'profile_version' => $profile->version(),
]);
}
}
if ($event['type'] === EventType::USER_DELETED) {
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
}
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
Cache::forget("preferences:{$object['user_id']}");
}
```
### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -559,7 +649,16 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
later),
- `isBillingLimitReached()` (409 `billing_limit_reached`: a [charge](#charges-and-billing-limit-trusted-apps)
would take the user's monthly bill above their billing limit),
- `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`).
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
has the messages per field.
```php
use Anikeen\Id\Exceptions\ApiException;
@@ -837,6 +936,127 @@ link's user. The session ends after an hour without activity or on "Back to *app
payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available.
### Charges and billing limit (trusted apps)
Trusted apps can book single items onto a user's **open monthly bill** of the app, e.g. each item
a shipping app sends: one line per charge, no subscription, charged with the bill on the 1st like
every other line (invoice and VAT by Anikeen ID). Anikeen has to enable charging for your app and
sets a maximum per charge; the user must be connected to your app (they authorised it, or your app
bills them through subscriptions or orders).
```php
$anikeen = app(AnikeenId::class);
$charge = $anikeen->createChargeForUser($user->anikeen_id, [
'idempotency_key' => "shipment-{$shipment->id}", // per app and user: a retry returns the same charge
'name' => 'Signed card', // the line on the bill
'type' => 'physical', // digital (default), physical, physical-reduced
'quantity' => 2,
'unit_price' => '4.00', // net per piece, up to 6 decimals
'reference' => "order-{$shipment->order_id}", // your own id; not on the bill
// 'group' => $team->id, // only a group your app already bills the user in
// 'occurred_at' => '2026-11-05T14:00:00Z', // the service date, at most 31 days ago
]);
$charge->id(); // keep it to void the charge later
$charge->grossAmount(); // "9.52": what it added to the bill
$charge->orderId(); // the monthly bill it is on
$charge->status(); // open, billed (the bill was closed) or voided
$anikeen->findChargeForUser($user->anikeen_id, $chargeId);
$anikeen->voidChargeForUser($user->anikeen_id, $chargeId, 'Parcel lost'); // while the bill is open
```
- **Idempotency:** retrying with the same key and the same fields returns the existing charge
(200 instead of 201), also after the limit was reached; the same key with other fields is 409
`idempotency_key_conflict` (`charge_id` in `body()`). Use one key per item you charge for.
- **Voiding** removes the line while the bill is open (an already voided charge is returned as
it is). Once the bill was closed: 409 `charge_already_billed`; only a refund of the order by
Anikeen credits it.
- **`group`** is null or a group your app already bills the user in (a running subscription or an
open monthly bill of your app); others are refused with 409 `group_not_allowed`.
- `reference` and `metadata` (up to 20 string values) are your data about the user, kept with the
charge and not shown on the bill: put nothing sensitive there.
**Billing limit.** A user can set one monthly limit (gross) for their whole open monthly bill
across all Anikeen services. A charge that would take the bill above it is refused with 409
`billing_limit_reached`; exactly reaching the limit is allowed, `0.00` refuses every charge.
Subscription renewals and usage are still billed, so the bill can end above the limit. Read the
limit to stop offering paid actions early (advisory only, it reserves nothing):
```php
$limit = $anikeen->billingLimitForUser($user->anikeen_id);
$limit->hasLimit(); // false: no limit, every charge fits
$limit->isReached(); // used >= limit: start no new paid service
$limit->remaining(); // "17.60" gross room left, null without a limit
$limit->remainingNet(); // "14.78" at the user's VAT rate, a hint
$limit->allows('9.52'); // would this charge fit right now (advisory)
$limit->resetsAt(); // when this month's bills are closed
$limit->version(); // only goes up
$limit->used(); // the user's open bills across all Anikeen services, null without a limit
$limit->usedByApp(); // your app's share; used − usedByApp = "your other Anikeen services"
```
`used` (the user's open bills across all Anikeen services) is visible to apps **only while the
user has set a limit**; without one `limit`, `used`, `remaining` and `remaining_net` are null.
`usedByApp()` (your app's own open bills) is always there. The user sees their own limit in
`$user->billingOverview()->billing_limit` (with `used`, without `used_by_app`; null while they
can't set one). Apps that may charge can read the limit; others need Anikeen to allow it (403
`billing_limit_not_enabled`).
Handling a refused charge:
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$charge = $anikeen->createChargeForUser($user->anikeen_id, $attributes);
} catch (ApiException $e) {
if ($e->isBillingLimitReached()) {
$e->body()->remaining; // room left before this charge, e.g. "2.60"
$e->body()->required; // the refused charge, gross
$e->body()->resets_at; // when the limit has room again at the latest
return $shipment->holdForBillingLimit();
}
match ($e->error) {
'charge_exceeds_maximum' => report($e), // above your app's maximum ($e->body()->max_charge_net)
'group_not_allowed' => report($e),
'billing_address_missing' => $shipment->askForBillingAddress(),
default => throw $e,
};
}
```
**Webhooks.** `billing_limit.reached` and `billing_limit.available` are sent when the limit is
reached or has room again (a raised or removed limit, a void, the monthly reset). They are
**opt-in** (select them for your endpoint; `*` doesn't include them) and go only to apps that may
read the limit of a user connected to them. `data.object` is the billing limit as your app reads
it, `data.reason` says why (`booking`, `limit_changed`, `limit_removed`, `voided`,
`period_reset`, `order_changed`). Keep the highest `version` per user and ignore events with a
lower or equal one, so a late delivery can't undo a newer state. In the `match` of the
[webhook route](#webhooks):
```php
EventType::BILLING_LIMIT_REACHED, EventType::BILLING_LIMIT_AVAILABLE => Customer::syncBillingLimit(
$object['user_id'], reached: $event['type'] === EventType::BILLING_LIMIT_REACHED, version: $object['version'],
),
```
The limit is checked live when a charge is booked, so a late or missed webhook never lets a
charge through; poll `billingLimitForUser()` if you need the current state.
#### Error codes
| Status | `error` |
|--------|---------|
| 403 | `client_not_allowed` (not a trusted app with its own token), `charges_not_enabled` (charging is not enabled for your app; also for reads and voids), `billing_limit_not_enabled` (limit read only) |
| 404 | `user_not_found` (unknown, deleted or not connected to your app), `charge_not_found` |
| 409 | `billing_limit_reached` (`isBillingLimitReached()`), `charge_exceeds_maximum` (+ `max_charge_net`), `group_not_allowed`, `idempotency_key_conflict` (+ `charge_id`), `billing_address_missing`, `charge_already_billed` (+ `charge_id`) |
| 422 | validation (`errors`), no `error` code |
| 429 | `too_many_requests` (`isTemporary()`) |
### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
@@ -918,6 +1138,233 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address.
## User profiles
### Changing the profile (user token)
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
username, email address and profile picture. Each call returns the updated profile
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
```php
use Anikeen\Id\Exceptions\ValidationFailedException;
try {
$profile = $user->updateProfile([ // only the fields sent change
'name' => 'Erika Mustermann',
'username' => 'erika',
'email' => 'erika.new@example.com',
]);
} catch (ValidationFailedException $e) {
return back()->withErrors(match ($e->errorCodeFor('username')) {
'username_taken' => ['username' => 'This username is taken.'],
default => $e->errors(),
});
}
$profile->email(); // still the current address
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
$profile->pendingEmailExpiresAt();
$user->uploadAvatar($request->file('avatar')); // a path or a file
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
$user->removeAvatar(); // back to the initials
```
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
address and a notice to the current one; the new address becomes the user's address only once
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
that is taken count too.
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` |
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
| 429 | `too_many_requests` |
Branch on the codes, the messages are for people and may change.
### Reading a user by ID (trusted apps)
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
missed `user.updated`:
```php
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
$profile?->name();
$profile?->email(); // returned even while unverified
$profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
```
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
## 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 (trusted
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
sooner):
```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 +1531,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
@@ -1116,6 +1573,10 @@ public function appCheckoutSessions(): AppCheckoutSessions
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
public function createChargeForUser(string $userId, array $attributes): AppCharge
public function findChargeForUser(string $userId, string $chargeId): AppCharge
public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge
public function billingLimitForUser(string $userId): BillingLimit
```
### ManagesSocialiteUsers
@@ -1136,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys
public function getAuthedUser(): Result
public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?Result
public function findUserById(string $userId): ?User
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result
@@ -1208,6 +1670,9 @@ public function getBillable(): Billable
```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
public function updateProfile(array $attributes): User
public function uploadAvatar(SplFileInfo|string $file, ?array $crop = null): User
public function removeAvatar(): User
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
@@ -1241,6 +1706,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
+454 -7
View File
@@ -16,11 +16,13 @@ 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. [User profiles](#user-profiles)
7. [Preferences](#preferences)
8. [API authentication](#api-authentication)
9. [General](#general)
10. [Examples](#examples)
11. [Documentation](#documentation)
12. [Development](#development)
## Installation
@@ -181,6 +183,16 @@ 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`.
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
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:
@@ -205,11 +217,14 @@ public function boot(): void
| 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) |
| Single items on the user's monthly bill (e.g. a shipped item) | [Charges](#charges-and-billing-limit-trusted-apps) | 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).
Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). For a per-item charge on
the user's monthly bill use `createChargeForUser()`, not `createOrderForUser()`: an order is a
separate one-off purchase, charged on its own.
The flow, as in a typical shop:
@@ -549,6 +564,81 @@ 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).
`billing_limit.reached` and `billing_limit.available` (`EventType::BILLING_LIMIT_REACHED`,
`BILLING_LIMIT_AVAILABLE`) tell trusted apps that a user's billing limit was reached or has room
again; they are opt-in like `subscription.renewed`, see
[Charges and billing limit](#charges-and-billing-limit-trusted-apps).
`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, so `order.cancelled` is sent too; don't rely on the
order of the two events.
#### User and preference events
These events are about the user rather than your app's orders. They are **opt-in**: select them
explicitly for your endpoint, `*` doesn't include them.
| Type | Constant | Sent to | `data` |
|------|----------|---------|--------|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
```json
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
"email": "erika@example.com", "email_verified": true,
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
"changed": ["name", "avatar"]}}
```
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
`email_verified` and the profile picture, from any source (the user's settings, the
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
Events can arrive late or out of order: keep the highest version you have seen and ignore older
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
what you keep only for it), and stop acting for the user. No other event about the user follows
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
the account: treat it as a returning user.
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
account is deleted (`user.deleted` covers that).
```php
use Anikeen\Id\Resources\User;
$object = $event['data']['object'];
if ($event['type'] === EventType::USER_UPDATED) {
$profile = new User($object);
$account = Account::firstWhere('anikeen_id', $profile->id());
if ($account && $profile->version() > $account->profile_version) {
$account->update([
'name' => $profile->name(),
'email' => $profile->email() ?? $account->email, // null while unverified
'profile_version' => $profile->version(),
]);
}
}
if ($event['type'] === EventType::USER_DELETED) {
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
}
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
Cache::forget("preferences:{$object['user_id']}");
}
```
### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -559,7 +649,16 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
later).
later),
- `isBillingLimitReached()` (409 `billing_limit_reached`: a [charge](#charges-and-billing-limit-trusted-apps)
would take the user's monthly bill above their billing limit),
- `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`).
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
has the messages per field.
```php
use Anikeen\Id\Exceptions\ApiException;
@@ -837,6 +936,127 @@ link's user. The session ends after an hour without activity or on "Back to *app
payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available.
### Charges and billing limit (trusted apps)
Trusted apps can book single items onto a user's **open monthly bill** of the app, e.g. each item
a shipping app sends: one line per charge, no subscription, charged with the bill on the 1st like
every other line (invoice and VAT by Anikeen ID). Anikeen has to enable charging for your app and
sets a maximum per charge; the user must be connected to your app (they authorised it, or your app
bills them through subscriptions or orders).
```php
$anikeen = app(AnikeenId::class);
$charge = $anikeen->createChargeForUser($user->anikeen_id, [
'idempotency_key' => "shipment-{$shipment->id}", // per app and user: a retry returns the same charge
'name' => 'Signed card', // the line on the bill
'type' => 'physical', // digital (default), physical, physical-reduced
'quantity' => 2,
'unit_price' => '4.00', // net per piece, up to 6 decimals
'reference' => "order-{$shipment->order_id}", // your own id; not on the bill
// 'group' => $team->id, // only a group your app already bills the user in
// 'occurred_at' => '2026-11-05T14:00:00Z', // the service date, at most 31 days ago
]);
$charge->id(); // keep it to void the charge later
$charge->grossAmount(); // "9.52": what it added to the bill
$charge->orderId(); // the monthly bill it is on
$charge->status(); // open, billed (the bill was closed) or voided
$anikeen->findChargeForUser($user->anikeen_id, $chargeId);
$anikeen->voidChargeForUser($user->anikeen_id, $chargeId, 'Parcel lost'); // while the bill is open
```
- **Idempotency:** retrying with the same key and the same fields returns the existing charge
(200 instead of 201), also after the limit was reached; the same key with other fields is 409
`idempotency_key_conflict` (`charge_id` in `body()`). Use one key per item you charge for.
- **Voiding** removes the line while the bill is open (an already voided charge is returned as
it is). Once the bill was closed: 409 `charge_already_billed`; only a refund of the order by
Anikeen credits it.
- **`group`** is null or a group your app already bills the user in (a running subscription or an
open monthly bill of your app); others are refused with 409 `group_not_allowed`.
- `reference` and `metadata` (up to 20 string values) are your data about the user, kept with the
charge and not shown on the bill: put nothing sensitive there.
**Billing limit.** A user can set one monthly limit (gross) for their whole open monthly bill
across all Anikeen services. A charge that would take the bill above it is refused with 409
`billing_limit_reached`; exactly reaching the limit is allowed, `0.00` refuses every charge.
Subscription renewals and usage are still billed, so the bill can end above the limit. Read the
limit to stop offering paid actions early (advisory only, it reserves nothing):
```php
$limit = $anikeen->billingLimitForUser($user->anikeen_id);
$limit->hasLimit(); // false: no limit, every charge fits
$limit->isReached(); // used >= limit: start no new paid service
$limit->remaining(); // "17.60" gross room left, null without a limit
$limit->remainingNet(); // "14.78" at the user's VAT rate, a hint
$limit->allows('9.52'); // would this charge fit right now (advisory)
$limit->resetsAt(); // when this month's bills are closed
$limit->version(); // only goes up
$limit->used(); // the user's open bills across all Anikeen services, null without a limit
$limit->usedByApp(); // your app's share; used − usedByApp = "your other Anikeen services"
```
`used` (the user's open bills across all Anikeen services) is visible to apps **only while the
user has set a limit**; without one `limit`, `used`, `remaining` and `remaining_net` are null.
`usedByApp()` (your app's own open bills) is always there. The user sees their own limit in
`$user->billingOverview()->billing_limit` (with `used`, without `used_by_app`; null while they
can't set one). Apps that may charge can read the limit; others need Anikeen to allow it (403
`billing_limit_not_enabled`).
Handling a refused charge:
```php
use Anikeen\Id\Exceptions\ApiException;
try {
$charge = $anikeen->createChargeForUser($user->anikeen_id, $attributes);
} catch (ApiException $e) {
if ($e->isBillingLimitReached()) {
$e->body()->remaining; // room left before this charge, e.g. "2.60"
$e->body()->required; // the refused charge, gross
$e->body()->resets_at; // when the limit has room again at the latest
return $shipment->holdForBillingLimit();
}
match ($e->error) {
'charge_exceeds_maximum' => report($e), // above your app's maximum ($e->body()->max_charge_net)
'group_not_allowed' => report($e),
'billing_address_missing' => $shipment->askForBillingAddress(),
default => throw $e,
};
}
```
**Webhooks.** `billing_limit.reached` and `billing_limit.available` are sent when the limit is
reached or has room again (a raised or removed limit, a void, the monthly reset). They are
**opt-in** (select them for your endpoint; `*` doesn't include them) and go only to apps that may
read the limit of a user connected to them. `data.object` is the billing limit as your app reads
it, `data.reason` says why (`booking`, `limit_changed`, `limit_removed`, `voided`,
`period_reset`, `order_changed`). Keep the highest `version` per user and ignore events with a
lower or equal one, so a late delivery can't undo a newer state. In the `match` of the
[webhook route](#webhooks):
```php
EventType::BILLING_LIMIT_REACHED, EventType::BILLING_LIMIT_AVAILABLE => Customer::syncBillingLimit(
$object['user_id'], reached: $event['type'] === EventType::BILLING_LIMIT_REACHED, version: $object['version'],
),
```
The limit is checked live when a charge is booked, so a late or missed webhook never lets a
charge through; poll `billingLimitForUser()` if you need the current state.
#### Error codes
| Status | `error` |
|--------|---------|
| 403 | `client_not_allowed` (not a trusted app with its own token), `charges_not_enabled` (charging is not enabled for your app; also for reads and voids), `billing_limit_not_enabled` (limit read only) |
| 404 | `user_not_found` (unknown, deleted or not connected to your app), `charge_not_found` |
| 409 | `billing_limit_reached` (`isBillingLimitReached()`), `charge_exceeds_maximum` (+ `max_charge_net`), `group_not_allowed`, `idempotency_key_conflict` (+ `charge_id`), `billing_address_missing`, `charge_already_billed` (+ `charge_id`) |
| 422 | validation (`errors`), no `error` code |
| 429 | `too_many_requests` (`isTemporary()`) |
### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
@@ -918,6 +1138,233 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address.
## User profiles
### Changing the profile (user token)
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
username, email address and profile picture. Each call returns the updated profile
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
```php
use Anikeen\Id\Exceptions\ValidationFailedException;
try {
$profile = $user->updateProfile([ // only the fields sent change
'name' => 'Erika Mustermann',
'username' => 'erika',
'email' => 'erika.new@example.com',
]);
} catch (ValidationFailedException $e) {
return back()->withErrors(match ($e->errorCodeFor('username')) {
'username_taken' => ['username' => 'This username is taken.'],
default => $e->errors(),
});
}
$profile->email(); // still the current address
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
$profile->pendingEmailExpiresAt();
$user->uploadAvatar($request->file('avatar')); // a path or a file
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
$user->removeAvatar(); // back to the initials
```
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
address and a notice to the current one; the new address becomes the user's address only once
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
that is taken count too.
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` |
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
| 429 | `too_many_requests` |
Branch on the codes, the messages are for people and may change.
### Reading a user by ID (trusted apps)
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
missed `user.updated`:
```php
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
$profile?->name();
$profile?->email(); // returned even while unverified
$profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
```
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
## 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 (trusted
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
sooner):
```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.
+40 -10
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPreferences;
use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels;
@@ -23,6 +24,7 @@ use Illuminate\Contracts\Auth\Authenticatable;
class AnikeenId
{
use OauthTrait;
use ManagesPreferences;
use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
@@ -369,10 +371,12 @@ class AnikeenId
/**
* Build query & execute.
*
* @param array<string, string> $headers Extra request headers, e.g. `If-Match` for conditional
* writes; they are sent in addition to (and may not replace) Client-ID, Accept and Authorization.
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false, array $headers = []): Result
{
if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor();
@@ -380,7 +384,7 @@ class AnikeenId
try {
$response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers,
'query' => Query::build($parameters),
'json' => $payload ?: null,
]);
@@ -393,6 +397,32 @@ class AnikeenId
return $result;
}
/**
* Send a multipart/form-data request (file uploads), with the same headers and error handling
* as request(), which stays JSON only.
*
* @param list<array{name: string, contents: mixed, filename?: string, headers?: array<string, string>}> $multipart
* Guzzle's multipart parts
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function requestMultipart(string $method, string $path, array $multipart): Result
{
try {
$response = $this->client->request($method, $path, [
// no Content-Type: Guzzle sets it with the boundary
'headers' => $this->buildHeaders(),
'multipart' => $multipart,
]);
$result = new Result($response, null, $this);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception, $this);
}
return $result;
}
/**
* Build headers for request.
*
@@ -419,35 +449,35 @@ class AnikeenId
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result
public function get(string $path, array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('GET', $path, null, $parameters, $paginator);
return $this->request('GET', $path, null, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('POST', $path, $payload, $parameters, $paginator);
return $this->request('POST', $path, $payload, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('PUT', $path, $payload, $parameters, $paginator);
return $this->request('PUT', $path, $payload, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('DELETE', $path, $payload, $parameters, $paginator);
return $this->request('DELETE', $path, $payload, $parameters, $paginator, false, $headers);
}
}
+4
View File
@@ -9,6 +9,7 @@ use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesUserPreferences;
use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions;
@@ -27,6 +28,7 @@ trait Billable
use ManagesSubscriptions;
use ManagesTaxation;
use ManagesTransactions;
use ManagesUserPreferences;
use ManagesUserSshKeys;
/*
@@ -40,6 +42,8 @@ trait Billable
protected mixed $invoicesCache = null;
protected mixed $ordersCache = null;
protected mixed $paymentMethodsCache = null;
/** @var array<string, \Anikeen\Id\Resources\Preferences> by namespace */
protected array $preferencesCache = [];
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
+7 -1
View File
@@ -31,7 +31,13 @@ trait ManagesBalance
/**
* Billing overview of the current user: current_balance, current_charges, vat_rate,
* has_billing_address, has_default_payment_method, open_orders.
* has_billing_address, has_default_payment_method, open_orders and billing_limit.
*
* billing_limit is the user's monthly billing limit {object: "billing_limit", user_id,
* currency, basis, limit, used, remaining, remaining_net, vat_rate, reached, resets_at,
* version} (`limit`, `remaining` and `remaining_net` null without a limit; wrap it in
* `new \Anikeen\Id\Resources\BillingLimit(…)` for its helpers), or null while the user has no
* limit and can't set one yet. Older Anikeen ID releases don't send the key.
*
* @throws Throwable
*/
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\AppRequest;
use Anikeen\Id\Support\PreferenceRequest;
/**
* Preferences of any user, with the app's own token (client credentials, scope
* `preferences:client`). Anikeen has to register the app for each namespace it uses: its own
* namespaces (read, write, lookup), read access to another app's namespace, or access to `global`.
* Without it every call fails with 403 "client_not_trusted" or 404 "namespace_not_found".
*/
trait ManagesPreferences
{
/**
* All preferences of a user in a namespace, sorted by key.
*
* @throws ApiException 404 "namespace_not_found", "user_not_found" (also deleted users), …
*/
public function preferencesByUserId(string $userId, string $namespace): Preferences
{
return Preferences::builder(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace)));
}
/**
* One preference of a user.
*
* @throws ApiException 404 "preference_not_found" when the key isn't set, "user_not_found", …
*/
public function preferenceByUserId(string $userId, string $namespace, string $key): Preference
{
return new Preference(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace, $key)));
}
/**
* Set a preference of a user. The value is any JSON value (pass `(object) []` for `{}`), at
* most 2048 bytes and 4 levels deep. Writing the value already stored changes nothing and
* succeeds, also when a precondition would fail, so a retried write is safe.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @param bool $createOnly only if the key isn't set yet (`If-None-Match: *`); not with $ifVersion
* @throws PreconditionFailedException 412 "preference_version_mismatch" / "preference_exists",
* with currentVersion()
* @throws ApiException 403 "namespace_read_only", 409 "preference_value_taken" (a unique key),
* 422 "invalid_key", "forbidden_key", "value_too_large", "preference_limit_exceeded", …
*/
public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference
{
$headers = PreferenceRequest::conditions($ifVersion, $createOnly);
return new Preference(fn () => AppRequest::send('PUT', $this->userPreferencePath($userId, $namespace, $key), ['value' => $value], [], $headers));
}
/**
* Delete a preference of a user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void
{
AppRequest::send('DELETE', $this->userPreferencePath($userId, $namespace, $key), null, [], PreferenceRequest::conditions($ifVersion));
}
/**
* The ID of the user holding a value of a unique key in one of the app's own namespaces, e.g.
* the user of a payment provider's customer ID. The value is sent in the body, never in the URL.
*
* @return string|null null when no (not deleted) user holds the value
* @throws ApiException 422 "key_not_unique", "invalid_unique_value", 403 "lookup_not_allowed", …
*/
public function findUserIdByPreference(string $namespace, string $key, string $value): ?string
{
try {
$result = AppRequest::send('POST', PreferenceRequest::path('v1/client/preferences', $namespace, 'lookup'), [
'key' => $key,
'value' => $value,
]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "nobody holds it", not e.g. an unknown namespace
if ($e->isNotFound() && $e->error === 'preference_not_found') {
return null;
}
throw $e;
}
return $result->data->user_id;
}
private function userPreferencePath(string $userId, string $namespace, ?string $key = null): string
{
$segments = [$userId, 'preferences', $namespace];
if ($key !== null) {
$segments[] = $key;
}
return PreferenceRequest::path('v1/client/users', ...$segments);
}
}
+107
View File
@@ -2,8 +2,19 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use SplFileInfo;
use Throwable;
/**
* The user's own profile, with the user's token. Changes need the scope `user` (`user:read`
* only reads; 403 "missing_scope").
*/
trait ManagesProfile
{
use HasBillable;
@@ -23,4 +34,100 @@ trait ManagesProfile
'options' => $options,
])->data->url;
}
/**
* Change the user's profile (scope `user`). Only the fields sent change. A new email address
* doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes
* the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the
* result; user.updated follows then). Clears the cached getUserData().
*
* @param array{
* name?: string,
* username?: string,
* email?: string
* } $attributes The fields to change
* - name: The display name
* - username: The username (unique regardless of case)
* - email: A new email address (lower-cased; needs the user's confirmation)
* @return User the user as `GET v1/user` returns them, plus `pending_email` and
* `pending_email_expires_at`
* @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken",
* "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field))
* @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour)
*/
public function updateProfile(array $attributes): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes));
}
/**
* Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to
* 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears
* the cached getUserData().
*
* @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile
* @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the
* upright picture; the centre without one
* @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large",
* "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them)
* @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (also more than 10 uploads an hour)
* @throws \RuntimeException when the file can't be opened (nothing is sent then)
*/
public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User
{
$path = $file instanceof SplFileInfo ? $file->getPathname() : $file;
$filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName')
? $file->getClientOriginalName()
: basename($path);
$multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]];
foreach (['x', 'y', 'size'] as $key) {
if (isset($crop[$key])) {
$multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]];
}
}
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart));
}
/**
* Remove the uploaded profile picture (scope `user`); the initials are shown again when it
* was the one shown. Clears the cached getUserData().
*
* @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests"
*/
public function removeAvatar(): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar'));
}
/**
* @param callable(): Result $send
* @throws ApiException
*/
private function sendProfileRequest(callable $send): User
{
try {
$result = $send();
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
$this->userDataCache = null;
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
return new User($result->data);
}
}␍
+99
View File
@@ -3,8 +3,10 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\BillingLimit;
use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable;
@@ -123,4 +125,101 @@ trait ManagesUserBilling
{
return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]);
}
/**
* Book a charge onto a user's open monthly bill of the app, e.g. a shipped item: one line,
* no subscription, charged with the bill on the 1st. Trusted apps that Anikeen allowed to
* charge (with a maximum per charge), for users connected to the app (they authorised it or
* the app bills them). The user's billing limit is checked when it is booked.
*
* Retrying with the same idempotency key and the same fields returns the existing charge
* (also after the limit was reached); other fields with that key: 409
* "idempotency_key_conflict" (`charge_id` in body()).
*
* @param string $userId The user's Anikeen ID.
* @param array{
* idempotency_key: string,
* name: string,
* description?: null|string,
* type?: string,
* quantity: int,
* unit_price: float|int|string,
* currency_iso?: string,
* group?: null|string,
* occurred_at?: string,
* reference?: null|string,
* metadata?: null|array<string, string>,
* image_url?: null|string
* } $attributes idempotency_key: unique per app and user, ≤ 255 (e.g. "shipment-123").
* name: the line on the bill, ≤ 255. description: ≤ 255.
* type: digital (default), physical or physical-reduced (the VAT treatment).
* quantity: 1–10000 pieces. unit_price: net per piece, > 0, ≤ 100000, up to 6
* decimals; quantity × unit_price rounded to cents must be at least 0.01.
* currency_iso: EUR only (default).
* group: null, or a group in which the app already bills the user (a running
* subscription or an open monthly bill of the app).
* occurred_at: the service date, not in the future, at most 31 days ago
* (default now).
* reference: the app's own id (≤ 255). metadata: ≤ 20 keys (≤ 40 characters),
* string values ≤ 500. Both are not on the bill; put nothing sensitive there.
* image_url: http(s) URL of an image for the line.
*
* @throws ApiException 403 "client_not_allowed" (not a trusted app), "charges_not_enabled";
* 404 "user_not_found" (unknown, deleted or not connected); 409 "billing_limit_reached"
* (isBillingLimitReached(); body() has limit, used, remaining, remaining_net, required,
* currency, resets_at), "charge_exceeds_maximum" (+ max_charge_net),
* "group_not_allowed", "idempotency_key_conflict" (+ charge_id),
* "billing_address_missing"; 422 validation; 429 "too_many_requests"
*/
public function createChargeForUser(string $userId, array $attributes): AppCharge
{
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId), $attributes)->data);
}
/**
* A charge of the app (or of a client delegated to the same app) for a user.
*
* @throws ApiException 403 "client_not_allowed", "charges_not_enabled"; 404 "user_not_found",
* "charge_not_found"; 429 "too_many_requests"
*/
public function findChargeForUser(string $userId, string $chargeId): AppCharge
{
return new AppCharge(AppRequest::send('GET', $this->chargesPath($userId, $chargeId))->data);
}
/**
* Void a charge while its bill is open: the line is removed and the bill's total drops. An
* already voided charge is returned as it is. Once the bill is closed only a refund of the
* order by Anikeen credits it.
*
* @param string|null $reason why (≤ 255), stored with the charge
* @throws ApiException 409 "charge_already_billed" (+ charge_id); 404 "charge_not_found",
* "user_not_found"; 403 "client_not_allowed", "charges_not_enabled"; 429 "too_many_requests"
*/
public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge
{
$payload = $reason === null ? null : ['reason' => $reason];
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId, $chargeId) . '/void', $payload)->data);
}
/**
* A user's monthly billing limit as the app sees it, e.g. to pause paid features while it is
* reached. Advisory only: it reserves nothing; a charge is checked when it is booked. Trusted
* apps that may charge or that Anikeen allowed to read limits, for users connected to the app.
*
* @throws ApiException 403 "client_not_allowed", "billing_limit_not_enabled"; 404
* "user_not_found"; 429 "too_many_requests"
*/
public function billingLimitForUser(string $userId): BillingLimit
{
return new BillingLimit(AppRequest::send('GET', sprintf('v1/client/users/%s/billing-limit', rawurlencode($userId)))->data);
}
private function chargesPath(string $userId, ?string $chargeId = null): string
{
$path = sprintf('v1/client/users/%s/charges', rawurlencode($userId));
return $chargeId === null ? $path : $path . '/' . rawurlencode($chargeId);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\PreferenceRequest;
use GuzzleHttp\Exception\GuzzleException;
/**
* The user's shared preferences (`global`, e.g. SharedPreference::APPEARANCE), with the user's
* token (scope `preferences` or `preferences:read`). User tokens reach `global` only; an app's
* own namespace needs the app token (AnikeenId::preferencesByUserId()).
*/
trait ManagesUserPreferences
{
/**
* The user's shared preferences, sorted by key. Cached on this instance; set and delete clear it.
*
* @throws ApiException
*/
public function globalPreferences(): Preferences
{
$namespace = SharedPreference::NAMESPACE;
if (!isset($this->preferencesCache[$namespace])) {
$anikeenId = $this->anikeenId();
$this->preferencesCache[$namespace] = Preferences::builder(
fn () => $anikeenId->get(PreferenceRequest::path('v1/preferences', $namespace))
);
}
return $this->preferencesCache[$namespace];
}
/**
* The value of a shared preference, or $default when the user hasn't set it.
*
* @throws ApiException
*/
public function globalPreference(string $key, mixed $default = null): mixed
{
return $this->globalPreferences()->get($key, $default);
}
/**
* Set a shared preference of the user, e.g. setGlobalPreference(SharedPreference::APPEARANCE,
* Appearance::DARK). Only registered keys and values are accepted (422 "unknown_shared_key",
* "invalid_shared_value").
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException
*/
public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
$anikeenId = $this->anikeenId();
try {
return new Preference(fn () => $anikeenId->put(PreferenceRequest::path('v1/preferences', $namespace, $key), ['value' => $value], [], null, $headers));
} finally {
unset($this->preferencesCache[$namespace]);
}
}
/**
* Delete a shared preference of the user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
try {
$result = $this->anikeenId()->delete(PreferenceRequest::path('v1/preferences', $namespace, $key), [], [], null, $headers);
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
unset($this->preferencesCache[$namespace]);
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
}
}
+25
View File
@@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable;
@@ -95,6 +96,30 @@ trait ManagesUsers
}
}
/**
* The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token),
* e.g. to catch up on a missed user.updated webhook. The email address is returned even while
* it isn't verified (emailVerified() is false then). No scope needed.
*
* @return User|null null when the user is unknown, deleted or not connected to your app
* (404 "user_not_found"; the three look the same)
* @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests",
* or another failure
*/
public function findUserById(string $userId): ?User
{
try {
return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no such user", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/**
* Find the user of a social login or create it (trusted apps, app token).
*
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The values of the shared preference SharedPreference::APPEARANCE.
*/
class Appearance
{
/** Follow the operating system. */
const SYSTEM = 'system';
const LIGHT = 'light';
const DARK = 'dark';
/**
* @return list<string>
*/
public static function all(): array
{
return [self::SYSTEM, self::LIGHT, self::DARK];
}
}
+6
View File
@@ -33,6 +33,12 @@ class Scope
const TRANSACTIONS_READ = 'transactions:read';
const TRANSACTIONS_CLIENT = 'transactions:client';
// The user's own shared preferences (`global`). PREFERENCES_CLIENT labels an app token for
// the preferences of any user; access to a namespace is granted by Anikeen, not by the scope.
const PREFERENCES = 'preferences';
const PREFERENCES_READ = 'preferences:read';
const PREFERENCES_CLIENT = 'preferences:client';
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The keys of the `global` preferences namespace, shared by all apps. Anikeen ID accepts only
* these keys there, each with its own values.
*/
class SharedPreference
{
/** The namespace of the shared preferences. */
const NAMESPACE = 'global';
/** Light or dark mode: one of the Appearance constants. */
const APPEARANCE = 'appearance';
}
+22 -1
View File
@@ -28,10 +28,21 @@ class ApiException extends ResourceException
public static function fromResult(Result $result): static
{
$body = is_object($result->data) ? $result->data : null;
$status = $result->response()?->getStatusCode() ?? 0;
// a failed conditional write; subclasses (CollectionException) keep their own type
if ($status === 412 && static::class === self::class) {
return PreconditionFailedException::fromResult($result);
}
// refused input, with per-field codes on some endpoints
if ($status === 422 && static::class === self::class) {
return ValidationFailedException::fromResult($result);
}
return new static(
rtrim((string) ($body->message ?? $result->error()), '.'),
$result->response()?->getStatusCode() ?? 0,
$status,
$body->error ?? null,
$result->exception,
);
@@ -73,6 +84,16 @@ class ApiException extends ResourceException
return $this->status === 409;
}
/**
* A charge was refused because it would take the user's open monthly bill above their billing
* limit (409 "billing_limit_reached"). body() has limit, used, remaining, remaining_net,
* required, currency and resets_at.
*/
public function isBillingLimitReached(): bool
{
return $this->status === 409 && $this->error === 'billing_limit_reached';
}
/**
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/
@@ -0,0 +1,21 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* A conditional write failed (412): the stored version wasn't the one sent with `If-Match`
* ("preference_version_mismatch"), or the key was already set and the write was create-only
* ("preference_exists"). Reload, merge and retry with currentVersion().
*/
class PreconditionFailedException extends ApiException
{
/**
* The version stored now, null when the key isn't set (or Anikeen ID didn't send one).
*/
public function currentVersion(): ?int
{
$version = $this->body()->current_version ?? null;
return is_numeric($version) ? (int) $version : null;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* Anikeen ID refused the input (422). `error` is the first code; endpoints with per-field codes
* (the profile API) send them as `codes`: `{field: code}`, e.g. `["username" => "username_taken"]`.
* Branch on the codes, the messages are for people and may change.
*/
class ValidationFailedException extends ApiException
{
/**
* @return array<string, string> field => code; empty when the endpoint sends none
*/
public function errorCodes(): array
{
$codes = $this->body()->codes ?? null;
return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : [];
}
/**
* The code of $field, or of its first failed sub-field when the codes are keyed by those
* (`crop` finds `crop.x`, `crop.y` or `crop.size`).
*/
public function errorCodeFor(string $field): ?string
{
$codes = $this->errorCodes();
if (isset($codes[$field])) {
return $codes[$field];
}
foreach ($codes as $key => $code) {
if (str_starts_with((string) $key, $field . '.')) {
return $code;
}
}
return null;
}
/**
* Whether any field failed with $code, or the response's `error` is $code.
*/
public function hasErrorCode(string $code): bool
{
return $this->error === $code || in_array($code, $this->errorCodes(), true);
}
/**
* The messages per field (`errors`), for showing them to people.
*
* @return array<string, list<string>>
*/
public function errors(): array
{
$errors = $this->body()->errors ?? null;
return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : [];
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A charge a trusted app booked onto a user's open monthly bill, see
* AnikeenId::createChargeForUser(). Amounts are decimal strings (`unit_price` with six decimals,
* the others with two).
*
* @property string $object "charge"
* @property string $id
* @property string $user_id
* @property string $status open (its bill is open), billed (the bill was closed) or voided
* @property string $idempotency_key
* @property string $name the line on the bill
* @property ?string $description
* @property string $type digital, physical or physical-reduced (the VAT treatment)
* @property int $quantity
* @property string $unit "piece"
* @property string $unit_price net per piece
* @property string $net_amount
* @property string $gross_amount what the charge added to the bill, VAT included
* @property string $currency_iso
* @property ?string $group
* @property string $occurred_at the line's service date (ISO 8601)
* @property ?string $reference the app's own id
* @property object $metadata the app's own data (string values)
* @property ?string $order_id the monthly bill (order) the line is on; follows the line when a rollover moves it
* @property ?string $voided_at
* @property ?string $void_reason
* @property string $created_at
*/
class AppCharge implements JsonSerializable
{
public const STATUS_OPEN = 'open';
public const STATUS_BILLED = 'billed';
public const STATUS_VOIDED = 'voided';
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});
}
public function id(): string
{
return $this->data->id;
}
/**
* open, billed or voided (the STATUS_* constants).
*/
public function status(): string
{
return $this->data->status;
}
/**
* Its bill is still open: the charge can be voided.
*/
public function isOpen(): bool
{
return $this->status() === self::STATUS_OPEN;
}
/**
* Its bill was closed (paid, waiting for payment or cancelled): only a refund of the order by
* Anikeen credits it.
*/
public function isBilled(): bool
{
return $this->status() === self::STATUS_BILLED;
}
public function isVoided(): bool
{
return $this->status() === self::STATUS_VOIDED;
}
/**
* Net, e.g. "8.00".
*/
public function netAmount(): string
{
return $this->data->net_amount;
}
/**
* Gross, what the charge added to the bill, e.g. "9.52".
*/
public function grossAmount(): string
{
return $this->data->gross_amount;
}
/**
* The monthly bill (order) the charge is on, null once its line is gone (voided).
*/
public function orderId(): ?string
{
return $this->data->order_id ?? null;
}
/**
* @return array<string, string>
*/
public function metadata(): array
{
return (array) ($this->data->metadata ?? []);
}
public function occurredAt(): ?DateTimeImmutable
{
return isset($this->data->occurred_at) ? new DateTimeImmutable($this->data->occurred_at) : null;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's monthly billing limit: one gross limit for their whole open monthly bill across all
* Anikeen services, see AnikeenId::billingLimitForUser() and the `billing_limit` key of
* billingOverview(). Amounts are decimal strings with two decimals.
*
* Without a limit `limit`, `remaining` and `remaining_net` are null; for an app `used` is null
* too (an app sees the user's whole open bill only while they have set a limit). `used_by_app`
* (the open bills of your app) is there only when an app reads the limit.
*
* @property string $object "billing_limit"
* @property string $user_id
* @property string $currency "EUR"
* @property string $basis "gross"
* @property ?string $limit
* @property ?string $used the user's open monthly bills across all Anikeen services
* @property ?string $used_by_app the open monthly bills of your app (app reads only)
* @property ?string $remaining max(0, limit − used)
* @property ?string $remaining_net remaining at the user's VAT rate, rounded down (a hint)
* @property int|float|null $vat_rate null without a billing address
* @property bool $reached used >= limit: start no new paid service
* @property string $resets_at when this month's bills are closed (ISO 8601)
* @property int $version only goes up; keep the highest seen (also in the billing_limit.* webhooks)
*/
class BillingLimit implements JsonSerializable
{
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* The user has set a limit (`0.00` is one too: no new charges at all).
*/
public function hasLimit(): bool
{
return $this->limit() !== null;
}
/**
* Gross, null without a limit.
*/
public function limit(): ?string
{
return $this->data->limit ?? null;
}
/**
* The user's open monthly bills across all Anikeen services (gross). Null for an app while the
* user has no limit.
*/
public function used(): ?string
{
return $this->data->used ?? null;
}
/**
* The open monthly bills of your app (gross); null outside an app read (billingOverview()).
* `used − used_by_app` is what the user spends on their other Anikeen services.
*/
public function usedByApp(): ?string
{
return $this->data->used_by_app ?? null;
}
/**
* Gross room left (never negative), null without a limit.
*/
public function remaining(): ?string
{
return $this->data->remaining ?? null;
}
/**
* The room left as a net amount at the user's VAT rate, rounded down: a hint only (the VAT of
* a charge depends on its type). Null without a limit or a billing address.
*/
public function remainingNet(): ?string
{
return $this->data->remaining_net ?? null;
}
/**
* The user's VAT rate in percent (e.g. 19.0), null without a billing address.
*/
public function vatRate(): ?float
{
return isset($this->data->vat_rate) ? (float) $this->data->vat_rate : null;
}
public function currency(): string
{
return $this->data->currency ?? 'EUR';
}
/**
* The limit is reached (used >= limit): start no new paid service for the user.
*/
public function isReached(): bool
{
return (bool) ($this->data->reached ?? false);
}
/**
* When this month's bills are closed and `used` drops.
*/
public function resetsAt(): ?DateTimeImmutable
{
return isset($this->data->resets_at) ? new DateTimeImmutable($this->data->resets_at) : null;
}
/**
* Only goes up: keep the highest one seen and ignore webhooks with a lower or equal one.
*/
public function version(): int
{
return (int) ($this->data->version ?? 0);
}
/**
* Whether a charge of $gross (gross, e.g. "4.76") would fit as of this read: always without a
* limit, else at most `remaining`. Advisory only: it reserves nothing, the binding check is
* the booking (409 "billing_limit_reached").
*/
public function allows(string|int|float $gross): bool
{
if (!$this->hasLimit()) {
return true;
}
$remaining = $this->remaining();
return $remaining !== null && self::compare($gross, $remaining) <= 0;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Compares two non-negative decimal amounts exactly (as digit strings, no float rounding);
* negative amounts count as zero.
*/
private static function compare(string|int|float $a, string|int|float $b): int
{
return strcmp(self::micros($a), self::micros($b)) <=> 0;
}
/**
* The amount in millionths as a zero-padded digit string of a fixed width.
*/
private static function micros(string|int|float $amount): string
{
$amount = is_float($amount) ? number_format($amount, 6, '.', '') : trim((string) $amount);
if (str_starts_with($amount, '-')) {
return str_repeat('0', 40);
}
[$whole, $fraction] = array_pad(explode('.', ltrim($amount, '+'), 2), 2, '');
$digits = preg_replace('/\D/', '', $whole) . substr(str_pad(preg_replace('/\D/', '', $fraction), 6, '0'), 0, 6);
return str_pad(ltrim($digits, '0'), 40, '0', STR_PAD_LEFT);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
/**
* A preference of a user: a JSON value under a namespace and a key. `version` is opaque: it only
* goes up (per user, not consecutive per key) and is what ifVersion (`If-Match`) compares.
* JSON objects come back as stdClass, so `{}` stays an object.
*
* @property string $namespace
* @property string $key
* @property mixed $value
* @property int $version
* @property string $updated_at
* @property object{type: string, client_id: ?string} $updated_by `type` "user" (written with a user token) or "app"
*/
class Preference extends BaseResource
{
/**
* The write that returned this preference created the key (201). False for reads, updates and
* writes of the value already stored.
*/
public function wasCreated(): bool
{
return $this->result->response()?->getStatusCode() === 201;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Anikeen\Id\Resources;
/**
* The preferences of a user in one namespace, sorted by key (at most 50, no pagination).
*/
class Preferences extends BaseCollection
{
/**
* The preference of a key; null when it isn't set. Reads the list, no request.
*/
public function find(string $id): ?Preference
{
$data = $this->item($id);
if ($data === null) {
return null;
}
$result = clone $this->result;
$result->data = $data;
return new Preference(fn () => $result);
}
/**
* The value of a key, or $default when it isn't set.
*/
public function get(string $key, mixed $default = null): mixed
{
$data = $this->item($key);
return $data !== null ? $data->value : $default;
}
public function has(string $key): bool
{
return $this->item($key) !== null;
}
/**
* The values by key.
*
* @return array<string, mixed>
*/
public function values(): array
{
$values = [];
foreach ((array) $this->result->data as $data) {
$values[$data->key] = $data->value;
}
return $values;
}
private function item(string $key): ?object
{
foreach ((array) $this->result->data as $data) {
if (($data->key ?? null) === $key) {
return $data;
}
}
return null;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's profile: AnikeenId::findUserById(), the result of the profile updates on Billable
* (updateProfile(), uploadAvatar(), removeAvatar()) and the `data.object` of a user.updated
* webhook (`new User($event['data']['object'])`).
*
* The REST shapes carry `email_verified_at` and `profile_version`, the webhook `email_verified`
* and `version`; emailVerified() and version() read either. The profile updates also return the
* other fields of `GET v1/user` plus `pending_email` and `pending_email_expires_at`.
*
* @property string $id
* @property ?string $name
* @property ?string $username
* @property ?string $email in a webhook only once verified (null while it isn't)
* @property ?string $email_verified_at REST shapes
* @property bool $email_verified webhook
* @property ?string $avatar a stable URL; the picture behind it can change
* @property ?string $locale REST shapes
* @property int $profile_version REST shapes
* @property int $version webhook
* @property ?string $created_at REST shapes
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
* @property ?string $pending_email_expires_at profile updates only
*/
class User implements JsonSerializable
{
protected object $data;
public function __construct(object|array $data)
{
$this->data = (object) $data;
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
public function id(): string
{
return (string) ($this->data->id ?? '');
}
public function name(): ?string
{
return $this->data->name ?? null;
}
public function username(): ?string
{
return $this->data->username ?? null;
}
public function email(): ?string
{
return $this->data->email ?? null;
}
/**
* From `email_verified` (webhooks) or `email_verified_at` (REST shapes).
*/
public function emailVerified(): bool
{
if (isset($this->data->email_verified)) {
return (bool) $this->data->email_verified;
}
return !empty($this->data->email_verified_at);
}
/**
* The URL of the profile picture. It stays the same when the picture changes (user.updated
* with `avatar` in `changed`): drop a cached copy then.
*/
public function avatar(): ?string
{
return $this->data->avatar ?? null;
}
/**
* Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks):
* keep the highest one seen and ignore older data.
*/
public function version(): int
{
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
}
/**
* A new email address waiting for the user's confirmation (profile updates only); `email`
* stays the current address until then.
*/
public function pendingEmail(): ?string
{
return $this->data->pending_email ?? null;
}
public function pendingEmailExpiresAt(): ?DateTimeImmutable
{
return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+16 -7
View File
@@ -5,36 +5,45 @@ namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code.
* once; every failure ends in an ApiException with status and error code (a 412 in a
* PreconditionFailedException, a 422 in a ValidationFailedException).
*/
final class AppRequest
{
/**
* @param array<string, string> $headers Extra request headers, e.g. `If-Match`.
* @throws ApiException
*/
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result
public static function send(string $method, string $path, ?array $payload = null, array $query = [], array $headers = []): Result
{
$result = self::attempt($method, $path, $payload, $query);
$result = self::attempt($method, $path, $payload, $query, $headers);
if ($result->status === 401) {
$tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query);
$result = self::attempt($method, $path, $payload, $query, $headers);
}
}
if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null;
$exception = match ($result->status) {
412 => PreconditionFailedException::class,
422 => ValidationFailedException::class,
default => ApiException::class,
};
throw new ApiException(
throw new $exception(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
@@ -48,10 +57,10 @@ final class AppRequest
/**
* @throws ApiException
*/
private static function attempt(string $method, string $path, ?array $payload, array $query): Result
private static function attempt(string $method, string $path, ?array $payload, array $query, array $headers = []): Result
{
try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query);
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query, null, false, $headers);
} catch (ApiException $e) {
throw $e;
} catch (GuzzleException|Throwable $e) {
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Anikeen\Id\Support;
use InvalidArgumentException;
/**
* @internal paths and preconditions of the preferences API
*/
final class PreferenceRequest
{
/**
* The path with every segment URL-encoded, e.g. path('v1/preferences', 'global', 'appearance').
*/
public static function path(string $base, string ...$segments): string
{
return $base . '/' . implode('/', array_map('rawurlencode', $segments));
}
/**
* `If-Match: "<version>"` (only if the stored version is this one) or `If-None-Match: *`
* (only if the key isn't set yet).
*
* @return array<string, string>
*/
public static function conditions(?int $ifVersion, bool $createOnly = false): array
{
if ($ifVersion !== null && $createOnly) {
throw new InvalidArgumentException('A write is either conditional on a version or create-only, not both.');
}
return match (true) {
$ifVersion !== null => ['If-Match' => sprintf('"%d"', $ifVersion)],
$createOnly => ['If-None-Match' => '*'],
default => [],
};
}
private function __construct()
{
}
}
+49
View File
@@ -13,6 +13,12 @@ final class EventType
public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
public const ORDER_CANCELLED = 'order.cancelled';
public const ORDER_SHIPPED = 'order.shipped';
/**
* Money was given back for an order (a refund or a partial cancellation, with a credit note);
* see the order's `refunds` and `refunded_total`. A full refund also cancels the order,
* so order.cancelled is sent too; don't rely on the order of the two events.
*/
public const ORDER_REFUNDED = 'order.refunded';
public const SUBSCRIPTION_CREATED = 'subscription.created';
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
@@ -55,6 +61,49 @@ final class EventType
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
public const CHECKOUT_EXPIRED = 'checkout.expired';
/**
* A user's open monthly bill reached their billing limit: start no new paid service for them.
* `data.object` is the billing limit as the app reads it (with `version`), `data.reason`
* `booking` or `limit_changed`. Opt-in: only endpoints that select this event get it (not
* through `*`), and only apps that may read the limit of a user connected to them.
*/
public const BILLING_LIMIT_REACHED = 'billing_limit.reached';
/**
* A user whose billing limit was reached has room again. `data.object` is the billing limit
* (with `version`), `data.reason` `limit_changed`, `limit_removed`, `voided`, `period_reset`
* or `order_changed`. Opt-in like BILLING_LIMIT_REACHED.
*/
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
/**
* A preference of a user was set, changed or deleted. Thin, never the value: `data.object` is
* `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by:
* {type: "user"|"app", client_id}}`; fetch the preference again (or drop your copy when
* `deleted`) and ignore events with a lower version than the one you hold. Opt-in (not through
* `*`); only to trusted apps that may read the namespace, never to the app that made the change.
*/
public const PREFERENCE_UPDATED = 'preference.updated';
/**
* The profile of a user connected to your app changed: name, username, the verified email
* address, email_verified or the avatar. `data.object` is the full profile `{object: "user", id,
* name, username, email, email_verified, avatar, version}` (Resources\User), `data.changed` names
* the changed fields (never their old values). An email address appears only once verified
* (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL
* changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also
* sent to the app that made the change.
*/
public const USER_UPDATED = 'user.updated';
/**
* A user connected to your app deleted their account (or it was deleted): delete or anonymise
* it and treat it as final. `data.object` is `{object: "user", id, deleted: true, deleted_at}`.
* Sent once; no other event about the user follows. A later user.updated for the same ID means
* the account was restored. Opt-in (not through `*`).
*/
public const USER_DELETED = 'user.deleted';
public const TEST = 'webhook.test';
/**
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\BillingLimit;
use DateTimeImmutable;
class AppChargesTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function charge(array $overrides = []): array
{
return ['data' => [
'object' => 'charge', 'id' => '01JCHARGE', 'user_id' => '01JUSER', 'status' => 'open',
'idempotency_key' => 'shipment-123', 'name' => 'Signed card', 'description' => null,
'type' => 'physical', 'quantity' => 2, 'unit' => 'piece', 'unit_price' => '4.000000',
'net_amount' => '8.00', 'gross_amount' => '9.52', 'currency_iso' => 'EUR', 'group' => null,
'occurred_at' => '2026-11-06T10:00:00+00:00', 'reference' => 'order-77', 'metadata' => ['parcel' => 'p-1'],
'order_id' => '01JORDER', 'voided_at' => null, 'void_reason' => null, 'created_at' => '2026-11-06T10:00:01+00:00',
...$overrides,
]];
}
private function limit(array $overrides = []): array
{
return ['data' => [
'object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross',
'limit' => '100.00', 'used' => '82.40', 'used_by_app' => '60.00', 'remaining' => '17.60',
'remaining_net' => '14.78', 'vat_rate' => 19, 'reached' => false,
'resets_at' => '2026-12-01T02:00:00Z', 'version' => 7,
...$overrides,
]];
}
public function test_a_charge_is_booked_with_the_app_token(): void
{
$this->respond(201, $this->charge());
$attributes = [
'idempotency_key' => 'shipment-123',
'name' => 'Signed card',
'type' => 'physical',
'quantity' => 2,
'unit_price' => '4.00',
'reference' => 'order-77',
'metadata' => ['parcel' => 'p-1'],
];
$charge = app(AnikeenId::class)->createChargeForUser('01JUSER', $attributes);
$this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0));
$this->assertSame($attributes, $this->sentJson(0));
$this->assertInstanceOf(AppCharge::class, $charge);
$this->assertSame('01JCHARGE', $charge->id());
$this->assertSame('open', $charge->status());
$this->assertTrue($charge->isOpen());
$this->assertFalse($charge->isBilled());
$this->assertFalse($charge->isVoided());
$this->assertSame('8.00', $charge->netAmount());
$this->assertSame('9.52', $charge->grossAmount());
$this->assertSame('01JORDER', $charge->orderId());
$this->assertSame(['parcel' => 'p-1'], $charge->metadata());
$this->assertEquals(new DateTimeImmutable('2026-11-06T10:00:00+00:00'), $charge->occurredAt());
$this->assertSame(2, $charge->quantity);
$this->assertSame('order-77', $charge->reference);
$this->assertNull($charge->voided_at);
$this->assertSame('Signed card', $charge->jsonSerialize()['name']);
}
public function test_empty_metadata_is_an_empty_array(): void
{
$this->respond(200, $this->charge(['metadata' => new \stdClass()]));
$this->assertSame([], app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE')->metadata());
}
public function test_a_charge_is_found_with_the_app_token(): void
{
$this->respond(200, $this->charge(['status' => 'billed']));
$charge = app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE');
$this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(0));
$this->assertTrue($charge->isBilled());
}
public function test_a_charge_is_voided_with_and_without_a_reason(): void
{
$voided = ['status' => 'voided', 'order_id' => null, 'voided_at' => '2026-11-06T11:00:00+00:00', 'void_reason' => 'Parcel lost'];
$this->respond(200, $this->charge($voided));
$this->respond(200, $this->charge($voided));
$charge = app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE', 'Parcel lost');
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(0));
$this->assertSame(['reason' => 'Parcel lost'], $this->sentJson(0));
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(1));
$this->assertSame([], $this->sentJson(1));
$this->assertTrue($charge->isVoided());
$this->assertNull($charge->orderId());
$this->assertSame('Parcel lost', $charge->void_reason);
}
public function test_path_segments_are_encoded(): void
{
$this->respond(200, $this->charge());
app(AnikeenId::class)->findChargeForUser('01J/USER', '01J CHARGE');
$this->assertSame('/api/v1/client/users/01J%2FUSER/charges/01J%20CHARGE', $this->sent[0]['request']->getUri()->getPath());
}
public function test_a_charge_over_the_billing_limit_carries_the_details(): void
{
$this->respond(409, [
'message' => 'The customer\'s billing limit is reached.', 'error' => 'billing_limit_reached',
'limit' => '100.00', 'used' => '97.40', 'remaining' => '2.60', 'remaining_net' => '2.18',
'required' => '4.76', 'currency' => 'EUR', 'resets_at' => '2026-12-01T02:00:00Z',
]);
try {
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isBillingLimitReached());
$this->assertTrue($e->isConflict());
$this->assertSame(409, $e->status);
$this->assertSame('2.60', $e->body()->remaining);
$this->assertSame('4.76', $e->body()->required);
$this->assertSame('2026-12-01T02:00:00Z', $e->body()->resets_at);
}
}
public function test_other_conflicts_are_not_the_billing_limit(): void
{
$this->respond(409, ['message' => 'The charge is more than this app may charge at once.', 'error' => 'charge_exceeds_maximum', 'max_charge_net' => '50.00']);
try {
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '60.00']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertFalse($e->isBillingLimitReached());
$this->assertSame('charge_exceeds_maximum', $e->error);
$this->assertSame('50.00', $e->body()->max_charge_net);
}
}
public function test_an_already_billed_charge_cannot_be_voided(): void
{
$this->respond(409, ['message' => 'The charge is on a closed bill; only a refund of that order credits it.', 'error' => 'charge_already_billed', 'charge_id' => '01JCHARGE']);
try {
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame('charge_already_billed', $e->error);
$this->assertSame('01JCHARGE', $e->body()->charge_id);
}
}
public function test_an_unknown_charge_is_not_found(): void
{
$this->respond(404, ['message' => 'Charge not found.', 'error' => 'charge_not_found']);
try {
app(AnikeenId::class)->findChargeForUser('01JUSER', '01JOTHER');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('charge_not_found', $e->error);
}
}
public function test_the_billing_limit_is_read_with_the_app_token(): void
{
$this->respond(200, $this->limit());
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(0));
$this->assertInstanceOf(BillingLimit::class, $limit);
$this->assertTrue($limit->hasLimit());
$this->assertSame('100.00', $limit->limit());
$this->assertSame('82.40', $limit->used());
$this->assertSame('60.00', $limit->usedByApp());
$this->assertSame('17.60', $limit->remaining());
$this->assertSame('14.78', $limit->remainingNet());
$this->assertSame(19.0, $limit->vatRate());
$this->assertSame('EUR', $limit->currency());
$this->assertFalse($limit->isReached());
$this->assertEquals(new DateTimeImmutable('2026-12-01T02:00:00Z'), $limit->resetsAt());
$this->assertSame(7, $limit->version());
$this->assertSame('gross', $limit->basis);
}
public function test_without_a_limit_the_app_sees_only_its_own_share(): void
{
$this->respond(200, $this->limit(['limit' => null, 'used' => null, 'remaining' => null, 'remaining_net' => null, 'vat_rate' => null]));
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertFalse($limit->hasLimit());
$this->assertNull($limit->limit());
$this->assertNull($limit->used());
$this->assertNull($limit->remaining());
$this->assertNull($limit->remainingNet());
$this->assertNull($limit->vatRate());
$this->assertSame('60.00', $limit->usedByApp());
$this->assertTrue($limit->allows('100000.00'));
}
public function test_allows_compares_with_the_remaining_amount(): void
{
$limit = new BillingLimit((object) $this->limit()['data']);
$this->assertTrue($limit->allows('17.60'));
$this->assertTrue($limit->allows('17.6'));
$this->assertTrue($limit->allows('4.76'));
$this->assertTrue($limit->allows(17.6));
$this->assertTrue($limit->allows(17));
$this->assertFalse($limit->allows('17.61'));
$this->assertFalse($limit->allows('17.600001'));
$this->assertFalse($limit->allows('100.00'));
$this->assertFalse($limit->allows(1000));
}
public function test_a_reached_or_zero_limit_allows_nothing(): void
{
$reached = new BillingLimit((object) $this->limit(['used' => '100.00', 'remaining' => '0.00', 'remaining_net' => '0.00', 'reached' => true])['data']);
$zero = new BillingLimit((object) $this->limit(['limit' => '0.00', 'used' => '0.00', 'remaining' => '0.00', 'reached' => true])['data']);
$this->assertTrue($reached->isReached());
$this->assertFalse($reached->allows('0.01'));
$this->assertTrue($zero->hasLimit());
$this->assertFalse($zero->allows('0.01'));
}
public function test_the_user_s_own_limit_has_no_app_share(): void
{
$limit = new BillingLimit((object) ['object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross', 'limit' => null, 'used' => '12.00', 'remaining' => null, 'remaining_net' => null, 'vat_rate' => 19.0, 'reached' => false, 'resets_at' => '2026-12-01T02:00:00Z', 'version' => 0]);
$this->assertSame('12.00', $limit->used());
$this->assertNull($limit->usedByApp());
$this->assertSame(0, $limit->version());
}
public function test_a_refused_limit_read_is_an_api_exception(): void
{
$this->respond(403, ['message' => 'This app may not read customers\' billing limits.', 'error' => 'billing_limit_not_enabled']);
try {
app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(403, $e->status);
$this->assertSame('billing_limit_not_enabled', $e->error);
$this->assertFalse($e->isBillingLimitReached());
}
}
}
+2
View File
@@ -19,6 +19,8 @@ class BillableModelTest extends TestCase
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
$this->{$name.'Cache'} = 'cached';
}
$this->preferencesCache['global'] = 'cached';
}
};
$user->email = 'ada@example.com';
+159
View File
@@ -55,6 +55,36 @@ class EndpointsTest extends TestCase
$this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0));
}
public function test_billing_overview_carries_the_billing_limit(): void
{
$this->respond(200, ['data' => ['current_charges' => 11.9, 'billing_limit' => ['object' => 'billing_limit', 'limit' => '50.00', 'used' => '11.90', 'remaining' => '38.10', 'reached' => false, 'version' => 3]]]);
$limit = new \Anikeen\Id\Resources\BillingLimit($this->owner()->billingOverview()->billing_limit);
$this->assertSame('38.10', $limit->remaining());
$this->assertTrue($limit->allows('38.10'));
$this->assertNull($limit->usedByApp());
}
public function test_charges_and_billing_limits_of_users_use_the_app_token(): void
{
$charge = ['data' => ['object' => 'charge', 'id' => '01JCHARGE', 'status' => 'open']];
$this->respond(201, $charge);
$this->respond(200, $charge);
$this->respond(200, $charge);
$this->respond(200, ['data' => ['object' => 'billing_limit', 'limit' => null]]);
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']);
app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE');
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0));
$this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(1));
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(2));
$this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(3));
}
public function test_invoice_download_url_is_read_not_updated(): void
{
$this->respond(200, $this->list([['id' => 'inv1']]));
@@ -283,4 +313,133 @@ class EndpointsTest extends TestCase
$this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0));
}
private function preference(string $namespace, string $key, mixed $value, int $version = 7): array
{
return ['namespace' => $namespace, 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'app', 'client_id' => 'app-client']];
}
private function conditions(int $index): array
{
$request = $this->sent[$index]['request'];
return [$request->getHeaderLine('If-Match'), $request->getHeaderLine('If-None-Match')];
}
public function test_global_preferences_of_the_user(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'dark')]]);
$preferences = $this->owner()->globalPreferences();
$this->assertSame('dark', $preferences->get('appearance'));
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_global_preference_with_a_default(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'light')]]);
$owner = $this->owner();
$this->assertSame('light', $owner->globalPreference('appearance'));
$this->assertSame('system', $owner->globalPreference('unknown', 'system'));
$this->assertCount(1, $this->sent);
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_set_global_preference(): void
{
$this->respond(201, ['data' => $this->preference('global', 'appearance', 'dark', 8)]);
$this->respond(200, ['data' => $this->preference('global', 'appearance', 'light', 9)]);
$created = $this->owner()->setGlobalPreference('appearance', 'dark');
$updated = $this->owner()->setGlobalPreference('appearance', 'light', ifVersion: 8);
$this->assertTrue($created->wasCreated());
$this->assertFalse($updated->wasCreated());
$this->assertSame(9, $updated->version);
$this->assertSame('PUT /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame(['value' => 'dark'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"8"', ''], $this->conditions(1));
}
public function test_delete_global_preference(): void
{
$this->respond(204);
$this->respond(204);
$this->owner()->deleteGlobalPreference('appearance');
$this->owner()->deleteGlobalPreference('appearance', ifVersion: 9);
$this->assertSame('DELETE /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame('', (string) $this->sent[0]['request']->getBody());
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"9"', ''], $this->conditions(1));
}
public function test_preferences_by_user_id(): void
{
$this->respond(200, ['data' => [$this->preference('acme', 'plan', 'pro'), $this->preference('acme', 'theme', ['accent' => 'blue'])]]);
$preferences = app(AnikeenId::class)->preferencesByUserId('01JUSER', 'acme');
$this->assertEquals(['plan' => 'pro', 'theme' => (object) ['accent' => 'blue']], $preferences->values());
$this->assertSame('blue', $preferences->find('theme')->value->accent);
$this->assertNull($preferences->find('missing'));
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme Bearer app-token', $this->sentCall(0));
}
public function test_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$preference = app(AnikeenId::class)->preferenceByUserId('01JUSER', 'acme', 'plan');
$this->assertSame('pro', $preference->value);
$this->assertSame(7, $preference->version);
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
}
public function test_set_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(201, ['data' => $this->preference('acme', 'plan', 'pro')]);
$anikeenId = app(AnikeenId::class);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro');
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->assertSame('PUT /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['value' => 'pro'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
$this->assertSame(['', '*'], $this->conditions(2));
}
public function test_delete_preference_by_user_id(): void
{
$this->respond(204);
$this->respond(204);
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan');
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan', ifVersion: 7);
$this->assertSame('DELETE /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
}
public function test_find_user_id_by_preference(): void
{
$this->respond(200, ['data' => ['user_id' => '01JUSER', 'namespace' => 'acme', 'key' => 'customer_id', 'version' => 7, 'updated_at' => '2026-11-06T10:00:00.000000Z']]);
$this->assertSame('01JUSER', app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
$this->assertSame('POST /api/v1/client/preferences/acme/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['key' => 'customer_id', 'value' => 'cus_123'], $this->sentJson(0));
$this->assertSame('', $this->sent[0]['request']->getUri()->getQuery());
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class PreferencesTest 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 preference(string $key, mixed $value, int $version = 7): array
{
return ['data' => ['namespace' => 'global', 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'user', 'client_id' => 'app-client']]];
}
private function path(int $index): string
{
return $this->sent[$index]['request']->getUri()->getPath();
}
public function test_the_lookup_returns_null_when_nobody_holds_the_value(): void
{
$this->respond(404, ['message' => 'The preference is not set.', 'error' => 'preference_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
}
public function test_the_lookup_throws_on_other_not_found_answers(): void
{
$this->respond(404, ['message' => 'The namespace does not exist.', 'error' => 'namespace_not_found']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(404, $e->status);
$this->assertSame('namespace_not_found', $e->error);
}
}
public function test_the_lookup_throws_on_a_404_without_an_error_code(): void
{
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
}
public function test_the_lookup_throws_on_validation_errors(): void
{
$this->respond(422, ['message' => 'The key is not unique.', 'error' => 'key_not_unique']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'plan', 'pro');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame('key_not_unique', $e->error);
}
}
public function test_a_failed_app_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_create_only_write_of_a_set_key_fails(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => 3]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame('preference_exists', $e->error);
$this->assertSame(3, $e->currentVersion());
}
}
public function test_a_failed_user_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => null]);
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 5]);
foreach ([
fn () => $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 4),
fn () => $this->owner()->deleteGlobalPreference('appearance', ifVersion: 4),
] as $i => $call) {
try {
$call();
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame([null, 5][$i], $e->currentVersion());
}
}
}
public function test_a_write_is_either_conditional_or_create_only(): void
{
$this->expectException(InvalidArgumentException::class);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7, createOnly: true);
} finally {
$this->assertCount(0, $this->sent);
}
}
public function test_writes_clear_the_cached_preferences(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(200, $this->preference('appearance', 'dark', 8));
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 8)['data']]]);
$this->respond(204);
$this->respond(200, ['data' => []]);
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE));
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE)); // cached
$owner->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$this->assertSame('dark', $owner->globalPreference(SharedPreference::APPEARANCE));
$owner->deleteGlobalPreference(SharedPreference::APPEARANCE);
$this->assertSame(Appearance::SYSTEM, $owner->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$this->assertCount(5, $this->sent);
$this->assertSame(['GET', 'PUT', 'GET', 'DELETE', 'GET'], array_map(fn ($sent) => $sent['request']->getMethod(), $this->sent));
}
public function test_a_failed_write_clears_the_cached_preferences_too(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(412, ['message' => 'x', 'error' => 'preference_version_mismatch', 'current_version' => 9]);
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 9)['data']]]);
$owner->globalPreferences();
try {
$owner->setGlobalPreference('appearance', 'dark', ifVersion: 7);
} catch (PreconditionFailedException) {
}
$this->assertSame('dark', $owner->globalPreference('appearance'));
$this->assertCount(3, $this->sent);
}
public function test_path_segments_are_encoded(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$this->respond(200, ['data' => ['user_id' => '01JUSER']]);
$anikeenId = app(AnikeenId::class);
$anikeenId->preferencesByUserId('a/b', 'x y');
$anikeenId->deletePreferenceByUserId('01JUSER', 'acme', '../key?');
$anikeenId->findUserIdByPreference('ac/me', 'k', 'v');
$this->assertSame('/api/v1/client/users/a%2Fb/preferences/x%20y', $this->path(0));
$this->assertSame('/api/v1/client/users/01JUSER/preferences/acme/..%2Fkey%3F', $this->path(1));
$this->assertSame('/api/v1/client/preferences/ac%2Fme/lookup', $this->path(2));
}
public function test_values_round_trip_as_json(): void
{
$this->respond(201, $this->preference('settings', (object) []));
$preference = app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'settings', (object) []);
$this->assertSame('{"value":{}}', (string) $this->sent[0]['request']->getBody());
$this->assertEquals((object) [], $preference->value);
}
public function test_a_deleted_user_is_not_found(): void
{
$this->respond(404, ['message' => 'The user does not exist.', 'error' => 'user_not_found']);
try {
app(AnikeenId::class)->preferenceByUserId('01JGONE', 'acme', 'plan');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('user_not_found', $e->error);
}
}
public function test_an_unreachable_service_is_temporary(): void
{
$this->http->append(new \GuzzleHttp\Exception\ConnectException('down', new \GuzzleHttp\Psr7\Request('DELETE', 'x')));
try {
$this->owner()->deleteGlobalPreference('appearance');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isTemporary());
}
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\SshKeys;
use Anikeen\Id\Support\AppRequest;
class RequestHeadersTest extends TestCase
{
public function test_requests_without_extra_headers_send_the_same_headers_as_before(): void
{
$this->respond(200, ['data' => []]);
AppRequest::send('GET', 'v1/client/subscriptions', null, ['page' => 1]);
$request = $this->sent[0]['request'];
$this->assertSame(['Bearer app-token'], $request->getHeader('Authorization'));
$this->assertSame(['app-client'], $request->getHeader('Client-ID'));
$this->assertFalse($request->hasHeader('If-Match'));
$this->assertFalse($request->hasHeader('If-None-Match'));
$this->assertSame('page=1', $request->getUri()->getQuery());
}
public function test_app_requests_send_extra_headers_also_on_the_retry_with_a_fresh_token(): void
{
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->respond(200, ['data' => []]);
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->assertSame('"7"', $this->sent[0]['request']->getHeaderLine('If-Match'));
$this->assertSame('"7"', $this->sent[1]['request']->getHeaderLine('If-Match'));
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
}
public function test_extra_headers_do_not_replace_the_client_and_token_headers(): void
{
$this->respond(200, ['data' => []]);
app(AnikeenId::class)->withToken('user-token')
->put('v1/x', ['value' => 1], [], null, ['If-None-Match' => '*', 'Authorization' => 'Bearer other']);
$request = $this->sent[0]['request'];
$this->assertSame('*', $request->getHeaderLine('If-None-Match'));
$this->assertSame('Bearer user-token', $request->getHeaderLine('Authorization'));
}
public function test_get_and_delete_helpers_take_headers(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
$anikeenId->get('v1/x', [], null, ['If-None-Match' => '"3"']);
$anikeenId->delete('v1/x', [], [], null, ['If-Match' => '"3"']);
$this->assertSame('"3"', $this->sent[0]['request']->getHeaderLine('If-None-Match'));
$this->assertSame('"3"', $this->sent[1]['request']->getHeaderLine('If-Match'));
}
public function test_a_412_of_an_app_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_412_of_a_user_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => null]);
$result = app(AnikeenId::class)->withToken('user-token')->put('v1/x', ['value' => 1]);
$e = ApiException::fromResult($result);
$this->assertInstanceOf(PreconditionFailedException::class, $e);
$this->assertSame('preference_exists', $e->error);
$this->assertNull($e->currentVersion());
}
public function test_list_errors_keep_their_collection_exception_type(): void
{
$this->respond(412, ['message' => 'x', 'error' => 'x']);
$this->expectException(CollectionException::class);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
SshKeys::builder(fn () => $anikeenId->get('v1/ssh-keys'));
}
public function test_other_errors_stay_plain_api_exceptions(): void
{
$this->respond(409, ['message' => 'Taken.', 'error' => 'preference_value_taken']);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1]);
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(ApiException::class, $e::class);
$this->assertTrue($e->isConflict());
}
}
}
+7
View File
@@ -25,6 +25,13 @@ class ScopeTest extends TestCase
$this->assertSame($expected, $constant);
}
public function test_the_preference_scopes_match_anikeen_id(): void
{
$this->assertSame('preferences', Scope::PREFERENCES);
$this->assertSame('preferences:read', Scope::PREFERENCES_READ);
$this->assertSame('preferences:client', Scope::PREFERENCES_CLIENT);
}
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
{
// the Console's product APIs accept `user`, `user:read` and `products:read` only
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
class SharedPreferenceTest extends TestCase
{
public function test_the_shared_keys_and_values_match_anikeen_id(): void
{
$this->assertSame('global', SharedPreference::NAMESPACE);
$this->assertSame('appearance', SharedPreference::APPEARANCE);
$this->assertSame(['system', 'light', 'dark'], Appearance::all());
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Webhooks\EventType;
class UserByIdTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void
{
$this->respond(200, $this->profile());
$user = app(AnikeenId::class)->findUserById('01JUSER');
$this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0));
$this->assertInstanceOf(User::class, $user);
$this->assertSame('01JUSER', $user->id());
$this->assertSame('Ada King', $user->name());
$this->assertSame('ada', $user->username());
$this->assertSame('ada@example.com', $user->email());
$this->assertTrue($user->emailVerified());
$this->assertSame('https://id.test/avatars/01JUSER', $user->avatar());
$this->assertSame(8, $user->version());
$this->assertSame('en', $user->locale);
$this->assertNull($user->pendingEmail());
$this->assertSame('Ada King', $user->jsonSerialize()['name']);
}
public function test_find_user_by_id_encodes_the_id(): void
{
$this->respond(200, $this->profile());
app(AnikeenId::class)->findUserById('a/b');
$this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath());
}
public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER'));
}
public function test_find_user_by_id_throws_other_failures(): void
{
$this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']);
try {
app(AnikeenId::class)->findUserById('01JUSER');
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(403, $e->status);
$this->assertSame('client_not_allowed', $e->error);
}
// a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user"
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserById('01JUSER');
}
public function test_an_unverified_address_is_not_verified(): void
{
$this->respond(200, $this->profile(['email_verified_at' => null]));
$this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified());
}
public function test_a_user_updated_webhook_object_is_a_user(): void
{
$user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada',
'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]);
$this->assertSame(9, $user->version());
$this->assertFalse($user->emailVerified());
$this->assertNull($user->email());
$this->assertSame('user', $user->object);
$this->assertSame('user.updated', EventType::USER_UPDATED);
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use DateTimeImmutable;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserProfileTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
private function validationError(array $codes): array
{
return [
'message' => 'The given data was invalid.',
'error' => array_values($codes)[0],
'errors' => array_map(fn () => ['The value is invalid.'], $codes),
'codes' => $codes,
];
}
public function test_update_profile_patches_the_fields_with_the_user_token(): void
{
$this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9,
'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z']));
$user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']);
$this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0));
$this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0));
$this->assertSame('Ada Lovelace', $user->name());
$this->assertSame('ada@example.com', $user->email());
$this->assertSame('new@example.com', $user->pendingEmail());
$this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt());
$this->assertSame(9, $user->version());
}
public function test_update_profile_clears_the_cached_user_data(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->assertSame('Ada King', $owner->getUserData()->name);
$owner->updateProfile(['name' => 'Ada Lovelace']);
$this->assertSame('Ada Lovelace', $owner->getUserData()->name);
$this->assertCount(3, $this->sent);
$this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2));
}
public function test_refused_profile_changes_carry_codes_per_field(): void
{
$this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid']));
try {
$this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('username_taken', $e->error);
$this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes());
$this->assertSame('email_invalid', $e->errorCodeFor('email'));
$this->assertNull($e->errorCodeFor('name'));
$this->assertTrue($e->hasErrorCode('email_invalid'));
$this->assertFalse($e->hasErrorCode('email_taken'));
$this->assertSame(['The value is invalid.'], $e->errors()['username']);
$this->assertInstanceOf(ApiException::class, $e);
}
}
public function test_a_client_credentials_token_is_refused(): void
{
$this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']);
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertNotInstanceOf(ValidationFailedException::class, $e);
$this->assertSame('user_token_required', $e->error);
}
}
public function test_an_unreachable_service_is_an_api_exception(): void
{
$this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile')));
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_upload_avatar_sends_the_picture_as_multipart(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile(['profile_version' => 10]));
try {
$user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]);
} finally {
unlink($path);
}
$request = $this->sent[0]['request'];
$body = (string) $request->getBody();
$this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0));
$this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type'));
$this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body);
$this->assertStringContainsString('picture-bytes', $body);
$this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body);
$this->assertStringContainsString('name="crop[y]"', $body);
$this->assertStringContainsString('name="crop[size]"', $body);
$this->assertSame(10, $user->version());
}
public function test_upload_avatar_without_crop_sends_only_the_picture(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile());
try {
$this->owner()->uploadAvatar(new \SplFileInfo($path));
} finally {
unlink($path);
}
$body = (string) $this->sent[0]['request']->getBody();
$this->assertStringContainsString('name="avatar"', $body);
$this->assertStringNotContainsString('crop', $body);
}
public function test_a_refused_upload_carries_its_code(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'not a picture');
$this->respond(422, $this->validationError(['avatar' => 'avatar_invalid']));
$this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']);
try {
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('avatar_invalid', $e->errorCodeFor('avatar'));
}
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(413, $e->status);
$this->assertSame('avatar_too_large', $e->error);
}
} finally {
unlink($path);
}
}
public function test_crop_codes_are_found_under_the_crop_field(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'a picture');
$this->respond(422, $this->validationError(['crop.size' => 'crop_invalid']));
try {
$this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('crop_invalid', $e->errorCodeFor('crop'));
$this->assertSame('crop_invalid', $e->errorCodeFor('crop.size'));
$this->assertNull($e->errorCodeFor('cro'));
$this->assertNull($e->errorCodeFor('avatar'));
} finally {
unlink($path);
}
}
public function test_an_unreadable_file_throws_before_anything_is_sent(): void
{
try {
$this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png');
$this->fail('No exception thrown');
} catch (\RuntimeException $e) {
$this->assertNotInstanceOf(ApiException::class, $e);
$this->assertCount(0, $this->sent);
}
}
public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['profile_version' => 11]));
$this->respond(200, $this->profile(['profile_version' => 11]));
$owner->getUserData();
$user = $owner->removeAvatar();
$owner->getUserData();
$this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1));
$this->assertSame(11, $user->version());
$this->assertCount(3, $this->sent);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Illuminate\Database\Eloquent\Model;
class ValidationFailedTest 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;
}
public function test_422_of_app_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10));
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('value_too_large', $e->error);
$this->assertSame([], $e->errorCodes());
$this->assertTrue($e->hasErrorCode('value_too_large'));
}
}
public function test_412_stays_a_precondition_failure(): void
{
$this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]);
$this->expectException(PreconditionFailedException::class);
$this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2);
}
public function test_422_of_user_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']);
try {
$this->owner()->setGlobalPreference('appearance', 'purple');
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('invalid_shared_value', $e->error);
$this->assertSame([], $e->errorCodes());
}
}
}
+24
View File
@@ -70,6 +70,30 @@ class WebhookTest extends TestCase
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
}
public function test_refunds_have_their_own_event_type(): void
{
$this->assertSame('order.refunded', EventType::ORDER_REFUNDED);
$this->assertContains(EventType::ORDER_REFUNDED, EventType::all());
}
public function test_billing_limit_events_have_their_own_types(): void
{
$this->assertSame('billing_limit.reached', EventType::BILLING_LIMIT_REACHED);
$this->assertSame('billing_limit.available', EventType::BILLING_LIMIT_AVAILABLE);
$this->assertContains(EventType::BILLING_LIMIT_REACHED, EventType::all());
$this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all());
}
public function test_user_and_preference_events_have_their_own_types(): void
{
$this->assertSame('preference.updated', EventType::PREFERENCE_UPDATED);
$this->assertSame('user.updated', EventType::USER_UPDATED);
$this->assertSame('user.deleted', EventType::USER_DELETED);
$this->assertContains(EventType::PREFERENCE_UPDATED, EventType::all());
$this->assertContains(EventType::USER_UPDATED, EventType::all());
$this->assertContains(EventType::USER_DELETED, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void
{
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [