Compare commits

...
Author SHA1 Message Date
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
21 changed files with 2186 additions and 22 deletions

No files matched your search

+30 -1
View File
@@ -2,7 +2,29 @@
## Unreleased ## Unreleased
Preferences; they need an Anikeen ID release that supports them. User profiles and the user and preference webhooks; they need an Anikeen ID release that supports
them.
### Added
- 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.4.0-beta.1 (2026-11-06)
Preferences, charges and the billing limit; they need an Anikeen ID release that supports them.
### Added ### Added
@@ -18,6 +40,13 @@ Preferences; they need an Anikeen ID release that supports them.
`PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`. `PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`.
- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`. - Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`.
- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`). - 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()`, - An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before. `delete()` and `AppRequest::send()`; calls without it send the same request as before.
+300 -9
View File
@@ -16,12 +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) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## Installation
@@ -182,6 +183,8 @@ 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`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. 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: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | Scope | Constant | Grants |
@@ -214,11 +217,14 @@ public function boot(): void
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID | | Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID | | 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) | | 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 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 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 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: The flow, as in a typical shop:
@@ -558,11 +564,81 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [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 `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 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 `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. 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 ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -573,7 +649,16 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`, `already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason, - `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry - `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 ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -851,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, payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available. `$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) ### 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, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
@@ -932,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. 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 ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
@@ -1072,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
on the model instance (one request per instance); `setGlobalPreference()` and 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 `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 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: 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 ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
@@ -1290,6 +1573,10 @@ public function appCheckoutSessions(): AppCheckoutSessions
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
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 ### ManagesSocialiteUsers
@@ -1310,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): Result public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?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 findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result public function isEmailExisting(string $email): Result
@@ -1382,6 +1670,9 @@ public function getBillable(): Billable
```php ```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string 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 setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
+292 -9
View File
@@ -16,12 +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) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## Installation
@@ -182,6 +183,8 @@ 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`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. 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: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | Scope | Constant | Grants |
@@ -214,11 +217,14 @@ public function boot(): void
| Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID | | Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID |
| Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID | | 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) | | 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 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 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 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: The flow, as in a typical shop:
@@ -558,11 +564,81 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [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 `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 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 `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. 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 ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -573,7 +649,16 @@ well as with the app's (`app*()`). It carries:
`already_cancelled`), otherwise `null`, `already_cancelled`), otherwise `null`,
- `getMessage()`: Anikeen ID's reason, - `getMessage()`: Anikeen ID's reason,
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry - `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 ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -851,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, payment) and back to `return_url`. For the signed-in user with their own token,
`$user->billingPortalUrl()` stays available. `$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) ### 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, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
@@ -932,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. 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 ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
@@ -1072,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
on the model instance (one request per instance); `setGlobalPreference()` and 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 `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 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: 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 ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
+26
View File
@@ -397,6 +397,32 @@ class AnikeenId
return $result; 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. * Build headers for request.
* *
+7 -1
View File
@@ -31,7 +31,13 @@ trait ManagesBalance
/** /**
* Billing overview of the current user: current_balance, current_charges, vat_rate, * 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 * @throws Throwable
*/ */
+107
View File
@@ -2,8 +2,19 @@
namespace Anikeen\Id\Concerns; 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; 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 trait ManagesProfile
{ {
use HasBillable; use HasBillable;
@@ -23,4 +34,100 @@ trait ManagesProfile
'options' => $options, 'options' => $options,
])->data->url; ])->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; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\BillingLimit;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
@@ -123,4 +125,101 @@ trait ManagesUserBilling
{ {
return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]); 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);
}
} }
+25
View File
@@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use Throwable; 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). * Find the user of a social login or create it (trusted apps, app token).
* *
+15
View File
@@ -35,6 +35,11 @@ class ApiException extends ResourceException
return PreconditionFailedException::fromResult($result); 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( return new static(
rtrim((string) ($body->message ?? $result->error()), '.'), rtrim((string) ($body->message ?? $result->error()), '.'),
$status, $status,
@@ -79,6 +84,16 @@ class ApiException extends ResourceException
return $this->status === 409; 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. * The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/ */
@@ -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);
}
}
+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();
}
}
+7 -2
View File
@@ -6,6 +6,7 @@ use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException; use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Throwable; use Throwable;
@@ -13,7 +14,7 @@ use Throwable;
/** /**
* Sends a request with the app's own token (client credentials). A rejected token is renewed * 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 (a 412 in a * once; every failure ends in an ApiException with status and error code (a 412 in a
* PreconditionFailedException). * PreconditionFailedException, a 422 in a ValidationFailedException).
*/ */
final class AppRequest final class AppRequest
{ {
@@ -36,7 +37,11 @@ final class AppRequest
if (!$result->success()) { if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null; $body = is_object($result->data) ? $result->data : null;
$exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; $exception = match ($result->status) {
412 => PreconditionFailedException::class,
422 => ValidationFailedException::class,
default => ApiException::class,
};
throw new $exception( throw new $exception(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
+43
View File
@@ -61,6 +61,49 @@ final class EventType
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */ /** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
public const CHECKOUT_EXPIRED = 'checkout.expired'; 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'; 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());
}
}
}
+30
View File
@@ -55,6 +55,36 @@ class EndpointsTest extends TestCase
$this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0)); $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 public function test_invoice_download_url_is_read_not_updated(): void
{ {
$this->respond(200, $this->list([['id' => 'inv1']])); $this->respond(200, $this->list([['id' => 'inv1']]));
+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());
}
}
}
+18
View File
@@ -76,6 +76,24 @@ class WebhookTest extends TestCase
$this->assertContains(EventType::ORDER_REFUNDED, EventType::all()); $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 public function test_a_change_carries_the_previous_attributes(): void
{ {
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [ $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [