diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd3a7e..38816ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +- `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 diff --git a/README.md b/README.md index 1da3dae..af76b77 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.stub b/README.stub index 6fb0a71..e0efbcf 100644 --- a/README.stub +++ b/README.stub @@ -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 diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php index 68824e5..26b07a7 100644 --- a/src/Id/Concerns/ManagesUsers.php +++ b/src/Id/Concerns/ManagesUsers.php @@ -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). * diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index 5654568..237cf16 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -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; diff --git a/src/Id/Resources/AppSubscription.php b/src/Id/Resources/AppSubscription.php index 351f120..f4f58aa 100644 --- a/src/Id/Resources/AppSubscription.php +++ b/src/Id/Resources/AppSubscription.php @@ -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, + ]); } /** diff --git a/tests/Id/AppSubscriptionsTest.php b/tests/Id/AppSubscriptionsTest.php index 53e41ef..8003bc9 100644 --- a/tests/Id/AppSubscriptionsTest.php +++ b/tests/Id/AppSubscriptionsTest.php @@ -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']); diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php index 8704144..7bd81bb 100644 --- a/tests/Id/EndpointsTest.php +++ b/tests/Id/EndpointsTest.php @@ -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]]);