mirror of
https://github.com/anikeen-com/id.git
synced 2026-10-31 20:59:32 +00:00
App subscriptions: manage a user's subscriptions with the app's own token
- $user->appSubscriptions(): create (idempotency_key), find, all, checkout, revoke, pause, resume, update against /v1/client/subscriptions with a client credentials token, so apps don't depend on the user's login - user id from the billable's anikeen_id (AnikeenId::useUserIdField()) - ApiException with status, stable error code, isNotFound/isConflict/isTemporary; network errors no longer escape as raw Guzzle exceptions - rejected app tokens are renewed once; asApp() never changes the shared client's token - tests (testbench + Guzzle mock) and CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c3f9344a0
commit
8de737641e
13 files changed
+715
-23
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php: ['8.3', '8.4']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
coverage: none
|
||||||
|
- run: composer update --no-interaction --prefer-dist --no-progress
|
||||||
|
- run: vendor/bin/phpunit
|
||||||
@@ -93,6 +93,55 @@ class User extends Authenticatable
|
|||||||
|
|
||||||
then, you can use the `Billable` trait methods in your user model.
|
then, you can use the `Billable` trait methods in your user model.
|
||||||
|
|
||||||
|
### App subscriptions (trusted apps, no user token)
|
||||||
|
|
||||||
|
`subscriptions()` acts with the user's own access token and stops working when the user's login
|
||||||
|
expired. Trusted apps can manage a user's subscriptions with the app's own token (client
|
||||||
|
credentials, scope `subscriptions:client`) instead:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$subscriptions = $team->owner->appSubscriptions(); // nothing is requested yet
|
||||||
|
|
||||||
|
$subscription = $subscriptions->create([
|
||||||
|
'group' => $team->id,
|
||||||
|
'name' => 'Cloud Server S',
|
||||||
|
'unit' => 'hour',
|
||||||
|
'price' => 0.0137,
|
||||||
|
'idempotency_key' => "server:{$pendingServer->id}", // retries return the same subscription
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subscription->checkout(); // bill the first period
|
||||||
|
$subscription->pause(); // expires when the paid period ends
|
||||||
|
$subscription->resume(); // expired subscriptions start a new period
|
||||||
|
$subscription->revoke(refund: false); // cancel
|
||||||
|
|
||||||
|
$subscriptions->find($id); // null if unknown or not this user's
|
||||||
|
$subscriptions->all(['group' => $team->id, 'active' => true]);
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
try {
|
||||||
|
$subscription->revoke();
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
if ($e->isNotFound() || $e->error === 'already_cancelled') {
|
||||||
|
// already gone
|
||||||
|
} elseif ($e->isTemporary()) {
|
||||||
|
// retry later
|
||||||
|
} else {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Change the default access token / refresh token field name
|
### Change the default access token / refresh token field name
|
||||||
|
|
||||||
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||||
|
|||||||
+3
-2
@@ -22,8 +22,9 @@
|
|||||||
"firebase/php-jwt": "^7.0"
|
"firebase/php-jwt": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^8.0|^9.0",
|
"phpunit/phpunit": "^11.0|^12.0",
|
||||||
"laravel/framework": "^10.0|^11.0|^12.0|^13.0"
|
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"orchestra/testbench": "^9.0|^10.0|^11.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
+8
-21
@@ -1,29 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<phpunit bootstrap="vendor/autoload.php"
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
backupGlobals="false"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
backupStaticAttributes="false"
|
bootstrap="vendor/autoload.php"
|
||||||
colors="true"
|
colors="true">
|
||||||
verbose="true"
|
|
||||||
convertErrorsToExceptions="true"
|
|
||||||
convertNoticesToExceptions="true"
|
|
||||||
convertWarningsToExceptions="true"
|
|
||||||
processIsolation="false"
|
|
||||||
stopOnFailure="false"
|
|
||||||
printerClass="\Codedungeon\PHPUnitPrettyResultPrinter\Printer">
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
<directory>tests</directory>
|
<directory>tests</directory>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<filter>
|
<source>
|
||||||
<whitelist processUncoveredFilesFromWhitelist="true">
|
<include>
|
||||||
<directory suffix=".php">src</directory>
|
<directory suffix=".php">src</directory>
|
||||||
</whitelist>
|
</include>
|
||||||
</filter>
|
</source>
|
||||||
<php>
|
|
||||||
<env name="ANIKEEN_ID_KEY" value="38"/>
|
|
||||||
<env name="ANIKEEN_ID_SECRET" value="2goNRF8x37HPVZVaa28ySZGVXJuksvxnxM7TcGzM"/>
|
|
||||||
<env name="ANIKEEN_ID_BASE_URL" value="https://id.anikeen.com"/>
|
|
||||||
<env name="CLIENT_ACCESS_TOKEN" value="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxNSIsImp0aSI6IjAxN2QxZDg0Y2MxNjAyZTYxOGZkNjYwZGViZWVjMDY4MTk2YmYzMDk1OGMzY2RiYzBjZmJkZWFjZjFhOTUxODQzZDU1YTk3OGY2YWIxY2YzIiwiaWF0IjoxNjY0NjIzMzA4LjQyODI4MSwibmJmIjoxNjY0NjIzMzA4LjQyODI4NCwiZXhwIjoxNjgwMzQ4MTA4LjQxNjc4MSwic3ViIjoiMzkiLCJzY29wZXMiOlsiYXBpIiwicmVhZF91c2VyIl0sImNsaWVudCI6eyJ0cnVzdGVkIjpmYWxzZX19.vxnzCaU4PpOrNVHa5AnGSS6gX_RCvxIERAnHFhjTrUzRafV9mr2Cvwd-BDVYoUr10wHvxa_TJSYfnAdDuhE-MEyDv13O3dL2XGTtJNa_Rg6L6CQ0JvC3wL-lWPvGPFax9pu-_lqbA3jm5B08hc3-7tq3f2nXcxjhtkqT6TTJv1-RCAppb2HCXiUDAqANzbhyInDjOH2WvFj1OGC_AI03J3W2KRWyeFLtUne8XKPFyr9XGcPuTrqogcuuXLeUt2kcf2bXbuIV1OlgIECrDiP1Ee0F2AzPs27ZVJ2z0R0JbT6AubKhGl5_Qi27cwjOH7hT2dmjcF1mLjzpN1uChLIdSnGSoStH8VzYHnHE2I8G-owW_aadG2UmGdnRY143q6g_28f3WIZNSucBSXkwFeS_t4fylsvpxhpjYJusf5qiEU_X3YbeawYMUCFUkSD2XTIypAqMJLNZQAeJ52eyL-9fln-Bv7n9v7K9G6ieR6Tm0tsJ1PRnaQi7rA1NTFwHoQmIOW9tfMycLzT7bgSoz3ra6Ez2J7ZNuWBZNKS0O-0YfSrAWyWK5U8YRfQuSVzP2VrIU63K6RGU2c284PfQGy11kgKUNQPykirb8p7MDQ8PwrxKaylBnD6hhDgjqEh2bfsr_43DfJA0R58L1HK3BmQnxgap0C77wK1e0yNlABpN28Q"/>
|
|
||||||
</php>
|
|
||||||
</phpunit>
|
</phpunit>
|
||||||
@@ -58,6 +58,8 @@ class AnikeenId
|
|||||||
*/
|
*/
|
||||||
private static string $refreshTokenField = 'anikeen_id_refresh_token';
|
private static string $refreshTokenField = 'anikeen_id_refresh_token';
|
||||||
|
|
||||||
|
private static string $userIdField = 'anikeen_id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guzzle is used to make http requests.
|
* Guzzle is used to make http requests.
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +145,41 @@ class AnikeenId
|
|||||||
return self::$accessTokenField;
|
return self::$accessTokenField;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute of the billable model that holds the user's Anikeen ID (used by app requests,
|
||||||
|
* e.g. appSubscriptions(), which act on behalf of a user without the user's token).
|
||||||
|
*/
|
||||||
|
public static function useUserIdField(string $userIdField): void
|
||||||
|
{
|
||||||
|
self::$userIdField = $userIdField;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getUserIdField(): string
|
||||||
|
{
|
||||||
|
return self::$userIdField;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy that uses the app's own access token (client credentials) instead of a user's token.
|
||||||
|
* The shared instance keeps its token, so app rights never leak into other requests.
|
||||||
|
*
|
||||||
|
* @throws Exceptions\RequestFreshAccessTokenException
|
||||||
|
*/
|
||||||
|
public function asApp(): static
|
||||||
|
{
|
||||||
|
return (clone $this)->withToken(app(Contracts\AppTokenRepository::class)->getAccessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the HTTP client, e.g. with one using a Guzzle MockHandler in tests.
|
||||||
|
*/
|
||||||
|
public function setHttpClient(Client $client): static
|
||||||
|
{
|
||||||
|
$this->client = $client;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getMode(): string
|
public static function getMode(): string
|
||||||
{
|
{
|
||||||
return config('services.anikeen.mode') ?: 'production';
|
return config('services.anikeen.mode') ?: 'production';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Anikeen\Id\Concerns;
|
namespace Anikeen\Id\Concerns;
|
||||||
|
|
||||||
|
use Anikeen\Id\Resources\AppSubscriptions;
|
||||||
use Anikeen\Id\Resources\Subscriptions;
|
use Anikeen\Id\Resources\Subscriptions;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -24,4 +25,14 @@ trait ManagesSubscriptions
|
|||||||
|
|
||||||
return $this->subscriptionsCache;
|
return $this->subscriptionsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's subscriptions, managed with the app's own token instead of the user's
|
||||||
|
* (trusted apps only, scope subscriptions:client). Use this in jobs and anywhere the user's
|
||||||
|
* login may have expired.
|
||||||
|
*/
|
||||||
|
public function appSubscriptions(): AppSubscriptions
|
||||||
|
{
|
||||||
|
return new AppSubscriptions($this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Exceptions;
|
||||||
|
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request to the ID service failed. `status` is the HTTP status (0 when the service could not
|
||||||
|
* be reached), `error` the service's stable error code when it sent one (e.g. "already_cancelled").
|
||||||
|
*/
|
||||||
|
class ApiException extends ResourceException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
public readonly int $status = 0,
|
||||||
|
public readonly ?string $error = null,
|
||||||
|
?Throwable $previous = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $status, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNotFound(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConflict(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 409;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
|
||||||
|
*/
|
||||||
|
public function isTemporary(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 0 || $this->status >= 500 || $this->status === 429;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,14 @@ class AppTokenRepository implements Repository
|
|||||||
return $this->requestFreshAccessToken('*');
|
return $this->requestFreshAccessToken('*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the cached token, e.g. after the ID service rejected it.
|
||||||
|
*/
|
||||||
|
public function forgetAccessToken(): void
|
||||||
|
{
|
||||||
|
Cache::forget(self::ACCESS_TOKEN_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws RequestFreshAccessTokenException
|
* @throws RequestFreshAccessTokenException
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Resources;
|
||||||
|
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Support\AppRequest;
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
|
||||||
|
*
|
||||||
|
* @property string $id
|
||||||
|
* @property string $user_id
|
||||||
|
* @property ?string $group
|
||||||
|
* @property string $name
|
||||||
|
* @property ?string $description
|
||||||
|
* @property string $unit
|
||||||
|
* @property float $price
|
||||||
|
* @property ?float $vat_rate
|
||||||
|
* @property bool $active
|
||||||
|
* @property bool $autorenew
|
||||||
|
* @property bool $cancelled
|
||||||
|
* @property ?string $status "active" or "inactive"
|
||||||
|
* @property ?string $starts_at
|
||||||
|
* @property ?string $ends_at
|
||||||
|
* @property ?string $accounting_at
|
||||||
|
* @property bool $is_past_due
|
||||||
|
* @property int $failed_renewal_retries
|
||||||
|
* @property ?array $payload
|
||||||
|
* @property ?string $idempotency_key
|
||||||
|
*/
|
||||||
|
class AppSubscription 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});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a subscription that wasn't checked out yet.
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function update(array $attributes): self
|
||||||
|
{
|
||||||
|
return $this->send('PUT', '', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the subscription and bill its first period.
|
||||||
|
*
|
||||||
|
* @throws ApiException 409 "already_active", "billing_address_missing", …
|
||||||
|
*/
|
||||||
|
public function checkout(): self
|
||||||
|
{
|
||||||
|
return $this->send('PUT', '/checkout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
|
||||||
|
* charged usage is credited.
|
||||||
|
*
|
||||||
|
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
|
||||||
|
*/
|
||||||
|
public function revoke(bool $refund = false): self
|
||||||
|
{
|
||||||
|
return $this->send('PUT', '/revoke', ['refund' => $refund]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop renewing; the subscription expires when its paid period ends.
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function pause(): self
|
||||||
|
{
|
||||||
|
return $this->send('PUT', '/pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew again (an expired subscription starts a new period).
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function resume(): self
|
||||||
|
{
|
||||||
|
return $this->send('PUT', '/resume');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function refresh(): self
|
||||||
|
{
|
||||||
|
return $this->send('GET', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return (array) $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return $this->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
private function send(string $method, string $action, ?array $payload = null): self
|
||||||
|
{
|
||||||
|
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Resources;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Contracts\Billable;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Support\AppRequest;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions of a user, managed with the app's own token (client credentials, scope
|
||||||
|
* `subscriptions:client`) instead of the user's token, so they keep working when the user's
|
||||||
|
* login expired. The app has to be trusted by the ID service. Nothing is requested until a
|
||||||
|
* method is called.
|
||||||
|
*/
|
||||||
|
class AppSubscriptions
|
||||||
|
{
|
||||||
|
public function __construct(protected Billable|Model $billable)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's Anikeen ID (see AnikeenId::useUserIdField()).
|
||||||
|
*/
|
||||||
|
public function userId(): string
|
||||||
|
{
|
||||||
|
$userId = $this->billable->{AnikeenId::getUserIdField()} ?? null;
|
||||||
|
|
||||||
|
if (empty($userId)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('The billable has no Anikeen ID in [%s].', AnikeenId::getUserIdField()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a subscription for the user (it is billed only after checkout()).
|
||||||
|
*
|
||||||
|
* @param array{
|
||||||
|
* group?: null|string,
|
||||||
|
* name: string,
|
||||||
|
* description?: null|string,
|
||||||
|
* unit: string,
|
||||||
|
* price: float|string,
|
||||||
|
* vat_rate?: float,
|
||||||
|
* payload?: null|array,
|
||||||
|
* ends_at?: null|string,
|
||||||
|
* webhook_url?: null|string,
|
||||||
|
* webhook_secret?: null|string,
|
||||||
|
* idempotency_key?: null|string
|
||||||
|
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
|
||||||
|
* subscription created by the first one instead of a duplicate.
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function create(array $attributes): AppSubscription
|
||||||
|
{
|
||||||
|
return new AppSubscription(AppRequest::send('POST', 'v1/client/subscriptions', [
|
||||||
|
...$attributes,
|
||||||
|
'user_id' => $this->userId(),
|
||||||
|
])->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find one of the user's subscriptions; null when it doesn't exist (or belongs to someone else).
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function find(string $id): ?AppSubscription
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$subscription = new AppSubscription(AppRequest::send('GET', sprintf('v1/client/subscriptions/%s', $id))->data);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
if ($e->isNotFound()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $subscription->user_id === $this->userId() ? $subscription : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All of the user's subscriptions, optionally filtered.
|
||||||
|
*
|
||||||
|
* @param array{group?: string, active?: bool, cancelled?: bool} $filters
|
||||||
|
* @return list<AppSubscription>
|
||||||
|
*
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public function all(array $filters = []): array
|
||||||
|
{
|
||||||
|
$subscriptions = [];
|
||||||
|
$page = 1;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$result = AppRequest::send('GET', 'v1/client/subscriptions', null, [
|
||||||
|
...array_map(fn ($value) => is_bool($value) ? (int) $value : $value, $filters),
|
||||||
|
'user_id' => $this->userId(),
|
||||||
|
'per_page' => 100,
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($result->data as $data) {
|
||||||
|
$subscriptions[] = new AppSubscription($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastPage = (int) ($result->meta->last_page ?? $page);
|
||||||
|
} while ($page++ < $lastPage);
|
||||||
|
|
||||||
|
return $subscriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Support;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Contracts\AppTokenRepository;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Result;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a request with the app's own token (client credentials). A rejected token is renewed
|
||||||
|
* once; every failure ends in an ApiException with status and error code.
|
||||||
|
*/
|
||||||
|
final class AppRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result
|
||||||
|
{
|
||||||
|
$result = self::attempt($method, $path, $payload, $query);
|
||||||
|
|
||||||
|
if ($result->status === 401) {
|
||||||
|
$tokens = app(AppTokenRepository::class);
|
||||||
|
|
||||||
|
if (method_exists($tokens, 'forgetAccessToken')) {
|
||||||
|
$tokens->forgetAccessToken();
|
||||||
|
$result = self::attempt($method, $path, $payload, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$result->success()) {
|
||||||
|
$body = is_object($result->data) ? $result->data : null;
|
||||||
|
|
||||||
|
throw new ApiException(
|
||||||
|
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
|
||||||
|
$result->response()?->getStatusCode() ?? 0,
|
||||||
|
$body->error ?? null,
|
||||||
|
$result->exception,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
private static function attempt(string $method, string $path, ?array $payload, array $query): Result
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (GuzzleException|Throwable $e) {
|
||||||
|
// network errors / timeouts (ConnectException isn't a RequestException) and token errors
|
||||||
|
throw new ApiException(sprintf('Anikeen ID is not reachable: %s', $e->getMessage()), 0, null, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Billable;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use GuzzleHttp\Exception\ConnectException;
|
||||||
|
use GuzzleHttp\Psr7\Request;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class AppSubscriptionsTest extends TestCase
|
||||||
|
{
|
||||||
|
private function owner(?string $anikeenId = '01JUSER'): Model
|
||||||
|
{
|
||||||
|
$owner = new class extends Model {
|
||||||
|
use Billable;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
};
|
||||||
|
$owner->anikeen_id = $anikeenId;
|
||||||
|
$owner->anikeen_id_access_token = 'user-token';
|
||||||
|
|
||||||
|
return $owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subscription(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return ['data' => ['id' => '01JSUB', 'user_id' => '01JUSER', 'name' => 'Server', 'active' => false, 'cancelled' => false, ...$overrides]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_sends_the_user_id_with_the_app_token(): void
|
||||||
|
{
|
||||||
|
$this->respond(201, $this->subscription());
|
||||||
|
|
||||||
|
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1']);
|
||||||
|
|
||||||
|
$request = $this->sent[0]['request'];
|
||||||
|
$this->assertSame('POST', $request->getMethod());
|
||||||
|
$this->assertSame('/api/v1/client/subscriptions', $request->getUri()->getPath());
|
||||||
|
$this->assertSame('Bearer app-token', $request->getHeaderLine('Authorization'));
|
||||||
|
$this->assertSame(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1', 'user_id' => '01JUSER'], $this->sentJson(0));
|
||||||
|
$this->assertSame('01JSUB', $subscription->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nothing_is_requested_until_a_method_is_called(): void
|
||||||
|
{
|
||||||
|
$this->owner()->appSubscriptions();
|
||||||
|
|
||||||
|
$this->assertCount(0, $this->sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_actions_use_the_app_endpoints(): void
|
||||||
|
{
|
||||||
|
$this->respond(201, $this->subscription());
|
||||||
|
$this->respond(200, $this->subscription(['active' => true]));
|
||||||
|
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => false]));
|
||||||
|
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true]));
|
||||||
|
$this->respond(200, $this->subscription(['cancelled' => true]));
|
||||||
|
|
||||||
|
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
||||||
|
|
||||||
|
$this->assertTrue($subscription->checkout()->active);
|
||||||
|
$this->assertFalse($subscription->pause()->autorenew);
|
||||||
|
$this->assertTrue($subscription->resume()->autorenew);
|
||||||
|
$this->assertTrue($subscription->revoke(refund: true)->cancelled);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'POST /api/v1/client/subscriptions',
|
||||||
|
'PUT /api/v1/client/subscriptions/01JSUB/checkout',
|
||||||
|
'PUT /api/v1/client/subscriptions/01JSUB/pause',
|
||||||
|
'PUT /api/v1/client/subscriptions/01JSUB/resume',
|
||||||
|
'PUT /api/v1/client/subscriptions/01JSUB/revoke',
|
||||||
|
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath(), $this->sent));
|
||||||
|
$this->assertSame(['refund' => true], $this->sentJson(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_errors_carry_status_and_error_code(): void
|
||||||
|
{
|
||||||
|
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
||||||
|
$this->fail('Expected an ApiException.');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertSame(409, $e->status);
|
||||||
|
$this->assertSame('already_cancelled', $e->error);
|
||||||
|
$this->assertTrue($e->isConflict());
|
||||||
|
$this->assertFalse($e->isTemporary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_returns_null_for_unknown_subscriptions_and_other_users(): void
|
||||||
|
{
|
||||||
|
$this->respond(404, ['message' => 'Subscription not found.', 'error' => 'subscription_not_found']);
|
||||||
|
$this->respond(200, $this->subscription(['user_id' => '01JSOMEONEELSE']));
|
||||||
|
$this->respond(200, $this->subscription());
|
||||||
|
|
||||||
|
$subscriptions = $this->owner()->appSubscriptions();
|
||||||
|
|
||||||
|
$this->assertNull($subscriptions->find('01JUNKNOWN'));
|
||||||
|
$this->assertNull($subscriptions->find('01JSUB'));
|
||||||
|
$this->assertSame('01JSUB', $subscriptions->find('01JSUB')->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_follows_pages_and_filters_by_user(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, ['data' => [['id' => 'a', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 1, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
|
||||||
|
$this->respond(200, ['data' => [['id' => 'b', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 2, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
|
||||||
|
|
||||||
|
$all = $this->owner()->appSubscriptions()->all(['group' => 'team-1', 'active' => true]);
|
||||||
|
|
||||||
|
$this->assertSame(['a', 'b'], array_map(fn ($s) => $s->id, $all));
|
||||||
|
parse_str($this->sent[0]['request']->getUri()->getQuery(), $query);
|
||||||
|
$this->assertSame(['group' => 'team-1', 'active' => '1', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_rejected_app_token_is_renewed_once(): void
|
||||||
|
{
|
||||||
|
$this->respond(401, ['message' => 'Unauthenticated.']);
|
||||||
|
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->owner()->appSubscriptions()->all());
|
||||||
|
|
||||||
|
$this->assertSame('Bearer app-token', $this->sent[0]['request']->getHeaderLine('Authorization'));
|
||||||
|
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unreachable_service_is_a_temporary_api_exception(): void
|
||||||
|
{
|
||||||
|
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/client/subscriptions')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->appSubscriptions()->all();
|
||||||
|
$this->fail('Expected an ApiException.');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertSame(0, $e->status);
|
||||||
|
$this->assertTrue($e->isTemporary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_shared_client_keeps_the_users_token(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
|
||||||
|
app(AnikeenId::class)->withToken('user-token');
|
||||||
|
|
||||||
|
$this->owner()->appSubscriptions()->all();
|
||||||
|
|
||||||
|
$this->assertSame('user-token', app(AnikeenId::class)->getToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_user_id_field_is_configurable(): void
|
||||||
|
{
|
||||||
|
AnikeenId::useUserIdField('id_service_user');
|
||||||
|
$owner = $this->owner(null);
|
||||||
|
$owner->id_service_user = '01JOTHER';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->assertSame('01JOTHER', $owner->appSubscriptions()->userId());
|
||||||
|
} finally {
|
||||||
|
AnikeenId::useUserIdField('anikeen_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_missing_user_id_is_reported(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$this->owner(null)->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Contracts\AppTokenRepository;
|
||||||
|
use Anikeen\Id\Providers\AnikeenIdServiceProvider;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Handler\MockHandler;
|
||||||
|
use GuzzleHttp\HandlerStack;
|
||||||
|
use GuzzleHttp\Middleware;
|
||||||
|
use GuzzleHttp\Psr7\Response;
|
||||||
|
use Orchestra\Testbench\TestCase as Orchestra;
|
||||||
|
|
||||||
|
abstract class TestCase extends Orchestra
|
||||||
|
{
|
||||||
|
protected MockHandler $http;
|
||||||
|
|
||||||
|
/** @var list<array{request: \Psr\Http\Message\RequestInterface}> */
|
||||||
|
protected array $sent = [];
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [AnikeenIdServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['services.anikeen.client_id' => 'app-client', 'services.anikeen.client_secret' => 'secret']);
|
||||||
|
|
||||||
|
$this->http = new MockHandler();
|
||||||
|
$stack = HandlerStack::create($this->http);
|
||||||
|
$stack->push(Middleware::history($this->sent));
|
||||||
|
|
||||||
|
$this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new Client(['handler' => $stack, 'base_uri' => 'https://id.test/api/'])));
|
||||||
|
$this->app->instance(AppTokenRepository::class, new class implements AppTokenRepository {
|
||||||
|
public int $forgotten = 0;
|
||||||
|
|
||||||
|
public function getAccessToken(): string
|
||||||
|
{
|
||||||
|
return $this->forgotten ? 'fresh-app-token' : 'app-token';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgetAccessToken(): void
|
||||||
|
{
|
||||||
|
$this->forgotten++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function respond(int $status, array $body = []): void
|
||||||
|
{
|
||||||
|
$this->http->append(new Response($status, ['Content-Type' => 'application/json'], json_encode($body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sentJson(int $index): array
|
||||||
|
{
|
||||||
|
return json_decode((string) $this->sent[$index]['request']->getBody(), true) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user