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>
This commit is contained in:
Maurice PreußandClaude Opus 5.5 committed 2026-10-25 17:18:05 +01:00
1 parent 8de737641e
commit 9613d31a8d
20 files changed
+659 -47

No files matched your search

+68 -4
View File
@@ -341,10 +341,41 @@ public function retrievingToken(string $grantType, array $attributes): Result
public function createOrderPreview(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 createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
```
### ManagesSocialiteUsers
```php
public function socialiteUsers(): SocialiteUsers
```
### ManagesSshKeys ### ManagesSshKeys
```php ```php
public function sshKeysByUserId(string $sskKeyId): SshKeys public function sshKeysByUserId(string $userId): SshKeys
``` ```
### ManagesUsers ### ManagesUsers
@@ -352,8 +383,10 @@ public function sshKeysByUserId(string $sskKeyId): SshKeys
```php ```php
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): 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 isEmailExisting(string $email): Result
public function setParent(?mixed $parent): self public function setParent(mixed $parent): self
public function getParent() public function getParent()
``` ```
@@ -365,6 +398,8 @@ public function getParent()
```php ```php
public function addresses(): Addresses public function addresses(): Addresses
public function hasDefaultBillingAddress(): bool public function hasDefaultBillingAddress(): bool
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesBalance ### ManagesBalance
@@ -372,25 +407,34 @@ public function hasDefaultBillingAddress(): bool
```php ```php
public function balance(): float public function balance(): float
public function charges(): 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 ### ManagesCountries
```php ```php
public function countries(): Countries public function countries(): Countries
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesInvoices ### ManagesInvoices
```php ```php
public function invoices(array $parameters = []): Invoices public function invoices(array $parameters = []): Invoices
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesOrders ### ManagesOrders
```php ```php
public function orders(array $parameters = []): Orders public function orders(array $parameters = []): Orders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesPaymentMethods ### ManagesPaymentMethods
@@ -400,32 +444,52 @@ public function paymentMethods(): PaymentMethods
public function hasPaymentMethod(): ?PaymentMethod public function hasPaymentMethod(): ?PaymentMethod
public function defaultPaymentMethod(): ?PaymentMethod public function defaultPaymentMethod(): ?PaymentMethod
public function hasDefaultPaymentMethod(): bool public function hasDefaultPaymentMethod(): bool
public function setDefaultPaymentMethod(string $id): PaymentMethod
public function billingPortalUrl(?string $returnUrl = null, array $options = []): string public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
public function createSetupIntent(array $options = []): Result public function createSetupIntent(array $options = []): Result
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesProfile ### ManagesProfile
```php ```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesSubscriptions ### ManagesSubscriptions
```php ```php
public function subscriptions(): Subscriptions public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesTaxation ### ManagesTaxation
```php ```php
public function vat(): float public function vatRate(): float
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
``` ```
### ManagesTransactions ### ManagesTransactions
```php ```php
public function transactions(): Transactions 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
``` ```
+49
View File
@@ -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:
+12 -12
View File
@@ -1,30 +1,30 @@
{ {
"name": "anikeen/id", "name": "anikeen/id",
"description": "PHP AnikeenId API Client for Laravel 10+", "description": "PHP Anikeen ID API client for Laravel 11+",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
{ {
"name": "René Preuß", "name": "Ren\u00e9 Preu\u00df",
"email": "rene@anikeen.com" "email": "rene@anikeen.com"
}, },
{ {
"name": "Maurice Preuß", "name": "Maurice Preu\u00df",
"email": "maurice@anikeen.com" "email": "maurice@anikeen.com"
} }
], ],
"require": { "require": {
"php": "^8.1", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0", "illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0", "illuminate/console": "^11.0|^12.0|^13.0",
"guzzlehttp/guzzle": "^6.3|^7.0", "guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^3.4|^4.0.1", "socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^7.0" "firebase/php-jwt": "^6.10|^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.0|^12.0", "laravel/framework": "^11.0|^12.0|^13.0",
"laravel/framework": "^10.0|^11.0|^12.0|^13.0", "orchestra/testbench": "^9.0|^10.0|^11.0",
"orchestra/testbench": "^9.0|^10.0|^11.0" "phpunit/phpunit": "^11.0|^12.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+16 -8
View File
@@ -7,6 +7,20 @@ use Anikeen\Id\Billable;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str; 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 // Liste der Klassen, die ausgewertet werden sollen
$classes = [ $classes = [
AnikeenId::class, AnikeenId::class,
@@ -51,10 +65,7 @@ $allMarkdown = collect($classes)
// Typ-Hint // Typ-Hint
$typeHint = ''; $typeHint = '';
if ($p->hasType()) { if ($p->hasType()) {
$type = $p->getType(); $typeHint = typeName($p->getType()) . ' ';
$nullable = $type->allowsNull() ? '?' : '';
$name = Arr::last(explode('\\', $type->getName()));
$typeHint = $nullable . $name . ' ';
} }
// Parameter-Name // Parameter-Name
@@ -84,10 +95,7 @@ $allMarkdown = collect($classes)
// Rückgabetyp, falls vorhanden // Rückgabetyp, falls vorhanden
if ($method->hasReturnType()) { if ($method->hasReturnType()) {
$retType = $method->getReturnType(); $decl .= ': ' . typeName($method->getReturnType());
$nullable = $retType->allowsNull() ? '?' : '';
$typeName = Arr::last(explode('\\', $retType->getName()));
$decl .= ': ' . $nullable . $typeName;
} }
return $decl; return $decl;
+8 -1
View File
@@ -3,8 +3,11 @@
namespace Anikeen\Id; namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPricing; use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels;
use Anikeen\Id\Concerns\ManagesSocialiteUsers; use Anikeen\Id\Concerns\ManagesSocialiteUsers;
use Anikeen\Id\Concerns\ManagesSshKeys; use Anikeen\Id\Concerns\ManagesSshKeys;
use Anikeen\Id\Concerns\ManagesUserBilling;
use Anikeen\Id\Concerns\ManagesUsers; use Anikeen\Id\Concerns\ManagesUsers;
use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException; use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException; use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
@@ -21,6 +24,9 @@ class AnikeenId
{ {
use OauthTrait; use OauthTrait;
use ManagesPricing; use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers; use ManagesSocialiteUsers;
use ManagesSshKeys; use ManagesSshKeys;
use ManagesUsers; use ManagesUsers;
@@ -398,7 +404,8 @@ class AnikeenId
'Client-ID' => $this->getClientId(), 'Client-ID' => $this->getClientId(),
'Accept' => 'application/json', '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; $headers['Authorization'] = 'Bearer ' . $bearerToken;
} }
if ($json) { if ($json) {
+2
View File
@@ -12,6 +12,7 @@ use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesSubscriptions; use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation; use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions; use Anikeen\Id\Concerns\ManagesTransactions;
use Anikeen\Id\Concerns\ManagesUserSshKeys;
use Throwable; use Throwable;
trait Billable trait Billable
@@ -26,6 +27,7 @@ trait Billable
use ManagesSubscriptions; use ManagesSubscriptions;
use ManagesTaxation; use ManagesTaxation;
use ManagesTransactions; use ManagesTransactions;
use ManagesUserSshKeys;
/** /**
* Get the currently authenticated user. * 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 * @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() return (new Transaction(fn() => $this->anikeenId()
->request('POST', 'billing/charge', [ ->request('POST', 'v1/billing/charge', [
'amount' => $amount, 'amount' => $amount,
'payment_method_id' => $paymentMethodId, 'payment_method_id' => $paymentMethodId,
'options' => $options,
]))) ])))
->setBillable($this); ->setBillable($this);
} }
+6
View File
@@ -32,6 +32,12 @@ trait ManagesPricing
*/ */
public function createOrderPreview(array $attributes = []): Result 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); 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');
}
}
+6 -7
View File
@@ -12,17 +12,16 @@ trait ManagesSshKeys
use Get; 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 * @throws Throwable
*/ */
public function sshKeysByUserId(string $sskKeyId): SshKeys public function sshKeysByUserId(string $userId): SshKeys
{ {
if (!isset($this->sshKeysCache)) { $app = $this->asApp();
$this->sshKeysCache = SshKeys::builder(fn() => $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId)))
->setParent($this);
}
return $this->sshKeysCache; return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
->setParent($app);
} }
} }
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Anikeen\Id\Concerns;
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
{
/**
* 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 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 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, 'email' => $email,
]); ]);
} }
+3 -3
View File
@@ -17,11 +17,11 @@ class Invoice extends BaseResource
* *
* @throws Throwable * @throws Throwable
*/ */
public function getInvoiceTemporaryUrl(): string public function getInvoiceTemporaryUrl(): ?string
{ {
return $this->billable->anikeenId() return $this->billable->anikeenId()
->request('PUT', sprintf('v1/invoices/%s', $this->id)) ->request('GET', sprintf('v1/invoices/%s', $this->id))
->data ->data
->temporary_url; ->temporary_url ?? null;
} }
}␍ }␍
+9 -2
View File
@@ -28,12 +28,19 @@ class OrderItems extends BaseCollection
* }> * }>
* } $attributes The order data: * } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity * - 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 * @throws Throwable
*/ */
public function create(string $orderId, array $attributes = []): OrderItem public function create(string $orderId, array $attributes = []): OrderItem
{ {
return (new OrderItem(fn() => $this->billable->anikeenId() return (new OrderItem(fn() => tap($this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s', $orderId), $attributes))) ->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) ->setBillable($this->billable)
->setParent($this->parent); ->setParent($this->parent);
} }
+12
View File
@@ -10,4 +10,16 @@ use Anikeen\Id\Concerns\HasBillable;
class PaymentMethod extends BaseResource class PaymentMethod extends BaseResource
{ {
use HasBillable; 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();
}
}␍ }␍
+10
View File
@@ -17,6 +17,16 @@ class SshKey extends BaseResource
* *
* @throws Throwable * @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 public function delete(): bool
{ {
return $this->parent->delete(sprintf('v1/ssh-keys/%s', $this->id))->success(); 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 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);
} }
}␍ }␍
+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));
}
}