Compare commits

...
5 Commits
Author SHA1 Message Date
envoyr 2b47be157b Release 3.2.0 2026-11-06 11:38:47 +00:00
envoyr 2796befc7d Merge pull request #19 from anikeen-com/feature/bill-elapsed-periods
Bill elapsed periods and look up users by email
2026-11-06 11:26:19 +00:00
envoyr 64ccb84987 Document elapsed periods and user accounts 2026-11-06 10:26:51 +00:00
envoyr 7813e8fbaa Look up users by email and document invitations 2026-11-06 10:26:51 +00:00
envoyr 27e868720f Bill elapsed periods on app subscription checkout 2026-11-06 10:26:51 +00:00
8 changed files with 374 additions and 16 deletions

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# 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
+64 -1
View File
@@ -514,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
@@ -526,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 {
@@ -616,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
@@ -833,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
+63 -1
View File
@@ -514,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
@@ -526,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 {
@@ -616,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
+48 -10
View File
@@ -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).
*
+19
View File
@@ -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;
+36 -4
View File
@@ -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,
]);
}
/**
+81
View File
@@ -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']);
+47
View File
@@ -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]]);