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)); + } +}