mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 15:48:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5cb4b76c9 | ||
|
|
2b47be157b | ||
|
|
2796befc7d | ||
|
|
64ccb84987 | ||
|
|
7813e8fbaa | ||
|
|
27e868720f | ||
|
|
fb71b172f2 | ||
|
|
72f761b077
|
||
|
|
e8432df997
|
||
|
|
d99512a359
|
||
|
|
0ed6934da3 | ||
|
|
df7e506a5e
|
No files matched your search
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 3.2.0 (2026-11-06)
|
||||
|
||||
Elapsed-period billing and user lookup; they need an Anikeen ID release that supports them.
|
||||
|
||||
- `AppSubscription::checkout()` takes `billElapsedPeriods` and `maxNetAmount`: a subscription
|
||||
started more than one period ago is billed for its elapsed periods too, up to the given net
|
||||
amount (409 `elapsed_amount_exceeded` with `net_amount`, `elapsed_periods_not_allowed`). Calls
|
||||
without the new arguments send the same request as before.
|
||||
- `ApiException::body()` returns the JSON body of Anikeen ID's error response, e.g. its
|
||||
`net_amount`.
|
||||
- Add `AnikeenId::findUserByEmail()` for trusted apps: looks up an account by email address
|
||||
(regardless of case) without creating one, null when there is none.
|
||||
- `createUser()` documents `name`, `password_confirmation` and `send_invitation` (a newly created
|
||||
account gets an invitation mail to set a password) and the response fields `created` and
|
||||
`invitation_sent`.
|
||||
|
||||
## 3.1.0 (2026-11-05)
|
||||
|
||||
- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs
|
||||
of the Anikeen Console.
|
||||
- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and
|
||||
`reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`,
|
||||
`Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console.
|
||||
- Document the scope schema and the new scopes in the README.
|
||||
|
||||
Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags.
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
|
||||
PHP Anikeen ID API Client for Laravel 11+
|
||||
PHP Anikeen ID API Client for Laravel 12+
|
||||
|
||||
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
|
||||
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
|
||||
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
|
||||
|
||||
### Event Listener
|
||||
|
||||
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
@@ -160,6 +160,27 @@ if ($result->success()) {
|
||||
}
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
|
||||
`resource:client` access to what the app or token created itself. All of them are constants on
|
||||
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
|
||||
|
||||
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
|
||||
these scopes for its product and reseller APIs:
|
||||
|
||||
| Scope | Constant | Grants |
|
||||
|-------|----------|--------|
|
||||
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
|
||||
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
|
||||
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
|
||||
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
|
||||
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
|
||||
|
||||
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
|
||||
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
|
||||
changing products needs `user`.
|
||||
|
||||
### Change the default access token / refresh token field name
|
||||
|
||||
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||
@@ -493,6 +514,11 @@ $subscription = $subscriptions->create([
|
||||
|
||||
$subscription->checkout(); // bill the first period
|
||||
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
|
||||
$subscription->checkout( // running for longer: also bill the periods that have ended
|
||||
startsAt: $domain->registered_at,
|
||||
billElapsedPeriods: true,
|
||||
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
|
||||
);
|
||||
$subscription->pause(); // expires when the paid period ends
|
||||
$subscription->resume(); // expired subscriptions start a new period
|
||||
$subscription->revoke(refund: false); // cancel
|
||||
@@ -505,10 +531,44 @@ The user's Anikeen ID is read from the billable's `anikeen_id` attribute
|
||||
(`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the
|
||||
subscriptions it created (plus those of clients delegated to it).
|
||||
|
||||
#### Starting from an earlier date
|
||||
|
||||
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
|
||||
the customer came to your app. If the first period from that date is still running, it is billed
|
||||
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
|
||||
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
|
||||
`checkout()` throws an `InvalidArgumentException` without sending anything):
|
||||
|
||||
- every period that has ended plus the running one is billed now, as one line with the full
|
||||
service period. It lands on the user's collective order of the current month and is invoiced and
|
||||
charged with the next monthly bill; nothing is back-dated.
|
||||
- the start may be at most about three years ago (422 otherwise).
|
||||
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
|
||||
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
|
||||
you can show it and retry.
|
||||
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
|
||||
before, replaces another one, or is billed by the hour or day with a monthly cap).
|
||||
|
||||
```php
|
||||
try {
|
||||
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
|
||||
} catch (ApiException $e) {
|
||||
if ($e->error === 'elapsed_amount_exceeded') {
|
||||
$actual = $e->body()->net_amount; // ask again with the real amount
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error codes
|
||||
|
||||
Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable
|
||||
`error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`,
|
||||
`already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`,
|
||||
`idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
||||
`idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
|
||||
`elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
|
||||
`isTemporary()`:
|
||||
|
||||
```php
|
||||
try {
|
||||
@@ -595,6 +655,29 @@ While a session is open, its subscriptions can't be checked out or repriced by t
|
||||
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
|
||||
`checkout.*` event of the session.
|
||||
|
||||
### User accounts (trusted apps, no user token)
|
||||
|
||||
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
|
||||
are matched regardless of case.
|
||||
|
||||
```php
|
||||
$anikeenId = app(AnikeenId::class);
|
||||
|
||||
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
|
||||
$account?->data->id; // also name, email, email_verified, has_billing_address
|
||||
|
||||
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
|
||||
'name' => 'Erika Mustermann',
|
||||
'email' => 'erika@example.com',
|
||||
'send_invitation' => true, // a new account gets a mail with a link to set a password
|
||||
]);
|
||||
$result->data->created; // false: the account already existed (and is unchanged)
|
||||
$result->data->invitation_sent; // only for an account created by this call without a password
|
||||
```
|
||||
|
||||
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
|
||||
other than an unknown address.
|
||||
|
||||
|
||||
## API authentication
|
||||
|
||||
@@ -812,6 +895,7 @@ public function sshKeysByUserId(string $userId): SshKeys
|
||||
```php
|
||||
public function getAuthedUser(): Result
|
||||
public function createUser(array $attributes): Result
|
||||
public function findUserByEmail(string $email): ?Result
|
||||
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
|
||||
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
|
||||
public function isEmailExisting(string $email): Result
|
||||
|
||||
+86
-3
@@ -4,7 +4,7 @@
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
|
||||
PHP Anikeen ID API Client for Laravel 11+
|
||||
PHP Anikeen ID API Client for Laravel 12+
|
||||
|
||||
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
|
||||
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
|
||||
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
|
||||
|
||||
### Event Listener
|
||||
|
||||
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
@@ -160,6 +160,27 @@ if ($result->success()) {
|
||||
}
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
|
||||
`resource:client` access to what the app or token created itself. All of them are constants on
|
||||
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
|
||||
|
||||
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
|
||||
these scopes for its product and reseller APIs:
|
||||
|
||||
| Scope | Constant | Grants |
|
||||
|-------|----------|--------|
|
||||
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
|
||||
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
|
||||
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
|
||||
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
|
||||
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
|
||||
|
||||
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
|
||||
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
|
||||
changing products needs `user`.
|
||||
|
||||
### Change the default access token / refresh token field name
|
||||
|
||||
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||
@@ -493,6 +514,11 @@ $subscription = $subscriptions->create([
|
||||
|
||||
$subscription->checkout(); // bill the first period
|
||||
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
|
||||
$subscription->checkout( // running for longer: also bill the periods that have ended
|
||||
startsAt: $domain->registered_at,
|
||||
billElapsedPeriods: true,
|
||||
maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to
|
||||
);
|
||||
$subscription->pause(); // expires when the paid period ends
|
||||
$subscription->resume(); // expired subscriptions start a new period
|
||||
$subscription->revoke(refund: false); // cancel
|
||||
@@ -505,10 +531,44 @@ The user's Anikeen ID is read from the billable's `anikeen_id` attribute
|
||||
(`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the
|
||||
subscriptions it created (plus those of clients delegated to it).
|
||||
|
||||
#### Starting from an earlier date
|
||||
|
||||
`checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before
|
||||
the customer came to your app. If the first period from that date is still running, it is billed
|
||||
now and renews when it ends. An older start is refused with `period_already_ended`, unless you
|
||||
pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or
|
||||
`checkout()` throws an `InvalidArgumentException` without sending anything):
|
||||
|
||||
- every period that has ended plus the running one is billed now, as one line with the full
|
||||
service period. It lands on the user's collective order of the current month and is invoiced and
|
||||
charged with the next monthly bill; nothing is back-dated.
|
||||
- the start may be at most about three years ago (422 otherwise).
|
||||
- if the periods come to more than `maxNetAmount`, nothing is booked: 409
|
||||
`elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so
|
||||
you can show it and retry.
|
||||
- 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran
|
||||
before, replaces another one, or is billed by the hour or day with a monthly cap).
|
||||
|
||||
```php
|
||||
try {
|
||||
$subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed);
|
||||
} catch (ApiException $e) {
|
||||
if ($e->error === 'elapsed_amount_exceeded') {
|
||||
$actual = $e->body()->net_amount; // ask again with the real amount
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error codes
|
||||
|
||||
Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable
|
||||
`error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`,
|
||||
`already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`,
|
||||
`idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`:
|
||||
`idempotency_key_conflict`, `period_already_ended`, `elapsed_periods_not_allowed`,
|
||||
`elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`,
|
||||
`isTemporary()`:
|
||||
|
||||
```php
|
||||
try {
|
||||
@@ -595,6 +655,29 @@ While a session is open, its subscriptions can't be checked out or repriced by t
|
||||
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
|
||||
`checkout.*` event of the session.
|
||||
|
||||
### User accounts (trusted apps, no user token)
|
||||
|
||||
Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses
|
||||
are matched regardless of case.
|
||||
|
||||
```php
|
||||
$anikeenId = app(AnikeenId::class);
|
||||
|
||||
$account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it
|
||||
$account?->data->id; // also name, email, email_verified, has_billing_address
|
||||
|
||||
$result = $anikeenId->createUser([ // finds the account of the email address or creates it
|
||||
'name' => 'Erika Mustermann',
|
||||
'email' => 'erika@example.com',
|
||||
'send_invitation' => true, // a new account gets a mail with a link to set a password
|
||||
]);
|
||||
$result->data->created; // false: the account already existed (and is unchanged)
|
||||
$result->data->invitation_sent; // only for an account created by this call without a password
|
||||
```
|
||||
|
||||
`findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason
|
||||
other than an unknown address.
|
||||
|
||||
|
||||
## API authentication
|
||||
|
||||
|
||||
+5
-5
@@ -15,15 +15,15 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^12.69|^13.0",
|
||||
"illuminate/console": "^12.69|^13.0",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"socialiteproviders/manager": "^4.6",
|
||||
"firebase/php-jwt": "^6.10|^7.0"
|
||||
"firebase/php-jwt": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
||||
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
||||
"laravel/framework": "^12.69|^13.0",
|
||||
"orchestra/testbench": "^10.0|^11.0",
|
||||
"phpunit/phpunit": "^11.0|^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\ApiOperations\Get;
|
||||
use Anikeen\Id\ApiOperations\Post;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Result;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use Throwable;
|
||||
|
||||
trait ManagesUsers
|
||||
@@ -24,20 +26,32 @@ trait ManagesUsers
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user on behalf of the current user.
|
||||
* Finds the account of an email address or creates it (trusted apps, app token). The email
|
||||
* address is matched regardless of case; a new account stores it in lower case, unverified.
|
||||
* An existing account is returned unchanged (only a missing name is filled in).
|
||||
*
|
||||
* @param array{
|
||||
* first_name: null|string,
|
||||
* last_name: null|string,
|
||||
* username: null|string,
|
||||
* name?: null|string,
|
||||
* first_name?: null|string,
|
||||
* last_name?: null|string,
|
||||
* username?: null|string,
|
||||
* email: string,
|
||||
* password: null|string
|
||||
* password?: null|string,
|
||||
* password_confirmation?: null|string,
|
||||
* send_invitation?: bool
|
||||
* } $attributes The user data
|
||||
* - first_name: The first name (optional)
|
||||
* - last_name: The last name (optional)
|
||||
* - username: The username (optional)
|
||||
* - email: The email (required)
|
||||
* - password: The password (optional, can be reset by the user if not provided)
|
||||
* - name: The display name (optional)
|
||||
* - first_name: The first name (optional, joined with last_name into name)
|
||||
* - last_name: The last name (optional, joined with first_name into name)
|
||||
* - username: The username (optional)
|
||||
* - email: The email (required)
|
||||
* - password: The password (optional, can be reset by the user if not provided)
|
||||
* - password_confirmation: Defaults to password
|
||||
* - send_invitation: Send the user an invitation mail with a link to set a password
|
||||
* (optional, only when this call created the account without a
|
||||
* password, never for an existing account)
|
||||
* @return Result `data` is the user plus `created` (this call created the account) and
|
||||
* `invitation_sent` (the invitation mail was sent); 201 when created, 200 when it existed
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function createUser(array $attributes): Result
|
||||
@@ -57,6 +71,30 @@ trait ManagesUsers
|
||||
return $this->asApp()->post('v1/admin/users', $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the account of an email address without creating or changing anything (trusted
|
||||
* apps, app token). The email address is matched regardless of case.
|
||||
*
|
||||
* `data` is `object{id: string, name: ?string, email: string, email_verified: bool,
|
||||
* has_billing_address: bool}`.
|
||||
*
|
||||
* @return Result|null null when no account uses the email address
|
||||
* @throws ApiException when the request fails for another reason
|
||||
*/
|
||||
public function findUserByEmail(string $email): ?Result
|
||||
{
|
||||
try {
|
||||
return AppRequest::send('POST', 'v1/admin/users/lookup', ['email' => $email]);
|
||||
} catch (ApiException $e) {
|
||||
// only Anikeen ID's own answer means "no account", not e.g. an unknown route
|
||||
if ($e->isNotFound() && $e->error === 'user_not_found') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user of a social login or create it (trusted apps, app token).
|
||||
*
|
||||
|
||||
@@ -36,5 +36,16 @@ class Scope
|
||||
const SSH_KEYS = 'ssh-keys';
|
||||
const SSH_KEYS_READ = 'ssh-keys:read';
|
||||
|
||||
// Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage),
|
||||
// read-only. Changing products needs `user`; there is no full `products` scope.
|
||||
const PRODUCTS_READ = 'products:read';
|
||||
|
||||
// Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not
|
||||
// open these endpoints, a token must carry the reseller scope itself.
|
||||
const RESELLER_CUSTOMERS = 'reseller-customers';
|
||||
const RESELLER_CUSTOMERS_READ = 'reseller-customers:read';
|
||||
const RESELLER_GRANTS = 'reseller-grants';
|
||||
const RESELLER_ORDERS = 'reseller-orders';
|
||||
|
||||
const ADMIN = 'admin';
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Anikeen\Id\Exceptions;
|
||||
|
||||
use Anikeen\Id\Result;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,24 @@ class ApiException extends ResourceException
|
||||
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON body of Anikeen ID's error response, null without one. Some errors carry details,
|
||||
* e.g. `net_amount` of "elapsed_amount_exceeded".
|
||||
*/
|
||||
public function body(): ?object
|
||||
{
|
||||
$previous = $this->getPrevious();
|
||||
$response = $previous !== null && method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
|
||||
|
||||
if (!$response instanceof ResponseInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = json_decode((string) $response->getBody());
|
||||
|
||||
return is_object($body) ? $body : null;
|
||||
}
|
||||
|
||||
public function isNotFound(): bool
|
||||
{
|
||||
return $this->status === 404;
|
||||
|
||||
@@ -6,6 +6,7 @@ use Anikeen\Id\Concerns\HasSubscriptionStatus;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
@@ -73,15 +74,46 @@ class AppSubscription implements JsonSerializable
|
||||
* $startsAt (in the past) books a service that is already running, e.g. a domain registered
|
||||
* earlier: the first period runs from $startsAt, is billed now and renews when it ends.
|
||||
*
|
||||
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", …
|
||||
* A $startsAt older than one period is refused ("period_already_ended") unless
|
||||
* $billElapsedPeriods is set: then the periods that have already ended are billed too, together
|
||||
* with the running one, as long as they come to at most $maxNetAmount (net, up to two
|
||||
* decimals) and $startsAt lies within the maximum age Anikeen ID accepts (about three years).
|
||||
* The lines land on the current month's collective order, which is charged on the 1st of the
|
||||
* next month. $billElapsedPeriods needs both $startsAt and $maxNetAmount.
|
||||
*
|
||||
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended",
|
||||
* "elapsed_periods_not_allowed" (the elapsed periods can't be billed for this subscription),
|
||||
* "elapsed_amount_exceeded" (they come to more than $maxNetAmount; the response body has
|
||||
* the booked `net_amount`), …; 422 invalid input (e.g. a start older than the maximum age)
|
||||
* @throws InvalidArgumentException $billElapsedPeriods without $startsAt or $maxNetAmount
|
||||
*/
|
||||
public function checkout(DateTimeInterface|string|null $startsAt = null): self
|
||||
{
|
||||
public function checkout(
|
||||
DateTimeInterface|string|null $startsAt = null,
|
||||
bool $billElapsedPeriods = false,
|
||||
float|string|null $maxNetAmount = null,
|
||||
): self {
|
||||
if ($startsAt instanceof DateTimeInterface) {
|
||||
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
|
||||
if (!$billElapsedPeriods) {
|
||||
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
|
||||
}
|
||||
|
||||
if ($startsAt === null || $startsAt === '') {
|
||||
throw new InvalidArgumentException('Billing elapsed periods needs a start date ($startsAt).');
|
||||
}
|
||||
|
||||
if ($maxNetAmount === null || $maxNetAmount === '') {
|
||||
throw new InvalidArgumentException('Billing elapsed periods needs a maximum net amount ($maxNetAmount).');
|
||||
}
|
||||
|
||||
return $this->send('PUT', '/checkout', [
|
||||
'starts_at' => $startsAt,
|
||||
'bill_elapsed_periods' => true,
|
||||
// floats from calculations may carry binary noise (e.g. 59.970000000000006)
|
||||
'max_net_amount' => is_float($maxNetAmount) ? round($maxNetAmount, 2) : $maxNetAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,87 @@ class AppSubscriptionsTest extends TestCase
|
||||
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
|
||||
}
|
||||
|
||||
public function test_a_checkout_without_arguments_sends_no_body(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(200, $this->subscription(['active' => true]));
|
||||
|
||||
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1])->checkout();
|
||||
|
||||
$request = $this->sent[1]['request'];
|
||||
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
|
||||
$this->assertSame('', (string) $request->getBody());
|
||||
$this->assertFalse($request->hasHeader('Content-Type'));
|
||||
}
|
||||
|
||||
public function test_a_checkout_from_a_date_sends_only_the_start(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(200, $this->subscription(['active' => true]));
|
||||
|
||||
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
|
||||
->checkout('2026-05-19T11:33:58+00:00');
|
||||
|
||||
$this->assertSame('{"starts_at":"2026-05-19T11:33:58+00:00"}', (string) $this->sent[1]['request']->getBody());
|
||||
}
|
||||
|
||||
public function test_elapsed_periods_are_billed_up_to_the_ceiling(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(200, $this->subscription(['active' => true]));
|
||||
|
||||
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
|
||||
->checkout(new \DateTimeImmutable('2024-05-19 11:33:58', new \DateTimeZone('UTC')), billElapsedPeriods: true, maxNetAmount: '15.57');
|
||||
|
||||
$request = $this->sent[1]['request'];
|
||||
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
|
||||
$this->assertSame(['starts_at' => '2024-05-19T11:33:58+00:00', 'bill_elapsed_periods' => true, 'max_net_amount' => '15.57'], $this->sentJson(1));
|
||||
}
|
||||
|
||||
public function test_a_float_ceiling_is_sent_with_two_decimals(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(200, $this->subscription(['active' => true]));
|
||||
|
||||
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => 19.99])
|
||||
->checkout('2024-05-19', true, 19.99 * 3);
|
||||
|
||||
$this->assertSame(['starts_at' => '2024-05-19', 'bill_elapsed_periods' => true, 'max_net_amount' => 59.97], $this->sentJson(1));
|
||||
}
|
||||
|
||||
public function test_billing_elapsed_periods_needs_a_start_and_a_ceiling(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
|
||||
|
||||
foreach ([[null, '10.00'], ['2024-05-19', null], ['', '10.00'], ['2024-05-19', '']] as [$startsAt, $maxNetAmount]) {
|
||||
try {
|
||||
$subscription->checkout($startsAt, billElapsedPeriods: true, maxNetAmount: $maxNetAmount);
|
||||
$this->fail('Expected an InvalidArgumentException.');
|
||||
} catch (InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertCount(1, $this->sent);
|
||||
}
|
||||
|
||||
public function test_an_exceeded_ceiling_carries_the_net_amount(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(409, ['message' => 'The elapsed periods come to 20.76 net, more than 15.57.', 'error' => 'elapsed_amount_exceeded', 'net_amount' => '20.76']);
|
||||
|
||||
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
|
||||
|
||||
try {
|
||||
$subscription->checkout('2024-05-19', billElapsedPeriods: true, maxNetAmount: '15.57');
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertTrue($e->isConflict());
|
||||
$this->assertSame('elapsed_amount_exceeded', $e->error);
|
||||
$this->assertSame('20.76', $e->body()->net_amount);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_errors_carry_status_and_error_code(): void
|
||||
{
|
||||
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\AnikeenId;
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,52 @@ class EndpointsTest extends TestCase
|
||||
$this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0));
|
||||
}
|
||||
|
||||
public function test_create_user_can_send_an_invitation(): void
|
||||
{
|
||||
$this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]);
|
||||
|
||||
$result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]);
|
||||
|
||||
$this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0));
|
||||
$this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0));
|
||||
$this->assertTrue($result->data->created);
|
||||
$this->assertTrue($result->data->invitation_sent);
|
||||
}
|
||||
|
||||
public function test_find_user_by_email_uses_the_lookup_endpoint(): void
|
||||
{
|
||||
$this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]);
|
||||
|
||||
$result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com');
|
||||
|
||||
$this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0));
|
||||
$this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0));
|
||||
$this->assertSame('01JUSER', $result->data->id);
|
||||
$this->assertFalse($result->data->has_billing_address);
|
||||
}
|
||||
|
||||
public function test_find_user_by_email_returns_null_without_an_account(): void
|
||||
{
|
||||
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
|
||||
|
||||
$this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com'));
|
||||
}
|
||||
|
||||
public function test_find_user_by_email_throws_other_failures(): void
|
||||
{
|
||||
$this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup
|
||||
$this->respond(500, ['message' => 'Server Error']);
|
||||
|
||||
foreach ([404, 500] as $status) {
|
||||
try {
|
||||
app(AnikeenId::class)->findUserByEmail('erika@example.com');
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame($status, $e->status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_email_check_and_socialite_users(): void
|
||||
{
|
||||
$this->respond(200, ['data' => ['exists' => true]]);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\Enums\Scope;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use ReflectionClass;
|
||||
|
||||
class ScopeTest extends TestCase
|
||||
{
|
||||
public static function newScopes(): array
|
||||
{
|
||||
return [
|
||||
'products:read' => [Scope::PRODUCTS_READ, 'products:read'],
|
||||
'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'],
|
||||
'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'],
|
||||
'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'],
|
||||
'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('newScopes')]
|
||||
public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, $constant);
|
||||
}
|
||||
|
||||
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
|
||||
{
|
||||
// the Console's product APIs accept `user`, `user:read` and `products:read` only
|
||||
$this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants());
|
||||
}
|
||||
|
||||
public function test_every_scope_follows_the_resource_and_level_schema(): void
|
||||
{
|
||||
$scopes = array_values((new ReflectionClass(Scope::class))->getConstants());
|
||||
|
||||
$this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique');
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
// "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource
|
||||
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_each_reseller_resource_is_its_own_group(): void
|
||||
{
|
||||
$groups = collect((new ReflectionClass(Scope::class))->getConstants())
|
||||
->filter(fn (string $scope) => str_starts_with($scope, 'reseller-'))
|
||||
->groupBy(fn (string $scope) => explode(':', $scope)[0])
|
||||
->map(fn ($scopes) => $scopes->sort()->values()->all())
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
'reseller-customers' => ['reseller-customers', 'reseller-customers:read'],
|
||||
'reseller-grants' => ['reseller-grants'],
|
||||
'reseller-orders' => ['reseller-orders'],
|
||||
], $groups);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user