Compare commits

...
Author SHA1 Message Date
Maurice PreußandClaude Opus 5.5 c4a42b4ddc Check out a running service from its start
AppSubscription::checkout() takes an optional $startsAt (needs id-core
with starts_at support on PUT v1/client/subscriptions/{id}/checkout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:03:47 +01:00
envoyr a1707e9189 Merge pull request #3 from anikeen-com/app-subscriptions
App orders: read the orders that bill the app's subscriptions
2026-10-25 18:18:57 +01:00
Maurice PreußandClaude Opus 5.5 820f1b37cc Read the orders that bill the app's subscriptions
$user->appOrders() and app(AnikeenId::class)->appOrders() read
v1/client/orders with the app's own token (scope orders:client):
all(), pending() and find(), paginated transparently, user scoped
when called on a billable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:14:40 +01:00
envoyr 9a6f40ad99 Merge pull request #2 from anikeen-com/app-subscriptions
Bring anikeen/id up to date: app subscriptions, fixed and missing endpoints, Laravel 11-13
2026-10-25 17:25:22 +01:00
Maurice PreußandClaude Opus 5.5 9613d31a8d Bring the package up to date with the Anikeen ID API
Fixed (called endpoints that don't exist):
- charge(): POST v1/billing/charge (Stripe options aren't passed through anymore)
- Invoice::getInvoiceTemporaryUrl(): GET instead of a non-existent PUT
- OrderItems::create(): POST v1/orders/{order}/items
- createUser() / isEmailExisting(): admin endpoints with the app token
- sshKeysByUserId(): app endpoint; no longer returns the first user's keys for every user
- createOrderPreview(): country_iso goes into billing_address as the API expects
- public endpoints (products, order preview) work without a token
- SshKeys::find() results can be deleted

Added:
- Billable: sshKeys() (create, update, delete), billingOverview(), PaymentMethod::delete()
- AnikeenId: products(), boxes(), shipping labels (list, show, pdf, issue, ship, price list),
  createOrderForUser(), createTransactionForUser(), findOrCreateSocialiteUser()

Requirements: PHP 8.2+, Laravel 11-13 (Laravel 10 and PHP 8.1 are end of life).
Docs generator handles union types; README regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:18:05 +01:00
Maurice PreußandClaude Opus 5.5 8de737641e 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>
2026-10-25 17:02:16 +01:00
René PreußandClaude Sonnet 4.6 9c3f9344a0 Add ManagesSocialiteUsers concern and resources for listing third-party connections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:47:12 +02:00
Maurice Preuß a7ec7aa908 add set default payment method
Signed-off-by: Maurice Preuß <envoyr@froxlor.org>
2026-05-17 16:46:06 +02:00
envoyr 4555efa80c Update Laravel version requirements in composer.json
Updated Laravel version requirements to include 13.0.
2026-05-12 17:29:14 +02:00
Maurice Preuß 4f78e32af1 update composer.json
Signed-off-by: Maurice Preuß <envoyr@froxlor.org>
2026-05-08 16:27:18 +02:00
37 changed files with 1685 additions and 67 deletions

No files matched your search

+22
View File
@@ -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
+134 -4
View File
@@ -93,6 +93,70 @@ class User extends Authenticatable
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->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$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;
}
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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:
@@ -292,10 +356,42 @@ public function retrievingToken(string $grantType, array $attributes): Result
public function createOrderPreview(array $attributes = []): Result
```
### ManagesProducts
```php
public function products(): Result
public function boxes(): Result
```
### ManagesShippingLabels
```php
public function shippingLabels(array $parameters = []): Result
public function shippingLabel(string $id): Result
public function shippingLabelPdf(string $id): ?string
public function issueShippingLabel(array $attributes): Result
public function shipShippingLabel(array $hex): Result
public function shippingPriceList(): Result
```
### ManagesUserBilling
```php
public function appOrders(): AppOrders
public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
```
### ManagesSocialiteUsers
```php
public function socialiteUsers(): SocialiteUsers
```
### ManagesSshKeys
```php
public function sshKeysByUserId(string $sskKeyId): SshKeys
public function sshKeysByUserId(string $userId): SshKeys
```
### ManagesUsers
@@ -303,8 +399,10 @@ public function sshKeysByUserId(string $sskKeyId): SshKeys
```php
public function getAuthedUser(): Result
public function createUser(array $attributes): 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
public function setParent(?mixed $parent): self
public function setParent(mixed $parent): self
public function getParent()
```
@@ -316,6 +414,8 @@ public function getParent()
```php
public function addresses(): Addresses
public function hasDefaultBillingAddress(): bool
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesBalance
@@ -323,25 +423,34 @@ public function hasDefaultBillingAddress(): bool
```php
public function balance(): float
public function charges(): float
public function charge(float $amount, string $paymentMethodId, array $options = []): Transaction
public function billingOverview(): object
public function charge(string|float $amount, string $paymentMethodId, array $options = []): Transaction
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesCountries
```php
public function countries(): Countries
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesInvoices
```php
public function invoices(array $parameters = []): Invoices
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesOrders
```php
public function orders(array $parameters = []): Orders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesPaymentMethods
@@ -351,32 +460,53 @@ public function paymentMethods(): PaymentMethods
public function hasPaymentMethod(): ?PaymentMethod
public function defaultPaymentMethod(): ?PaymentMethod
public function hasDefaultPaymentMethod(): bool
public function setDefaultPaymentMethod(string $id): PaymentMethod
public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
public function createSetupIntent(array $options = []): Result
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesProfile
```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesSubscriptions
```php
public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions
public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesTaxation
```php
public function vat(): float
public function vatRate(): float
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesTransactions
```php
public function transactions(): Transactions
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
### ManagesUserSshKeys
```php
public function sshKeys(): SshKeys
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
+64
View File
@@ -93,6 +93,70 @@ class User extends Authenticatable
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->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$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;
}
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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:
+12 -11
View File
@@ -1,29 +1,30 @@
{
"name": "anikeen/id",
"description": "PHP AnikeenId API Client for Laravel 10+",
"description": "PHP Anikeen ID API client for Laravel 11+",
"license": "MIT",
"authors": [
{
"name": "René Preuß",
"name": "Ren\u00e9 Preu\u00df",
"email": "rene@anikeen.com"
},
{
"name": "Maurice Preuß",
"name": "Maurice Preu\u00df",
"email": "maurice@anikeen.com"
}
],
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/console": "^10.0|^11.0|^12.0",
"guzzlehttp/guzzle": "^6.3|^7.0",
"socialiteproviders/manager": "^3.4|^4.0.1",
"firebase/php-jwt": "^6.0"
"illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0"
},
"require-dev": {
"phpunit/phpunit": "^8.0|^9.0",
"laravel/framework": "^10.0|^11.0|^12.0"
"laravel/framework": "^11.0|^12.0|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"autoload": {
"psr-4": {
+16 -8
View File
@@ -7,6 +7,20 @@ use Anikeen\Id\Billable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
// short type name incl. union, intersection and nullable types
function typeName(ReflectionType $type): string
{
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
$separator = $type instanceof ReflectionUnionType ? '|' : '&';
return collect($type->getTypes())->map(fn (ReflectionType $t) => typeName($t))->implode($separator);
}
$name = Arr::last(explode('\\', $type->getName()));
return ($type->allowsNull() && !in_array($name, ['mixed', 'null'], true) ? '?' : '') . $name;
}
// Liste der Klassen, die ausgewertet werden sollen
$classes = [
AnikeenId::class,
@@ -51,10 +65,7 @@ $allMarkdown = collect($classes)
// Typ-Hint
$typeHint = '';
if ($p->hasType()) {
$type = $p->getType();
$nullable = $type->allowsNull() ? '?' : '';
$name = Arr::last(explode('\\', $type->getName()));
$typeHint = $nullable . $name . ' ';
$typeHint = typeName($p->getType()) . ' ';
}
// Parameter-Name
@@ -84,10 +95,7 @@ $allMarkdown = collect($classes)
// Rückgabetyp, falls vorhanden
if ($method->hasReturnType()) {
$retType = $method->getReturnType();
$nullable = $retType->allowsNull() ? '?' : '';
$typeName = Arr::last(explode('\\', $retType->getName()));
$decl .= ': ' . $nullable . $typeName;
$decl .= ': ' . typeName($method->getReturnType());
}
return $decl;
+8 -21
View File
@@ -1,29 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
printerClass="\Codedungeon\PHPUnitPrettyResultPrinter\Printer">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<source>
<include>
<directory suffix=".php">src</directory>
</whitelist>
</filter>
<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>
</include>
</source>
</phpunit>
+47 -1
View File
@@ -3,7 +3,11 @@
namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels;
use Anikeen\Id\Concerns\ManagesSocialiteUsers;
use Anikeen\Id\Concerns\ManagesSshKeys;
use Anikeen\Id\Concerns\ManagesUserBilling;
use Anikeen\Id\Concerns\ManagesUsers;
use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
@@ -20,6 +24,10 @@ class AnikeenId
{
use OauthTrait;
use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys;
use ManagesUsers;
@@ -56,6 +64,8 @@ class AnikeenId
*/
private static string $refreshTokenField = 'anikeen_id_refresh_token';
private static string $userIdField = 'anikeen_id';
/**
* Guzzle is used to make http requests.
*/
@@ -141,6 +151,41 @@ class AnikeenId
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
{
return config('services.anikeen.mode') ?: 'production';
@@ -359,7 +404,8 @@ class AnikeenId
'Client-ID' => $this->getClientId(),
'Accept' => 'application/json',
];
if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->getToken()) {
// public endpoints (products, order preview, …) work without a token
if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->token) {
$headers['Authorization'] = 'Bearer ' . $bearerToken;
}
if ($json) {
+2
View File
@@ -12,6 +12,7 @@ use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions;
use Anikeen\Id\Concerns\ManagesUserSshKeys;
use Throwable;
trait Billable
@@ -26,6 +27,7 @@ trait Billable
use ManagesSubscriptions;
use ManagesTaxation;
use ManagesTransactions;
use ManagesUserSshKeys;
/**
* Get the currently authenticated user.
+17 -7
View File
@@ -30,20 +30,30 @@ trait ManagesBalance
}
/**
* Charge given amount from bank to current user.
* Billing overview of the current user: current_balance, current_charges, vat_rate,
* has_billing_address, has_default_payment_method, open_orders.
*
* @param float $amount Amount to charge in euros.
* @param string $paymentMethodId Payment method ID.
* @param array $options Additional options for the charge.
* @throws Throwable
*/
public function charge(float $amount, string $paymentMethodId, array $options = []): Transaction
public function billingOverview(): object
{
return $this->anikeenId()->request('GET', 'v1/billing')->data;
}
/**
* Charge given amount (deposit) from one of the current user's payment methods.
*
* @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals).
* @param string $paymentMethodId Stripe payment method ID (pm_…).
* @param array $options Ignored; Stripe options can no longer be passed through.
* @throws Throwable
*/
public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction
{
return (new Transaction(fn() => $this->anikeenId()
->request('POST', 'billing/charge', [
->request('POST', 'v1/billing/charge', [
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
'options' => $options,
])))
->setBillable($this);
}
+13
View File
@@ -60,6 +60,19 @@ trait ManagesPaymentMethods
return $this->paymentMethods()->hasDefaultPaymentMethod();
}
/**
* Set the default payment method for the current user.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::setDefault()
*/
public function setDefaultPaymentMethod(string $id): PaymentMethod
{
unset($this->paymentMethodsCache);
return $this->paymentMethods()->setDefault($id);
}
/**
* Get billing portal URL for the current user.
*
+6
View File
@@ -32,6 +32,12 @@ trait ManagesPricing
*/
public function createOrderPreview(array $attributes = []): Result
{
// the API expects the country as part of the billing address
if (isset($attributes['country_iso']) && !isset($attributes['billing_address'])) {
$attributes['billing_address'] = ['country_iso' => $attributes['country_iso']];
unset($attributes['country_iso']);
}
return $this->post('v1/orders/preview', $attributes);
}
}␍
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
trait ManagesProducts
{
/**
* Public product catalogue.
*
* @throws Throwable
*/
public function products(): Result
{
return $this->request('GET', 'v1/products');
}
/**
* Shipping boxes (for shipping labels).
*
* @throws Throwable
*/
public function boxes(): Result
{
return $this->request('GET', 'v1/boxes');
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
/**
* Shipping labels (Deutsche Post Internetmarke). Trusted apps only, sent with the app's token.
*/
trait ManagesShippingLabels
{
/**
* Latest shipping labels (100 per page).
*
* @throws Throwable
*/
public function shippingLabels(array $parameters = []): Result
{
return $this->asApp()->request('GET', 'v1/admin/shipping-labels', null, $parameters);
}
/**
* @throws Throwable
*/
public function shippingLabel(string $id): Result
{
return $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s', $id));
}
/**
* The label as PDF (binary string), null if there is none.
*
* @throws Throwable
*/
public function shippingLabelPdf(string $id): ?string
{
$result = $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s/pdf', $id));
return $result->success() ? (string) $result->response()->getBody() : null;
}
/**
* Buy a shipping label; the postage is billed to the given user.
*
* @param array{
* box: array,
* weight: int,
* address: array,
* billed_user_id: string,
* test?: bool
* } $attributes
* - box: barcode, id, common_name or size of the box
* - weight: in grams
* - address: recipient address
* - billed_user_id: Anikeen ID of the user who pays the postage
* - test: validate only, no real label is bought
* @throws Throwable
*/
public function issueShippingLabel(array $attributes): Result
{
return $this->asApp()->request('POST', 'v1/admin/shipping-labels/issue', $attributes);
}
/**
* Mark the label with the scanned matrix code as shipped.
*
* @param list<string> $hex Hex bytes of the scanned Internetmarke matrix code
* @throws Throwable
*/
public function shipShippingLabel(array $hex): Result
{
return $this->asApp()->request('POST', 'v1/admin/shipping-labels/ship', ['hex' => $hex]);
}
/**
* Prices of the available franking types.
*
* @throws Throwable
*/
public function shippingPriceList(): Result
{
return $this->asApp()->request('GET', 'v1/admin/shipping-labels/price-list');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\Resources\SocialiteUsers;
use Throwable;
trait ManagesSocialiteUsers
{
use Get;
/**
* Get all socialite connections for the authenticated user.
*
* @throws Throwable
*/
public function socialiteUsers(): SocialiteUsers
{
return SocialiteUsers::builder(fn() => $this->get('v1/user/socialite-users'))
->setParent($this);
}
}
+6 -7
View File
@@ -12,17 +12,16 @@ trait ManagesSshKeys
use Get;
/**
* Get currently authed user with Bearer Token.
* SSH keys of a user, read with the app's own token (trusted apps, scope ssh-keys:read),
* e.g. to install them on a new server.
*
* @throws Throwable
*/
public function sshKeysByUserId(string $sskKeyId): SshKeys
public function sshKeysByUserId(string $userId): SshKeys
{
if (!isset($this->sshKeysCache)) {
$this->sshKeysCache = SshKeys::builder(fn() => $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId)))
->setParent($this);
}
$app = $this->asApp();
return $this->sshKeysCache;
return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
->setParent($app);
}
}
+21
View File
@@ -2,6 +2,8 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
@@ -24,4 +26,23 @@ trait ManagesSubscriptions
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);
}
/**
* The user's orders that bill the app's subscriptions, read with the app's own token
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
*/
public function appOrders(): AppOrders
{
return new AppOrders($this);
}
}␍
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result;
use Throwable;
/**
* Orders and payments on behalf of a user. Trusted apps only, sent with the app's token.
*/
trait ManagesUserBilling
{
/**
* Orders of all users that bill the app's subscriptions (e.g. ->pending() for unpaid ones).
*/
public function appOrders(): AppOrders
{
return new AppOrders();
}
/**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
* default payment method (402 when declined; the order stays pending and is retried).
*
* @param string $userId The user's Anikeen ID.
* @param array{
* group?: string,
* billing_address_id?: string,
* billing_address?: array,
* shipping_address_id?: string,
* shipping_address?: array,
* items: array<array{type: string, name: string, description?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
* checkout?: bool,
* transaction_id?: string,
* transaction?: array,
* invoice_date?: string,
* currency_iso?: string
* } $attributes
* @throws Throwable
*/
public function createOrderForUser(string $userId, array $attributes): Result
{
return $this->asApp()->request('POST', 'v1/admin/orders', [...$attributes, 'user_id' => $userId]);
}
/**
* Book a payment of a user that was captured elsewhere (e.g. PayPal, Ko-fi). The same
* provider/payment_intent can only be booked once (409 with the existing transaction_id).
*
* @param string $userId The user's Anikeen ID.
* @param array{
* payment_provider?: string,
* payment_intent?: string,
* status: string,
* type: string,
* amount: float|string,
* currency_iso: string,
* created_at: string,
* group?: string,
* invoice_id?: string
* } $attributes
* @throws Throwable
*/
public function createTransactionForUser(string $userId, array $attributes): Result
{
return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\SshKeys;
use Throwable;
trait ManagesUserSshKeys
{
use HasBillable;
/**
* SSH keys of the current user (user token).
*
* @throws Throwable
*/
public function sshKeys(): SshKeys
{
$anikeenId = $this->anikeenId();
return SshKeys::builder(fn() => $anikeenId->get('v1/ssh-keys'))->setParent($anikeenId);
}
}
+32 -2
View File
@@ -42,7 +42,36 @@ trait ManagesUsers
*/
public function createUser(array $attributes): Result
{
return $this->post('v1/users', $attributes);
// the API takes a single display name
if (!isset($attributes['name']) && (isset($attributes['first_name']) || isset($attributes['last_name']))) {
$attributes['name'] = trim(($attributes['first_name'] ?? '') . ' ' . ($attributes['last_name'] ?? '')) ?: null;
}
if (isset($attributes['password']) && !isset($attributes['password_confirmation'])) {
$attributes['password_confirmation'] = $attributes['password'];
}
unset($attributes['first_name'], $attributes['last_name']);
// finds the user by email or creates it (trusted apps, app token)
return $this->asApp()->post('v1/admin/users', $attributes);
}
/**
* Find the user of a social login or create it (trusted apps, app token).
*
* @param string $provider The socialite driver, e.g. "twitch".
* @param string $providerId The user's ID at the provider.
* @throws Throwable
*/
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
{
return $this->asApp()->post('v1/admin/users/socialite', array_filter([
'provider' => $provider,
'provider_id' => $providerId,
'email' => $email,
'name' => $name,
], fn ($value) => $value !== null));
}
/**
@@ -67,7 +96,8 @@ trait ManagesUsers
*/
public function isEmailExisting(string $email): Result
{
return $this->post('v1/users/check', [
// `data.exists` tells whether an account uses the email (trusted apps, app token)
return $this->asApp()->post('v1/admin/users/check', [
'email' => $email,
]);
}
+39
View File
@@ -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;
}
}
+8
View File
@@ -32,6 +32,14 @@ class AppTokenRepository implements Repository
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
*/
+88
View File
@@ -0,0 +1,88 @@
<?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;
/**
* Orders that bill the app's subscriptions, read with the app's own token (trusted apps,
* scope orders:client) - of one user ($user->appOrders()) or of all users
* (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called.
*
* Each order is an object with id, user_id, group, status (processing|pending|completed|cancelled),
* is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at,
* payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null).
*/
class AppOrders
{
public function __construct(protected Billable|Model|null $billable = null)
{
}
/**
* All matching orders, oldest first.
*
* @param array{status?: string, group?: string} $filters
* @return list<object>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$orders = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/orders', null, [
...$filters,
...($this->billable ? ['user_id' => $this->userId()] : []),
'per_page' => 100,
'page' => $page,
]);
array_push($orders, ...$result->data);
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $orders;
}
/**
* Closed orders that aren't paid (yet), oldest first.
*
* @return list<object>
*
* @throws ApiException
*/
public function pending(array $filters = []): array
{
return $this->all([...$filters, 'status' => 'pending']);
}
/**
* @throws ApiException
*/
public function find(string $id): ?object
{
try {
$order = AppRequest::send('GET', sprintf('v1/client/orders/%s', $id))->data;
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return !$this->billable || $order->user_id === $this->userId() ? $order : null;
}
private function userId(): string
{
return (new AppSubscriptions($this->billable))->userId();
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
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.
*
* $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", …
*/
public function checkout(DateTimeInterface|string|null $startsAt = null): self
{
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
/**
* 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;
}
}
+116
View File
@@ -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;
}
}
+3 -3
View File
@@ -17,11 +17,11 @@ class Invoice extends BaseResource
*
* @throws Throwable
*/
public function getInvoiceTemporaryUrl(): string
public function getInvoiceTemporaryUrl(): ?string
{
return $this->billable->anikeenId()
->request('PUT', sprintf('v1/invoices/%s', $this->id))
->request('GET', sprintf('v1/invoices/%s', $this->id))
->data
->temporary_url;
->temporary_url ?? null;
}
}␍
+9 -2
View File
@@ -28,12 +28,19 @@ class OrderItems extends BaseCollection
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
*
* Only trusted apps may add items, and only to draft orders. Returns the first created item.
*
* @throws Throwable
*/
public function create(string $orderId, array $attributes = []): OrderItem
{
return (new OrderItem(fn() => $this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s', $orderId), $attributes)))
return (new OrderItem(fn() => tap($this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s/items', $orderId), $attributes), function ($result) {
if (is_array($result->data)) {
$result->data = $result->data[0] ?? null;
}
})))
->setBillable($this->billable)
->setParent($this->parent);
}
+12
View File
@@ -10,4 +10,16 @@ use Anikeen\Id\Concerns\HasBillable;
class PaymentMethod extends BaseResource
{
use HasBillable;
/**
* Remove this payment method from the current user.
*
* @throws \Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/payment-methods/%s', $this->id))
->success();
}
}␍
+14
View File
@@ -45,6 +45,20 @@ class PaymentMethods extends BaseCollection
return $this->defaultPaymentMethodCache;
}
/**
* Set the default payment method for the current user.
*
* @throws Throwable
*/
public function setDefault(string $id): PaymentMethod
{
unset($this->defaultPaymentMethodCache);
return (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/payment-methods/%s/default', $id))))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Anikeen\Id\Resources;
/**
* @property string $id
* @property string $user_id
* @property string $driver
* @property string $uid
* @property string|null $display_name
* @property string|null $avatar
* @property string|null $expires_at
* @property string $created_at
* @property string $updated_at
*/
class SocialiteUser extends BaseResource
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
class SocialiteUsers extends BaseCollection
{
use HasParent;
public function find(string $id): ?SocialiteUser
{
return null;
}
}
+10
View File
@@ -17,6 +17,16 @@ class SshKey extends BaseResource
*
* @throws Throwable
*/
/**
* Rename the SSH key.
*
* @throws Throwable
*/
public function update(string $name): self
{
return (new self(fn() => $this->parent->put(sprintf('v1/ssh-keys/%s', $this->id), ['name' => $name])))->setParent($this->parent);
}
public function delete(): bool
{
return $this->parent->delete(sprintf('v1/ssh-keys/%s', $this->id))->success();
+1 -1
View File
@@ -29,6 +29,6 @@ class SshKeys extends BaseCollection
*/
public function find(string $id): ?SshKey
{
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))));
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))))->setParent($this->parent);
}
}␍
+62
View File
@@ -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);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
return $owner;
}
private function page(array $orders, int $page = 1, int $last = 1): array
{
return ['data' => $orders, 'meta' => ['current_page' => $page, 'last_page' => $last, 'total' => count($orders)], 'links' => ['next' => null]];
}
private function requestQuery(int $index): array
{
parse_str($this->sent[$index]['request']->getUri()->getQuery(), $query);
return $query;
}
public function test_pending_orders_of_a_user(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => '01JUSER', 'status' => 'pending', 'gross_total' => 11.9, 'next_payment_attempt_at' => null, 'payment_attempts' => 4]]));
$orders = $this->owner()->appOrders()->pending();
$this->assertSame('o1', $orders[0]->id);
$this->assertSame(4, $orders[0]->payment_attempts);
$request = $this->sent[0]['request'];
$this->assertSame('GET /api/v1/client/orders Bearer app-token', $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'));
$this->assertSame(['status' => 'pending', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $this->requestQuery(0));
}
public function test_pending_orders_of_all_users_follow_pages(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => 'a']], 1, 2));
$this->respond(200, $this->page([['id' => 'o2', 'user_id' => 'b']], 2, 2));
$orders = app(AnikeenId::class)->appOrders()->pending(['group' => 'team-1']);
$this->assertSame(['o1', 'o2'], array_map(fn ($o) => $o->id, $orders));
$this->assertSame(['group' => 'team-1', 'status' => 'pending', 'per_page' => '100', 'page' => '2'], $this->requestQuery(1));
}
public function test_find_returns_null_for_unknown_and_other_users_orders(): void
{
$this->respond(404, ['message' => 'Order not found.', 'error' => 'order_not_found']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$orders = $this->owner()->appOrders();
$this->assertNull($orders->find('unknown'));
$this->assertNull($orders->find('o1'));
$this->assertSame('o1', $orders->find('o1')->id);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?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_a_running_service_is_checked_out_from_its_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
$subscription->checkout(new \DateTimeImmutable('2026-05-19 11:33:58', new \DateTimeZone('UTC')));
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
}
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]);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
/**
* Every package method has to call an endpoint that exists in Anikeen ID, with the right token.
*/
class EndpointsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function list(array $items = []): array
{
return ['data' => $items, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => count($items)], 'links' => ['next' => null]];
}
public function test_charge_uses_the_versioned_endpoint_without_stripe_options(): void
{
$this->respond(201, ['data' => ['id' => 'tx1', 'status' => 'succeeded']]);
$this->owner()->charge(10, 'pm_1', ['transfer_data' => ['destination' => 'acct_x']]);
$this->assertSame('POST /api/v1/billing/charge Bearer user-token', $this->sentCall(0));
$this->assertSame(['amount' => 10, 'payment_method_id' => 'pm_1'], $this->sentJson(0));
}
public function test_billing_overview(): void
{
$this->respond(200, ['data' => ['current_charges' => 11.9, 'has_default_payment_method' => true]]);
$this->assertSame(11.9, $this->owner()->billingOverview()->current_charges);
$this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0));
}
public function test_invoice_download_url_is_read_not_updated(): void
{
$this->respond(200, $this->list([['id' => 'inv1']]));
$this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]);
$this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]);
$url = $this->owner()->invoices()->find('inv1')->getInvoiceTemporaryUrl();
$this->assertSame('https://s3/inv1.pdf', $url);
$this->assertSame('GET /api/v1/invoices/inv1 Bearer user-token', $this->sentCall(2));
}
public function test_order_items_are_created_on_the_items_endpoint(): void
{
$this->respond(200, $this->list()); // GET v1/orders
$this->respond(200, ['data' => ['id' => 'order1', 'status' => 'draft']]); // GET v1/orders/order1
$this->respond(200, $this->list()); // GET v1/orders/order1/items
$this->respond(201, ['data' => [['id' => 'item1', 'name' => 'Domain']]]); // POST v1/orders/order1/items
$created = $this->owner()->orders()->find('order1')->orderItems()
->create('order1', ['items' => [['type' => 'digital', 'name' => 'Domain', 'price' => 10, 'unit' => 'year', 'units' => 1]]]);
$this->assertSame('item1', $created->id);
$this->assertSame('POST /api/v1/orders/order1/items Bearer user-token', $this->sentCall(3));
}
public function test_create_user_uses_the_app_token_and_admin_endpoint(): void
{
$this->respond(200, ['data' => ['id' => '01JNEW']]);
app(AnikeenId::class)->createUser(['first_name' => 'Erika', 'last_name' => 'Mustermann', 'email' => 'erika@example.com', 'password' => 'secret-123']);
$this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0));
$this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0));
}
public function test_email_check_and_socialite_users(): void
{
$this->respond(200, ['data' => ['exists' => true]]);
$this->respond(200, ['data' => ['id' => '01JUSER']]);
$this->assertTrue(app(AnikeenId::class)->isEmailExisting('erika@example.com')->data->exists);
app(AnikeenId::class)->findOrCreateSocialiteUser('twitch', '123', 'streamer@example.com');
$this->assertSame('POST /api/v1/admin/users/check Bearer app-token', $this->sentCall(0));
$this->assertSame('POST /api/v1/admin/users/socialite Bearer app-token', $this->sentCall(1));
$this->assertSame(['provider' => 'twitch', 'provider_id' => '123', 'email' => 'streamer@example.com'], $this->sentJson(1));
}
public function test_ssh_keys_of_a_user_for_apps_are_not_cached_across_users(): void
{
$this->respond(200, ['data' => [['id' => 'k1', 'name' => 'A']]]);
$this->respond(200, ['data' => [['id' => 'k2', 'name' => 'B']]]);
$first = app(AnikeenId::class)->sshKeysByUserId('01JA');
$second = app(AnikeenId::class)->sshKeysByUserId('01JB');
$this->assertSame('k1', $first->toArray()[0]->id);
$this->assertSame('k2', $second->toArray()[0]->id);
$this->assertSame('GET /api/v1/client/users/01JA/ssh-keys Bearer app-token', $this->sentCall(0));
$this->assertSame('GET /api/v1/client/users/01JB/ssh-keys Bearer app-token', $this->sentCall(1));
}
public function test_users_manage_their_own_ssh_keys(): void
{
$this->respond(200, $this->list());
$this->respond(201, ['data' => ['id' => 'k1', 'name' => 'Laptop']]);
$this->respond(200, ['data' => ['id' => 'k1', 'name' => 'Work']]);
$this->respond(204);
$keys = $this->owner()->sshKeys();
$key = $keys->create('ssh-ed25519 AAAA… erika@laptop', 'Laptop');
$renamed = $key->update('Work');
$this->assertSame('Work', $renamed->name);
$this->assertTrue($renamed->delete());
$this->assertSame([
'GET /api/v1/ssh-keys Bearer user-token',
'POST /api/v1/ssh-keys Bearer user-token',
'PUT /api/v1/ssh-keys/k1 Bearer user-token',
'DELETE /api/v1/ssh-keys/k1 Bearer user-token',
], array_map(fn ($i) => $this->sentCall($i), range(0, 3)));
}
public function test_payment_methods_can_be_deleted(): void
{
$this->respond(200, ['data' => [['id' => 'pm_1']]]);
$this->respond(200, ['data' => ['id' => 'pm_1', 'type' => 'card']]);
$this->respond(204);
$this->assertTrue($this->owner()->paymentMethods()->find('pm_1')->delete());
$this->assertSame('DELETE /api/v1/payment-methods/pm_1 Bearer user-token', $this->sentCall(2));
}
public function test_products_and_boxes(): void
{
$this->respond(200, ['data' => [['id' => 'p1']]]);
$this->respond(200, [['id' => 'b1']]);
app(AnikeenId::class)->products();
app(AnikeenId::class)->boxes();
$this->assertStringStartsWith('GET /api/v1/products', $this->sentCall(0));
$this->assertStringStartsWith('GET /api/v1/boxes', $this->sentCall(1));
}
public function test_shipping_labels_use_the_app_token(): void
{
foreach (range(1, 5) as $i) {
$this->respond(200, ['data' => []]);
}
$this->http->append(new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7'));
$id = app(AnikeenId::class);
$id->shippingLabels();
$id->shippingLabel('l1');
$id->issueShippingLabel(['box' => ['id' => 'b1'], 'weight' => 20, 'address' => [], 'billed_user_id' => '01JUSER', 'test' => true]);
$id->shipShippingLabel(['0A', 'FF']);
$id->shippingPriceList();
$pdf = $id->shippingLabelPdf('l1');
$this->assertSame('%PDF-1.7', $pdf);
$this->assertSame([
'GET /api/v1/admin/shipping-labels Bearer app-token',
'GET /api/v1/admin/shipping-labels/l1 Bearer app-token',
'POST /api/v1/admin/shipping-labels/issue Bearer app-token',
'POST /api/v1/admin/shipping-labels/ship Bearer app-token',
'GET /api/v1/admin/shipping-labels/price-list Bearer app-token',
'GET /api/v1/admin/shipping-labels/l1/pdf Bearer app-token',
], array_map(fn ($i) => $this->sentCall($i), range(0, 5)));
}
public function test_orders_and_transactions_for_users(): void
{
$this->respond(201, ['data' => ['id' => 'o1']]);
$this->respond(201, ['data' => ['id' => 'tx1']]);
app(AnikeenId::class)->createOrderForUser('01JUSER', ['items' => [], 'checkout' => true]);
app(AnikeenId::class)->createTransactionForUser('01JUSER', ['status' => 'succeeded', 'type' => 'deposit', 'amount' => '9.99', 'currency_iso' => 'EUR', 'created_at' => '2026-10-26']);
$this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sentCall(0));
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1));
}
public function test_order_preview_maps_the_country_into_the_billing_address(): void
{
$this->respond(200, ['data' => ['gross_total' => 11.9]]);
app(AnikeenId::class)->createOrderPreview(['country_iso' => 'DE', 'items' => []]);
$this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0));
}
}
+62
View File
@@ -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) ?? [];
}
}