diff --git a/README.md b/README.md
index 1314472..01ae3e2 100644
--- a/README.md
+++ b/README.md
@@ -341,10 +341,41 @@ 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 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
@@ -352,8 +383,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()
```
@@ -365,6 +398,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
@@ -372,25 +407,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
@@ -400,32 +444,52 @@ 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 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
```
diff --git a/README.stub b/README.stub
index db68d40..bafe028 100644
--- a/README.stub
+++ b/README.stub
@@ -93,6 +93,55 @@ 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->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
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:
diff --git a/composer.json b/composer.json
index 10d7319..916adb0 100644
--- a/composer.json
+++ b/composer.json
@@ -1,30 +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|^13.0",
- "illuminate/console": "^10.0|^11.0|^12.0|^13.0",
- "guzzlehttp/guzzle": "^6.3|^7.0",
- "socialiteproviders/manager": "^3.4|^4.0.1",
- "firebase/php-jwt": "^7.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": "^11.0|^12.0",
- "laravel/framework": "^10.0|^11.0|^12.0|^13.0",
- "orchestra/testbench": "^9.0|^10.0|^11.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": {
diff --git a/generator/generate-docs.php b/generator/generate-docs.php
index 84e01e2..84ad269 100644
--- a/generator/generate-docs.php
+++ b/generator/generate-docs.php
@@ -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;
diff --git a/phpunit.xml b/phpunit.xml
index 55c80ed..bd95b1b 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,16 +1,16 @@
-
-
-
-
- tests
-
-
-
-
- src
-
-
-
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php
index 64a4d64..93f504b 100644
--- a/src/Id/AnikeenId.php
+++ b/src/Id/AnikeenId.php
@@ -3,8 +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;
@@ -21,6 +24,9 @@ class AnikeenId
{
use OauthTrait;
use ManagesPricing;
+ use ManagesProducts;
+ use ManagesShippingLabels;
+ use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys;
use ManagesUsers;
@@ -398,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) {
diff --git a/src/Id/Billable.php b/src/Id/Billable.php
index 6074035..81b4737 100644
--- a/src/Id/Billable.php
+++ b/src/Id/Billable.php
@@ -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.
diff --git a/src/Id/Concerns/ManagesBalance.php b/src/Id/Concerns/ManagesBalance.php
index 7bf856d..7913096 100644
--- a/src/Id/Concerns/ManagesBalance.php
+++ b/src/Id/Concerns/ManagesBalance.php
@@ -30,21 +30,31 @@ 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);
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Id/Concerns/ManagesPricing.php b/src/Id/Concerns/ManagesPricing.php
index 43f27d2..ee521a5 100644
--- a/src/Id/Concerns/ManagesPricing.php
+++ b/src/Id/Concerns/ManagesPricing.php
@@ -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);
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Id/Concerns/ManagesProducts.php b/src/Id/Concerns/ManagesProducts.php
new file mode 100644
index 0000000..77d63c6
--- /dev/null
+++ b/src/Id/Concerns/ManagesProducts.php
@@ -0,0 +1,29 @@
+request('GET', 'v1/products');
+ }
+
+ /**
+ * Shipping boxes (for shipping labels).
+ *
+ * @throws Throwable
+ */
+ public function boxes(): Result
+ {
+ return $this->request('GET', 'v1/boxes');
+ }
+}
diff --git a/src/Id/Concerns/ManagesShippingLabels.php b/src/Id/Concerns/ManagesShippingLabels.php
new file mode 100644
index 0000000..44bd633
--- /dev/null
+++ b/src/Id/Concerns/ManagesShippingLabels.php
@@ -0,0 +1,85 @@
+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 $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');
+ }
+}
diff --git a/src/Id/Concerns/ManagesSshKeys.php b/src/Id/Concerns/ManagesSshKeys.php
index 07f05d5..5fa1c0e 100644
--- a/src/Id/Concerns/ManagesSshKeys.php
+++ b/src/Id/Concerns/ManagesSshKeys.php
@@ -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);
}
}
\ No newline at end of file
diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php
index 6a5cdda..d9cb6dd 100644
--- a/src/Id/Concerns/ManagesSubscriptions.php
+++ b/src/Id/Concerns/ManagesSubscriptions.php
@@ -1,38 +1,38 @@
-subscriptionsCache)) {
- $this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
- ->request('GET', 'v1/subscriptions'))
- ->setBillable($this);
- }
-
- 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);
- }
-}
\ No newline at end of file
+subscriptionsCache)) {
+ $this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
+ ->request('GET', 'v1/subscriptions'))
+ ->setBillable($this);
+ }
+
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php
new file mode 100644
index 0000000..44472b9
--- /dev/null
+++ b/src/Id/Concerns/ManagesUserBilling.php
@@ -0,0 +1,61 @@
+,
+ * 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]);
+ }
+}
diff --git a/src/Id/Concerns/ManagesUserSshKeys.php b/src/Id/Concerns/ManagesUserSshKeys.php
new file mode 100644
index 0000000..bc6745a
--- /dev/null
+++ b/src/Id/Concerns/ManagesUserSshKeys.php
@@ -0,0 +1,23 @@
+anikeenId();
+
+ return SshKeys::builder(fn() => $anikeenId->get('v1/ssh-keys'))->setParent($anikeenId);
+ }
+}
diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php
index cdeb20b..68824e5 100644
--- a/src/Id/Concerns/ManagesUsers.php
+++ b/src/Id/Concerns/ManagesUsers.php
@@ -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,
]);
}
diff --git a/src/Id/Resources/Invoice.php b/src/Id/Resources/Invoice.php
index 055cd71..e9d3012 100644
--- a/src/Id/Resources/Invoice.php
+++ b/src/Id/Resources/Invoice.php
@@ -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;
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Id/Resources/OrderItems.php b/src/Id/Resources/OrderItems.php
index b0dd390..5ef55d6 100644
--- a/src/Id/Resources/OrderItems.php
+++ b/src/Id/Resources/OrderItems.php
@@ -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);
}
@@ -48,4 +55,4 @@ class OrderItems extends BaseCollection
->setBillable($this->billable)
->setParent($this->parent);
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Id/Resources/PaymentMethod.php b/src/Id/Resources/PaymentMethod.php
index 8107c6f..7674805 100644
--- a/src/Id/Resources/PaymentMethod.php
+++ b/src/Id/Resources/PaymentMethod.php
@@ -10,4 +10,16 @@ use Anikeen\Id\Concerns\HasBillable;
class PaymentMethod extends BaseResource
{
use HasBillable;
-}
\ No newline at end of file
+
+ /**
+ * 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();
+ }
+}
\ No newline at end of file
diff --git a/src/Id/Resources/SshKey.php b/src/Id/Resources/SshKey.php
index 52b726a..c2c01af 100644
--- a/src/Id/Resources/SshKey.php
+++ b/src/Id/Resources/SshKey.php
@@ -17,8 +17,18 @@ 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();
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/src/Id/Resources/SshKeys.php b/src/Id/Resources/SshKeys.php
index 3ad451d..4dd3e4a 100644
--- a/src/Id/Resources/SshKeys.php
+++ b/src/Id/Resources/SshKeys.php
@@ -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);
}
-}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php
new file mode 100644
index 0000000..8704144
--- /dev/null
+++ b/tests/Id/EndpointsTest.php
@@ -0,0 +1,210 @@
+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));
+ }
+}