Compare commits

...
91 Commits
Author SHA1 Message Date
envoyr 9c9603591a Merge pull request #25 from anikeen-com/feature/user-and-preference-events
Add user profiles, user events and preference.updated
2026-11-07 01:57:46 +01:00
envoyr 57efac2bf5 Find crop codes under crop and document unreadable avatar files 2026-11-07 00:36:52 +00:00
envoyr 0ccf46d69a Add user profiles and user events to the changelog 2026-11-07 00:34:40 +00:00
envoyr 5ecc535e07 Document profile updates, reading users by ID and the user events 2026-11-07 00:34:40 +00:00
envoyr 93f6d79a0a Let users change their profile and avatar with their token 2026-11-07 00:33:41 +00:00
envoyr 2eceb34faa Read a connected user's profile by ID for trusted apps 2026-11-07 00:33:39 +00:00
envoyr d458d5d7fc Throw ValidationFailedException with per-field codes for 422 responses 2026-11-07 00:33:38 +00:00
envoyr a117a6a3c9 Add the preference.updated, user.updated and user.deleted event types 2026-11-07 00:31:35 +00:00
envoyr 3d7ce65f61 Merge pull request #24 from anikeen-com/feature/app-charges
Add charges and the billing limit for trusted apps
2026-11-06 21:25:38 +00:00
envoyr b35c066b48 Merge pull request #23 from anikeen-com/feature/preferences
Add the preferences API
2026-11-06 21:25:11 +00:00
envoyr be91df6d18 Restore CRLF line endings in ManagesBalance 2026-11-06 20:03:12 +00:00
envoyr 3a78359f59 Add charges and the billing limit to the changelog 2026-11-06 20:01:15 +00:00
envoyr 32103f6921 Document charges and the billing limit 2026-11-06 20:01:15 +00:00
envoyr 7bd7249730 Add charges and the billing limit for trusted apps 2026-11-06 20:00:26 +00:00
envoyr f5d48b7e35 Add the billing limit event types 2026-11-06 20:00:26 +00:00
envoyr 5a46add518 Drop event ordering claim for full refunds 2026-11-06 19:31:19 +00:00
envoyr f5d63df9d2 Add preferences to the changelog 2026-11-06 19:29:21 +00:00
envoyr eebfe557b1 Document preferences, their scopes and order.refunded 2026-11-06 19:29:20 +00:00
envoyr 802f52ce93 Add the preferences API for apps and users 2026-11-06 19:28:23 +00:00
envoyr 90dac3a5a6 Add the order.refunded event type 2026-11-06 19:26:15 +00:00
envoyr 1b64d2822f Add the shared preference keys and appearance values 2026-11-06 19:26:15 +00:00
envoyr 11ff83e40d Add the preference scopes 2026-11-06 19:26:15 +00:00
envoyr d76095cc94 Send extra request headers and throw PreconditionFailedException on 412 2026-11-06 19:25:51 +00:00
envoyr 9c505842cb Merge pull request #22 from anikeen-com/release/3.3.0
Release 3.3.0
2026-11-06 18:08:00 +00:00
envoyr 556daadbbe Release 3.3.0 2026-11-06 18:07:29 +00:00
envoyr 70d6fae5a1 Merge pull request #17 from anikeen-com/feature/uptimr-billing-api
Support trials, discounts, plan changes, order details, confirmable orders and billing portal links
2026-11-06 18:07:25 +00:00
envoyr 37e7e659e2 Merge main (3.2.0) into feature/uptimr-billing-api 2026-11-06 18:07:08 +00:00
envoyr c5cb4b76c9 Merge pull request #20 from anikeen-com/release/3.2.0
Release 3.2.0
2026-11-06 11:39:50 +00:00
envoyr 2b47be157b Release 3.2.0 2026-11-06 11:38:47 +00:00
envoyr 2796befc7d Merge pull request #19 from anikeen-com/feature/bill-elapsed-periods
Bill elapsed periods and look up users by email
2026-11-06 11:26:19 +00:00
envoyr 64ccb84987 Document elapsed periods and user accounts 2026-11-06 10:26:51 +00:00
envoyr 7813e8fbaa Look up users by email and document invitations 2026-11-06 10:26:51 +00:00
envoyr 27e868720f Bill elapsed periods on app subscription checkout 2026-11-06 10:26:51 +00:00
envoyr 0dfc6f51fc Add the app billing features to the changelog 2026-11-05 23:46:21 +00:00
envoyr dc9d54fc06 Merge main into feature/uptimr-billing-api 2026-11-05 23:45:45 +00:00
envoyr fb71b172f2 Merge pull request #18 from anikeen-com/feature/reseller-scopes
Add product and reseller scopes (3.1.0)
2026-11-05 00:31:01 +01:00
Maurice Preuß 72f761b077 Release 3.1.0 2026-11-05 00:21:52 +01:00
Maurice Preuß e8432df997 Drop the full products scope the Console does not accept
The Console's product APIs accept user, user:read and products:read only
(decision 21 names products:read). A token with just "products" was
refused everywhere despite its consent text.
2026-11-05 00:11:35 +01:00
Maurice Preuß d99512a359 Add product and reseller scopes 2026-11-04 23:27:25 +01:00
Maurice Preuß ade2970272 Document per-app collective orders and the order's oauth_client_id
Collective orders are kept per app on Anikeen ID now: an app's subscriptions
are never billed on another app's order, even with the same group. Document
oauth_client_id on orders (Order property, app orders) and how legacy orders
that billed several apps appear (own lines only, invoice PDF refused with
403 invoice_includes_other_apps).
2026-11-04 23:02:48 +01:00
Maurice Preuß a54b5fe598 Document trials without a payment method
trial_payment_method (TrialPaymentMethod::REQUIRED, the default, or
::OPTIONAL) on app subscriptions, subscriptions and checkout sessions:
an optional trial can be confirmed without a payment method and is
cancelled at its end if there is still none (cancellation_reason and
subscription.cancelled data.reason
CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
subscription.trial_will_end carries data.payment_method_missing.
2026-11-04 22:44:03 +01:00
Maurice Preuß a68e9ed257 Merge main into feature/uptimr-billing-api 2026-11-04 22:11:16 +01:00
envoyr 0ed6934da3 Merge pull request #16 from anikeen-com/fix/dependabot-alerts
Require patched Laravel and php-jwt versions
2026-11-04 22:09:39 +01:00
Maurice Preuß df7e506a5e Require patched Laravel and php-jwt versions
Raise the minimum versions so installs can no longer resolve releases with
known advisories (Dependabot alerts):

- laravel/framework / illuminate/*: ^12.69|^13.0 (TokenGuard auth bypass,
  CRLF injection in the email rule, signed URL path confusion, debug page XSS).
  Laravel 11 is end of life and gets no fixes, so support for it is dropped.
- firebase/php-jwt: ^7.0 (weak encryption in 6.x).
- orchestra/testbench: ^10.0|^11.0 to match.

Tests pass with both the lowest and the highest allowed versions.
2026-11-04 22:05:30 +01:00
Maurice Preuß 7f16155701 Document first_period on subscription.renewed and login-free billing portal links
- subscription.renewed is now sent for the first paid period too;
  data.period.first_period tells it apart (README, EventType, test payload).
- Billing portal links need no login, are single use and valid for about
  15 minutes: create them on click and redirect right away (README,
  createBillingPortalLinkForUser(), appBillingPortalUrl()).
2026-11-04 21:57:49 +01:00
Maurice Preuß 35ca9ddd2b Create billing portal links for users as a trusted app
AnikeenId::createBillingPortalLinkForUser() and $user->appBillingPortalUrl()
create a short-lived link to a user's billing pages with the app's token
(return_url, group, flow), e.g. for a Manage billing button of an app
that bills a team owner's account.
2026-11-04 21:27:26 +01:00
Maurice Preuß e45a871ca7 Let users confirm orders of trusted apps
confirm, return_url and metadata on createOrderForUser() and orders
created with the user's token: the order stays a draft with a
confirmation_url and expires after 24 hours (409 confirmation_expired on
checkout). metadata can be changed on a draft with Order::update().
2026-11-04 21:26:47 +01:00
Maurice Preuß bd56f114da Read order lines and invoices of app orders
Document the items (subscription, period, net amount, discount), invoice
and metadata of orders read with the app's token, add
AppOrders::invoicePdf() for the invoice PDF and the opt-in
subscription.renewed event type with its period data.
2026-11-04 21:25:56 +01:00
Maurice Preuß db3c50af72 Change running subscriptions
AppSubscription::change() changes price, unit, name, description or
discount of a running subscription, prorated right away (APPLY_NOW) or
from the next period on (APPLY_PERIOD_END, shown as scheduled_change);
cancelScheduledChange() drops a scheduled one. Adds the
subscription.updated event type and documents proration_not_possible
and credit_exceeds_bill.
2026-11-04 21:25:00 +01:00
Maurice Preuß bf21142f72 Discount subscriptions per billed period
discount (label, percent_off or amount_off, duration once, repeating or
forever) when creating subscriptions and per subscription of checkout
sessions, the discount field on subscriptions and the
subscription.discount_ended event type.
2026-11-04 21:24:22 +01:00
Maurice Preuß 5cc1a519c3 Start subscriptions with a free trial
trial_days, trial_ends_at and trial_once when creating subscriptions (app and
user token) and per subscription of checkout sessions. isTrial() on
subscriptions, the subscription.trial_will_end event type, and the
trial_already_used error code in the docs.
2026-11-04 21:23:52 +01:00
envoyr 61385df92f Merge pull request #15 from anikeen-com/feature/user-api-exceptions
Throw ApiException for requests with the user's token too
2026-11-04 18:44:38 +01:00
Maurice PreußandClaude Opus 5.5 57ce0b4a79 Throw ApiException for requests with the user's token too
Requests with the user's token threw a bare ResourceException with a
message like "… for resource [Anikeen\Id\Resources\Order]" and no
error code, while app requests threw ApiException. Both throw
ApiException now: status (also getCode()), Anikeen ID's error code and
message, isNotFound(), isConflict(), isTemporary(). Failed lists throw
CollectionException, now a subclass of ApiException. A request without
a response (connection refused, timeout) throws ApiException with
status 0 instead of a Guzzle exception.

ApiException extends ResourceException, so existing catch blocks keep
working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:28:50 +01:00
envoyr a4345e72ec Merge pull request #14 from anikeen-com/docs/remove-live-test-hint
Document payments end to end, add subscription status helpers
2026-11-04 17:56:25 +01:00
Maurice PreußandClaude Opus 5.5 ce820cd198 Add subscription status helpers
status() returns draft, trial, active, paused, cancelling, expired or
cancelled (SubscriptionStatus constants), derived from the fields the
same way Anikeen ID derives its status, so it also works with older
responses. isActive() is true while the subscription runs and is paid.
Both Subscription and AppSubscription have them. Document the states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:15:48 +01:00
Maurice PreußandClaude Opus 5.5 2faccac0f4 Document Stripe test cards on staging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:09:47 +01:00
Maurice PreußandClaude Opus 5.5 e3e59fb8a4 Document payments end to end
Add a Payments guide that follows a typical third-party shop: sign-in
scopes, user table and token storage, creating orders and subscriptions,
the confirmation and return flow, statuses, prices and VAT, shipments,
webhooks with Laravel 11 CSRF exclusion, error handling and testing.

Move the trusted-app sections and API authentication after it, add the
webhook secret to the config, fix the broken General/Examples snippets
and the Scope enum link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:06:23 +01:00
Maurice PreußandClaude Opus 5.5 26716e7bba Add CLAUDE.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:00:29 +01:00
Maurice PreußandClaude Opus 5.5 2acbef72c8 Drop the live API test hint from the README
The tests use a mocked HTTP client and ignore these variables. Also move
the shipping address and shipment docs into README.stub, so
composer docs no longer drops them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:00:24 +01:00
envoyr 396eb889c4 Merge pull request #13 from anikeen-com/fix/guard-registration
Fix the anikeen API guard and register its user provider
2026-11-03 23:13:21 +01:00
Maurice PreußandClaude Opus 5.5 dc29eb2844 Fix the anikeen API guard and register its user provider
- Register the guard driver and the anikeen user provider with static closures:
  Laravel binds extend() closures to the AuthManager, so $this->makeGuard() failed.
- Look the token's sub up in the user's Anikeen ID column (anikeen_id by default;
  provider 'column', services.anikeen.user_id_column or useUserIdField()),
  not in the primary key; works with the anikeen, eloquent and database providers.
- JwtParser: key from services.anikeen.public_key(_path) or the bundled key, read
  once per process; misconfiguration is an error, not a 401. Passport tokens have
  no client claim, so it is optional now.
- Optional revocation check against Anikeen ID (verify_revocation), cached per token.
- anikeenTokenCan() without a token, anikeenTokenCanAny(), scope middleware accept
  JSON responses.
- Update the bundled staging public key to the current staging key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
2026-11-03 23:07:58 +01:00
envoyr b074ec3c0c Merge pull request #12 from anikeen-com/feature/monthly-cap
Document monthly_cap, predecessor_id and expected_monthly_price
2026-11-03 12:49:37 +01:00
Maurice PreußandClaude Opus 5.5 c2493b76fd Document monthly_cap, predecessor_id and expected_monthly_price
Subscriptions (user token and app token) and checkout-session subscriptions
take a net monthly cap and the subscription they replace (a resize shares
the cap within the month). Attributes are passed through unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
2026-11-03 12:43:17 +01:00
envoyr 285594e7e9 Merge pull request #11 from anikeen-com/feature/trusted-checkout-sessions
Checkout sessions: one hosted checkout for several subscriptions
2026-11-02 17:36:08 +01:00
Maurice PreußandClaude Opus 5.5 34f6854bdd Fix the checkout session return status docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:35:25 +01:00
Maurice PreußandClaude Opus 5.5 3acd36ca29 Add checkout sessions and webhook event type constants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:26:24 +01:00
envoyr a0470111fa Merge pull request #10 from anikeen-com/docs/shipment-mail
Document the shipment mail and notify_customer
2026-11-01 15:04:09 +01:00
Maurice PreußandClaude Opus 5.5 934b1c5c3f Document the shipment mail and notify_customer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:57:46 +01:00
envoyr 6a568a5354 Merge pull request #9 from anikeen-com/feature/shipments
Shipments: ship orders and download delivery notes
2026-11-01 14:20:32 +01:00
Maurice PreußandClaude Opus 5.5 5349ee81f6 Ship orders: shipments with delivery notes
$order->shipments()->create([...]) records a delivery of a paid order's goods on Anikeen ID,
which adds its delivery note to the user's order documents and sends order.shipped.
$shipment->deliveryNote() downloads the PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:45 +01:00
envoyr 60b4b80f17 Merge pull request #8 from anikeen-com/docs/subscription-description
Document changing the description of running app subscriptions
2026-11-01 13:52:30 +01:00
Maurice PreußandClaude Opus 5.5 1f645b50bc Document changing the description of running app subscriptions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:29:30 +01:00
envoyr 9731dd36ca Merge pull request #7 from anikeen-com/docs/order-shipping-address
Fix saving Billable models after API calls; document shipping addresses and item images
2026-11-01 13:05:01 +01:00
Maurice PreußandClaude Opus 5.5 be59602b30 Declare the billable's API caches, so Eloquent models can still be saved
The caches were undeclared properties: on an Eloquent model they went through
Model::__set into the attributes, and save() failed with "no such column: ordersCache".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:55:51 +01:00
Maurice PreußandClaude Opus 5.5 fd96e559af Document shipping address fields and item images on orders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:36:26 +01:00
envoyr d6e6684d00 Merge pull request #6 from anikeen-com/feature/webhooks
Verify Anikeen ID webhooks
2026-11-01 11:48:51 +01:00
envoyr 68132e7026 Merge pull request #5 from anikeen-com/feature/checkout-confirmation
Third-party checkout confirmation
2026-11-01 11:48:16 +01:00
Maurice PreußandClaude Opus 5.5 5c09089822 Drop the unused per-subscription webhook fields
Apps receive events through their webhook endpoints now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:18:38 +01:00
Maurice PreußandClaude Opus 5.5 526b65cb5c Verify Anikeen ID webhooks
Webhook::constructEvent() checks the Anikeen-Signature header (HMAC-SHA256
of "t.body" with the endpoint's secret, 5-minute tolerance) and returns
the event, like Stripe's helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:14:42 +01:00
Maurice PreußandClaude Opus 5.5 f20a12e6da Support third-party checkout confirmation
Orders and subscriptions created by untrusted apps are confirmed by the
user on Anikeen ID. confirmationUrl($returnUrl) on Order and
Subscription builds the link with the return URL; checking out such a
draft throws ConfirmationRequiredException carrying the same URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:01:48 +01:00
envoyr 0fce38f48a Merge pull request #4 from anikeen-com/feature/backdated-checkout
checkout(startsAt:) for services that are already running
2026-10-25 19:04:57 +01:00
Maurice PreußandClaude Opus 5.5 c4a42b4ddc Check out a running service from its start
AppSubscription::checkout() takes an optional $startsAt (needs id-core
with starts_at support on PUT v1/client/subscriptions/{id}/checkout).

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:18:05 +01:00
Maurice PreußandClaude Opus 5.5 8de737641e App subscriptions: manage a user's subscriptions with the app's own token
- $user->appSubscriptions(): create (idempotency_key), find, all, checkout, revoke, pause,
  resume, update against /v1/client/subscriptions with a client credentials token, so apps don't
  depend on the user's login
- user id from the billable's anikeen_id (AnikeenId::useUserIdField())
- ApiException with status, stable error code, isNotFound/isConflict/isTemporary; network
  errors no longer escape as raw Guzzle exceptions
- rejected app tokens are renewed once; asApp() never changes the shared client's token
- tests (testbench + Guzzle mock) and CI

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

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4']
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- run: composer update --no-interaction --prefer-dist --no-progress
- run: vendor/bin/phpunit
+96
View File
@@ -0,0 +1,96 @@
# Changelog
## Unreleased
User profiles and the user and preference webhooks; they need an Anikeen ID release that supports
them.
### Added
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
`Billable`. They return the updated profile and clear the cached `getUserData()`.
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
unknown, deleted or unconnected user).
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
`user.updated` event.
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
working.
- `AnikeenId::requestMultipart()` for file uploads.
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
## 3.4.0-beta.1 (2026-11-06)
Preferences, charges and the billing limit; they need an Anikeen ID release that supports them.
### Added
- Preferences of any user with the app's token (trusted apps, registered by Anikeen per
namespace): `preferencesByUserId()`, `preferenceByUserId()`, `setPreferenceByUserId()`,
`deletePreferenceByUserId()` and `findUserIdByPreference()` (the user holding a value of a unique
key, `null` when nobody does).
- The user's shared preferences with their token: `globalPreferences()`, `globalPreference()`,
`setGlobalPreference()` and `deleteGlobalPreference()` on `Billable`, cached per instance.
- `Resources\Preference` and `Resources\Preferences`; `Enums\SharedPreference` and
`Enums\Appearance`.
- Conditional writes: `ifVersion` (`If-Match`) and `createOnly` (`If-None-Match: *`);
`PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`.
- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`.
- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`).
- Charges on a user's monthly bill for trusted apps: `createChargeForUser()` (with an idempotency
key), `findChargeForUser()` and `voidChargeForUser()`; `Resources\AppCharge`.
- The user's billing limit for trusted apps: `billingLimitForUser()` and `Resources\BillingLimit`
(`allows()` is advisory); the `billing_limit` key of `billingOverview()` is documented.
- `ApiException::isBillingLimitReached()` for 409 `billing_limit_reached`.
- Opt-in webhook events `billing_limit.reached` and `billing_limit.available`
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
## 3.3.0 (2026-11-06)
App billing features; they need an Anikeen ID release that supports them.
- Free trials: `trial_days` / `trial_ends_at` (optional `trial_once`, `trial_payment_method` with
`TrialPaymentMethod::REQUIRED` / `OPTIONAL`) when creating subscriptions and checkout sessions;
`Subscription::isTrial()`.
- Discounts per billed period: `discount` with `percent_off` or `amount_off`, `duration` and `label`.
- Plan changes of running subscriptions: `AppSubscription::change()` (`apply: now` or
`period_end`) and `AppSubscription::cancelScheduledChange()`.
- App orders: order lines with subscription, period, net amount and discount;
`AppOrders::invoicePdf()`. Confirmable orders of trusted apps (`confirm: true`).
- Billing portal links that work without a login: `createBillingPortalLinkForUser()` and
`appBillingPortalUrl()`.
- Webhook events `subscription.trial_will_end`, `subscription.discount_ended`,
`subscription.updated` and `subscription.renewed` (`EventType::SUBSCRIPTION_*`);
`data.reason` with `CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD`.
## 3.2.0 (2026-11-06)
Elapsed-period billing and user lookup; they need an Anikeen ID release that supports them.
- `AppSubscription::checkout()` takes `billElapsedPeriods` and `maxNetAmount`: a subscription
started more than one period ago is billed for its elapsed periods too, up to the given net
amount (409 `elapsed_amount_exceeded` with `net_amount`, `elapsed_periods_not_allowed`). Calls
without the new arguments send the same request as before.
- `ApiException::body()` returns the JSON body of Anikeen ID's error response, e.g. its
`net_amount`.
- Add `AnikeenId::findUserByEmail()` for trusted apps: looks up an account by email address
(regardless of case) without creating one, null when there is none.
- `createUser()` documents `name`, `password_confirmation` and `send_invitation` (a newly created
account gets an invitation mail to set a password) and the response fields `created` and
`invitation_sent`.
## 3.1.0 (2026-11-05)
- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs
of the Anikeen Console.
- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and
`reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`,
`Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console.
- Document the scope schema and the new scopes in the README.
Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags.
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
Guidance for Claude Code (and other contributors) working in this repository.
## What this is
`anikeen/id` is the public PHP client for the Anikeen ID API, shipped as a Laravel package
(Laravel 11–13, PHP 8.2+). It covers OAuth/Socialite login, an API token guard, and the billing
API (subscriptions, orders, checkout sessions, shipments, invoices, webhooks). This is a public
repo published on Packagist: never commit secrets, internal hostnames or customer data.
## Commands
```shell
composer install
composer test # vendor/bin/phpunit
vendor/bin/phpunit --filter AppSubscriptionsTest
composer docs # regenerate README.md from README.stub
```
CI (`.github/workflows/tests.yml`) runs PHPUnit on PHP 8.3 and 8.4.
## Layout
- `src/Id/AnikeenId.php` – the API client (singleton, facade `AnikeenId`). Reads
`config('services.anikeen.*')`; `mode=staging` switches to the staging base URL unless
`base_url` is set. `request()` wraps Guzzle and returns a `Result`.
- `src/Id/Billable.php` – trait for the app's user model; composes the `Concerns/Manages*` traits.
Its API caches are declared properties on purpose (undeclared ones would be treated as Eloquent
attributes and break `save()`). Add a new cache property there when a concern caches.
- `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client;
traits on `Billable` act for one user.
- `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via
`::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for
lists), no response `::unreachable()`, `confirmation_required` a `ConfirmationRequiredException`.
All extend `ResourceException`.
- `src/Id/Resources/App*` – "app" variants that use the app's own client-credentials token
(`AnikeenId::asApp()`, `Support/AppRequest`) instead of the user's token, for trusted apps.
They must not send any request until a method is called.
- `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen`
guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled
`oauth-public*.key`), scope middleware.
- `src/Id/Webhooks/` – signature verification (`Webhook::constructEvent`) and `EventType`
constants.
- `src/Support/Query.php` – query-string builder.
- `generator/generate-docs.php` – reflects the public methods of `AnikeenId` and `Billable` traits
into the README.
## Conventions
- **README:** edit `README.stub`, never `README.md` directly, then run `composer docs`. The
`<!-- GENERATED-DOCS -->` marker is replaced with the method reference. Commit both files.
- **New endpoints:** add a method to the matching `Concerns/Manages*` trait (or a resource class),
document request attributes with an `array{…}` PHPDoc shape and a short description per key,
and add an example to `README.stub` when it is a new feature.
- **Tests:** extend `Anikeen\Id\Tests\TestCase` (Orchestra Testbench). No live API: queue
responses with `$this->respond($status, $body)` (Guzzle `MockHandler`) and assert the sent
requests via `$this->sent` / `$this->sentJson($i)` – method, path, `Authorization` header and
JSON body. The app token is faked as `app-token`.
- **Compatibility:** this is a library used by several apps. Keep public method signatures
backwards compatible; add optional parameters instead of changing existing ones.
- Code, comments, docs and commit messages in English. Commit subjects are short, imperative,
sentence case (e.g. "Ship orders: shipments with delivery notes"). Work happens on feature
branches merged via PR into `main`.
+1452 -112
View File
File diff suppressed because it is too large. Load diff
+1349 -108
View File
File diff suppressed because it is too large. Load diff
+12 -11
View File
@@ -1,29 +1,30 @@
{
"name": "anikeen/id",
"description": "PHP AnikeenId API Client for Laravel 10+",
"description": "PHP Anikeen ID API client for Laravel 11+",
"license": "MIT",
"authors": [
{
"name": "René Preuß",
"name": "Ren\u00e9 Preu\u00df",
"email": "rene@anikeen.com"
},
{
"name": "Maurice Preuß",
"name": "Maurice Preu\u00df",
"email": "maurice@anikeen.com"
}
],
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/console": "^10.0|^11.0|^12.0",
"guzzlehttp/guzzle": "^6.3|^7.0",
"socialiteproviders/manager": "^3.4|^4.0.1",
"firebase/php-jwt": "^6.0"
"illuminate/support": "^12.69|^13.0",
"illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^8.0|^9.0",
"laravel/framework": "^10.0|^11.0|^12.0"
"laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"autoload": {
"psr-4": {
+16 -8
View File
@@ -7,6 +7,20 @@ use Anikeen\Id\Billable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
// short type name incl. union, intersection and nullable types
function typeName(ReflectionType $type): string
{
if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) {
$separator = $type instanceof ReflectionUnionType ? '|' : '&';
return collect($type->getTypes())->map(fn (ReflectionType $t) => typeName($t))->implode($separator);
}
$name = Arr::last(explode('\\', $type->getName()));
return ($type->allowsNull() && !in_array($name, ['mixed', 'null'], true) ? '?' : '') . $name;
}
// Liste der Klassen, die ausgewertet werden sollen
$classes = [
AnikeenId::class,
@@ -51,10 +65,7 @@ $allMarkdown = collect($classes)
// Typ-Hint
$typeHint = '';
if ($p->hasType()) {
$type = $p->getType();
$nullable = $type->allowsNull() ? '?' : '';
$name = Arr::last(explode('\\', $type->getName()));
$typeHint = $nullable . $name . ' ';
$typeHint = typeName($p->getType()) . ' ';
}
// Parameter-Name
@@ -84,10 +95,7 @@ $allMarkdown = collect($classes)
// Rückgabetyp, falls vorhanden
if ($method->hasReturnType()) {
$retType = $method->getReturnType();
$nullable = $retType->allowsNull() ? '?' : '';
$typeName = Arr::last(explode('\\', $retType->getName()));
$decl .= ': ' . $nullable . $typeName;
$decl .= ': ' . typeName($method->getReturnType());
}
return $decl;
+12 -12
View File
@@ -1,14 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAi4Ta8r01zKaGSnGi1EiD
uMFWRXBlK4y/ZIfWBpElmS2ygv4mGeP3hT4Flm696Z2UMy56KC+c7CC/PQCiutLk
5NUphyX/t+0QS5Dqpw6FB33fLTNNY7GqSmGIUE4os8XYZRSyDQRgtOgq3R3vJkoV
7zoavTJmSCQlG5Qf0T//iMmzQ+b+6VZm1CJSz5nGx94u1DuXNyP5Epkk0wuHrtwy
kADR2lmydNodJzqpSD+8yQqnAhOZNtNF4qwQ3g13fRvHycBp3G2nlCfOn2g5PmYD
KYBKqvTq4PQH4E+K3pbbMz6zf/T6Dw7zTfksqHR4hqMgN6byRRxmwuBczIumcu9b
y7xbgoIGIVZXgJliALPFi+zTPTN7c8MedFs/xCBHCmzWYTCZfHgr8RPRewD19tCG
NSny5R0vlArpuZCTTgedPESDeGU4eNEddg4yXFzKlpE2nNuvzZ1Ohruc5ETOSU19
RTCBUBkjeL6ESZRd/yKGjbVx4dEYxZdIz4yBl+hZ2ZOIyG7L3zPrccAWrPpG56xr
E5IDBXxLFhaJ5LlyEAGQehB0ShEuCdkr88Xz7ba9PHpGqY83l4//ULrqPIZPAa4Z
E3AWHT1ZtXNPeA4SzZ9Y9Oij4M3chyHxqM0lL3kYP+dstZehTujStfElDIx2Ni10
73tILu4edYS0FxsL19m8gbsCAwEAAQ==
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArGn4rtfKjlLtJsh61eW/
cZdLC6ECbSUKVTZlLLvRoYr7Li6RKRDiIj7KEy+lRL4miiMtadXNx6d89KZwr8SL
DQXDbP//F3HEXVJBtr6j8p8HbJh1pinLJeKtcmeJfOIwVLyQ3sPdewkQoFtwkQyF
uZWLeh6TCfAtcQs49I6C0rYgLDQrfTzvQ195mX1HkgArndNDXBiECMYCCulmPpzv
yq5MWS5+/uQjKmPToo5lPfKxbWD6lyLII9ZkGp7wFilSonpUN1LmwRJlgQqh+1Hb
celGHTIXiP3lDNIG3sR/H5v6vwGeqfu9MBQ2Ksrz259O1aojqRQ+d9k1dj8pcbuC
ZurlI4++JOsmecc4B3g8tvlsevlb0UbtW/JHKckgK/JL9PO+04WJD+hSJrEhokLq
aB5Ceg6FKwqoM0l7dtoHRka8vh/q/UU3oawUESoZ5l3TjWI4b0jZzsJqE4cyKgK3
S3tBooj4rM4dOh4y7crD1PcsJG/IF2bg4YEAO8q8Fz6TmOzCM20oBhaLrFJOnYph
Xhhl7ZPHqCCIt6HSDbwlFJw1Iw/2nygnkAZ0iJV/EQl0M+zlDGEv41SWHQfsLTK6
HBSM1QSEbuMz0sF/TpolYEvYso+nCDjDzQ4MFdhGxH6OVyuSySm6nG0fK/Bio5Ow
6SNz0wIg3RagjzTbljHsT4cCAwEAAQ==
-----END PUBLIC KEY-----
+8 -21
View File
@@ -1,29 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
printerClass="\Codedungeon\PHPUnitPrettyResultPrinter\Printer">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<source>
<include>
<directory suffix=".php">src</directory>
</whitelist>
</filter>
<php>
<env name="ANIKEEN_ID_KEY" value="38"/>
<env name="ANIKEEN_ID_SECRET" value="2goNRF8x37HPVZVaa28ySZGVXJuksvxnxM7TcGzM"/>
<env name="ANIKEEN_ID_BASE_URL" value="https://id.anikeen.com"/>
<env name="CLIENT_ACCESS_TOKEN" value="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxNSIsImp0aSI6IjAxN2QxZDg0Y2MxNjAyZTYxOGZkNjYwZGViZWVjMDY4MTk2YmYzMDk1OGMzY2RiYzBjZmJkZWFjZjFhOTUxODQzZDU1YTk3OGY2YWIxY2YzIiwiaWF0IjoxNjY0NjIzMzA4LjQyODI4MSwibmJmIjoxNjY0NjIzMzA4LjQyODI4NCwiZXhwIjoxNjgwMzQ4MTA4LjQxNjc4MSwic3ViIjoiMzkiLCJzY29wZXMiOlsiYXBpIiwicmVhZF91c2VyIl0sImNsaWVudCI6eyJ0cnVzdGVkIjpmYWxzZX19.vxnzCaU4PpOrNVHa5AnGSS6gX_RCvxIERAnHFhjTrUzRafV9mr2Cvwd-BDVYoUr10wHvxa_TJSYfnAdDuhE-MEyDv13O3dL2XGTtJNa_Rg6L6CQ0JvC3wL-lWPvGPFax9pu-_lqbA3jm5B08hc3-7tq3f2nXcxjhtkqT6TTJv1-RCAppb2HCXiUDAqANzbhyInDjOH2WvFj1OGC_AI03J3W2KRWyeFLtUne8XKPFyr9XGcPuTrqogcuuXLeUt2kcf2bXbuIV1OlgIECrDiP1Ee0F2AzPs27ZVJ2z0R0JbT6AubKhGl5_Qi27cwjOH7hT2dmjcF1mLjzpN1uChLIdSnGSoStH8VzYHnHE2I8G-owW_aadG2UmGdnRY143q6g_28f3WIZNSucBSXkwFeS_t4fylsvpxhpjYJusf5qiEU_X3YbeawYMUCFUkSD2XTIypAqMJLNZQAeJ52eyL-9fln-Bv7n9v7K9G6ieR6Tm0tsJ1PRnaQi7rA1NTFwHoQmIOW9tfMycLzT7bgSoz3ra6Ez2J7ZNuWBZNKS0O-0YfSrAWyWK5U8YRfQuSVzP2VrIU63K6RGU2c284PfQGy11kgKUNQPykirb8p7MDQ8PwrxKaylBnD6hhDgjqEh2bfsr_43DfJA0R58L1HK3BmQnxgap0C77wK1e0yNlABpN28Q"/>
</php>
</include>
</source>
</phpunit>
+87 -11
View File
@@ -2,8 +2,13 @@
namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPreferences;
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;
@@ -19,7 +24,12 @@ use Illuminate\Contracts\Auth\Authenticatable;
class AnikeenId
{
use OauthTrait;
use ManagesPreferences;
use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys;
use ManagesUsers;
@@ -56,6 +66,8 @@ class AnikeenId
*/
private static string $refreshTokenField = 'anikeen_id_refresh_token';
private static string $userIdField = 'anikeen_id';
/**
* Guzzle is used to make http requests.
*/
@@ -141,6 +153,41 @@ class AnikeenId
return self::$accessTokenField;
}
/**
* Attribute of the billable model that holds the user's Anikeen ID (used by app requests,
* e.g. appSubscriptions(), which act on behalf of a user without the user's token).
*/
public static function useUserIdField(string $userIdField): void
{
self::$userIdField = $userIdField;
}
public static function getUserIdField(): string
{
return self::$userIdField;
}
/**
* A copy that uses the app's own access token (client credentials) instead of a user's token.
* The shared instance keeps its token, so app rights never leak into other requests.
*
* @throws Exceptions\RequestFreshAccessTokenException
*/
public function asApp(): static
{
return (clone $this)->withToken(app(Contracts\AppTokenRepository::class)->getAccessToken());
}
/**
* Replace the HTTP client, e.g. with one using a Guzzle MockHandler in tests.
*/
public function setHttpClient(Client $client): static
{
$this->client = $client;
return $this;
}
public static function getMode(): string
{
return config('services.anikeen.mode') ?: 'production';
@@ -324,10 +371,12 @@ class AnikeenId
/**
* Build query & execute.
*
* @param array<string, string> $headers Extra request headers, e.g. `If-Match` for conditional
* writes; they are sent in addition to (and may not replace) Client-ID, Accept and Authorization.
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false, array $headers = []): Result
{
if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor();
@@ -335,7 +384,7 @@ class AnikeenId
try {
$response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers,
'query' => Query::build($parameters),
'json' => $payload ?: null,
]);
@@ -348,6 +397,32 @@ class AnikeenId
return $result;
}
/**
* Send a multipart/form-data request (file uploads), with the same headers and error handling
* as request(), which stays JSON only.
*
* @param list<array{name: string, contents: mixed, filename?: string, headers?: array<string, string>}> $multipart
* Guzzle's multipart parts
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function requestMultipart(string $method, string $path, array $multipart): Result
{
try {
$response = $this->client->request($method, $path, [
// no Content-Type: Guzzle sets it with the boundary
'headers' => $this->buildHeaders(),
'multipart' => $multipart,
]);
$result = new Result($response, null, $this);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception, $this);
}
return $result;
}
/**
* Build headers for request.
*
@@ -359,7 +434,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) {
@@ -373,35 +449,35 @@ class AnikeenId
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result
public function get(string $path, array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('GET', $path, null, $parameters, $paginator);
return $this->request('GET', $path, null, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('POST', $path, $payload, $parameters, $paginator);
return $this->request('POST', $path, $payload, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('PUT', $path, $payload, $parameters, $paginator);
return $this->request('PUT', $path, $payload, $parameters, $paginator, false, $headers);
}
/**
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{
return $this->request('DELETE', $path, $payload, $parameters, $paginator);
return $this->request('DELETE', $path, $payload, $parameters, $paginator, false, $headers);
}
}
+104 -55
View File
@@ -9,38 +9,39 @@ use Exception;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use stdClass;
use Throwable;
/**
* Resolves the user of a request from an Anikeen ID access token (JWT, "Authorization: Bearer …")
* or from the encrypted token cookie of CreateFreshApiToken.
*
* A bearer token is verified with the JwtParser (signature, expiry). Its `sub` is the user's
* Anikeen ID, looked up by the provider (AnikeenIdUserProvider::retrieveByAnikeenId(), column
* `anikeen_id` by default). The token is attached to the user: `$user->anikeenTokenCan('scope')`.
*
* With `services.anikeen.verify_revocation` the token is also checked against Anikeen ID
* (GET v1/user, cached per token for `services.anikeen.revocation_cache_ttl` seconds), so
* revoked tokens stop working before they expire.
*/
class TokenGuard
{
use GuardHelpers;
private Encrypter $encrypter;
private JwtParser $jwtParser;
public function __construct(UserProvider $provider, Encrypter $encrypter, JwtParser $jwtParser)
{
$this->provider = $provider;
$this->encrypter = $encrypter;
$this->jwtParser = $jwtParser;
public function __construct(
protected UserProvider $provider,
private Encrypter $encrypter,
private JwtParser $jwtParser
) {
//
}
/**
* Get the user for the incoming request.
*
* @throws BindingResolutionException
* @throws Throwable
*/
public function user(Request $request): ?Authenticatable
{
@@ -55,9 +56,6 @@ class TokenGuard
/**
* Authenticate the incoming request via the Bearer token.
*
* @throws BindingResolutionException
* @throws Throwable
*/
protected function authenticateViaBearerToken(Request $request): ?Authenticatable
{
@@ -65,51 +63,97 @@ class TokenGuard
return null;
}
// If the access token is valid we will retrieve the user according to the user ID
// associated with the token. We will use the provider implementation which may
// be used to retrieve users from Eloquent. Next, we'll be ready to continue.
/** @var Authenticatable|HasAnikeenTokens $user */
$user = $this->provider->retrieveById(
$request->attributes->get('oauth_user_id') ?: null
);
// client credentials tokens have no user
if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') {
return null;
}
return $user?->withAnikeenAccessToken($token);
if (!$this->tokenIsActive($token, (string) $request->bearerToken())) {
return null;
}
/** @var Authenticatable|HasAnikeenTokens|null $user */
$user = $this->retrieveUser((string) $token->sub);
return $user && method_exists($user, 'withAnikeenAccessToken')
? $user->withAnikeenAccessToken($token)
: $user;
}
/**
* Authenticate and get the incoming request via the Bearer token.
*
* @throws BindingResolutionException
* @throws Throwable
* Verify the Bearer token and expose its claims as request attributes.
*/
protected function validateRequestViaBearerToken(Request $request): ?stdClass
{
try {
$decoded = $this->jwtParser->decode($request);
} catch (AuthenticationException) {
return null;
}
$request->attributes->set('oauth_access_token_id', $decoded->jti);
$request->attributes->set('oauth_client_id', $decoded->aud);
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted);
$request->attributes->set('oauth_user_id', $decoded->sub);
$decoded->scopes = array_values(array_filter((array) ($decoded->scopes ?? []), 'is_string'));
$request->attributes->set('oauth_access_token_id', $decoded->jti ?? null);
$request->attributes->set('oauth_client_id', $decoded->aud ?? null);
$request->attributes->set('oauth_client_trusted', $decoded->client->trusted ?? null);
$request->attributes->set('oauth_user_id', $decoded->sub ?? null);
$request->attributes->set('oauth_scopes', $decoded->scopes);
return $decoded;
} catch (AuthenticationException $e) {
$request->headers->set('Authorization', '', true);
Container::getInstance()->make(
ExceptionHandler::class
)->report($e);
return null;
}
/**
* Whether Anikeen ID still accepts the token (only with services.anikeen.verify_revocation).
*/
protected function tokenIsActive(stdClass $token, string $jwt): bool
{
if (!config('services.anikeen.verify_revocation')) {
return true;
}
$key = 'anikeen-id:token-active:' . hash('sha256', $jwt);
if (($cached = Cache::get($key)) !== null) {
return (bool) $cached;
}
try {
$result = (clone app(AnikeenId::class))->withToken($jwt)->getAuthedUser();
} catch (Throwable) {
return false; // Anikeen ID unreachable: fail closed, ask again next time
}
$status = $result->response?->getStatusCode();
if ($result->success()) {
$active = true;
} elseif (in_array($status, [401, 403], true)) {
$active = false;
} else {
return false; // server error: fail closed without caching
}
$ttl = (int) config('services.anikeen.revocation_cache_ttl', 300);
if (isset($token->exp)) {
$ttl = max(1, min($ttl, (int) $token->exp - time()));
}
Cache::put($key, $active, $ttl);
return $active;
}
/**
* Find the local user of an Anikeen ID.
*/
protected function retrieveUser(string $anikeenId): ?Authenticatable
{
return $this->provider->retrieveByAnikeenId($anikeenId);
}
/**
* Authenticate the incoming request via the token cookie.
*/
protected function authenticateViaCookie(Request $request): mixed
protected function authenticateViaCookie(Request $request): ?Authenticatable
{
if (!$token = $this->getTokenViaCookie($request)) {
return null;
@@ -118,9 +162,11 @@ class TokenGuard
// If this user exists, we will return this user and attach a "transient" token to
// the user model. The transient token assumes it has all scopes since the user
// is physically logged into the application via the application's interface.
/** @var Authenticatable|HasAnikeenTokens $user */
if ($user = $this->provider->retrieveById($token['sub'])) {
return $user->withAnikeenAccessToken((object)['scopes' => ['*']]);
/** @var Authenticatable|HasAnikeenTokens|null $user */
if ($user = $this->retrieveUser((string) $token['sub'])) {
return method_exists($user, 'withAnikeenAccessToken')
? $user->withAnikeenAccessToken((object) ['scopes' => ['*']])
: $user;
}
return null;
@@ -136,15 +182,18 @@ class TokenGuard
// database. If we can't decrypt the value we'll bail out with a null return.
try {
$token = $this->decodeJwtTokenCookie($request);
} catch (Exception $e) {
} catch (Exception) {
return null;
}
if (!isset($token['sub'], $token['expiry'])) {
return null;
}
// We will compare the CSRF token in the decoded API token against the CSRF header
// sent with the request. If they don't match then this request isn't sent from
// a valid source and we won't authenticate the request for further handling.
if (!AnikeenId::$ignoreCsrfToken && (!$this->validCsrf($token, $request) ||
time() >= $token['expiry'])) {
if (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) {
return null;
}
@@ -156,7 +205,7 @@ class TokenGuard
*/
protected function decodeJwtTokenCookie(Request $request): array
{
return (array)JWT::decode(
return (array) JWT::decode(
CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)),
new Key(
$this->encrypter->getKey(),
@@ -186,7 +235,7 @@ class TokenGuard
$token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
}
return $token;
return (string) $token;
}
/**
+25 -2
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Auth;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider as Base;
@@ -10,11 +11,33 @@ class UserProvider implements Base
/**
* Create a new AnikeenId user provider.
*/
public function __construct(protected Base $provider, protected string $providerName)
public function __construct(protected Base $provider, protected string $providerName, protected ?string $column = null)
{
//
}
/**
* The column that holds a user's Anikeen ID: the provider's `column`, else
* `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
*/
public function column(): string
{
return $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
}
/**
* Find the user of an Anikeen ID (the `sub` of an access token).
*/
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
{
if (method_exists($this->provider, 'retrieveByAnikeenId')) {
return $this->provider->retrieveByAnikeenId($anikeenId);
}
// eloquent / database providers: a plain where on the column
return $this->provider->retrieveByCredentials([$this->column() => $anikeenId]);
}
/**
* {@inheritdoc}
*/
@@ -65,6 +88,6 @@ class UserProvider implements Base
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// TODO: Implement rehashPasswordIfRequired() method.
$this->provider->rehashPasswordIfRequired($user, $credentials, $force);
}
}
+21
View File
@@ -9,9 +9,11 @@ use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesUserPreferences;
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 +28,25 @@ trait Billable
use ManagesSubscriptions;
use ManagesTaxation;
use ManagesTransactions;
use ManagesUserPreferences;
use ManagesUserSshKeys;
/*
* API results cached per instance. Declared, so Eloquent models don't take them for
* attributes (an undeclared property goes through Model::__set and save() then fails
* with "no such column").
*/
protected mixed $addressesCache = null;
protected mixed $countriesCache = null;
protected mixed $defaultPaymentMethodCache = null;
protected mixed $invoicesCache = null;
protected mixed $ordersCache = null;
protected mixed $paymentMethodsCache = null;
/** @var array<string, \Anikeen\Id\Resources\Preferences> by namespace */
protected array $preferencesCache = [];
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
/**
* Get the currently authenticated user.
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
/**
* Orders and subscriptions created by third-party apps are confirmed by the user on Anikeen ID.
*/
trait HasConfirmationUrl
{
/**
* Where the user confirms (or cancels) this booking, or null once it was checked out.
*
* Afterwards the user is sent back to $returnUrl with `status` (completed, pending or
* cancelled) and `order` or `subscription`. The return URL must have the origin of one of
* the app's redirect URIs. Check the status through the API; don't trust the query string.
*/
public function confirmationUrl(?string $returnUrl = null): ?string
{
$url = $this->confirmation_url ?? null;
if (!$url || !$returnUrl) {
return $url ?: null;
}
return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query(['return_url' => $returnUrl]);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SubscriptionStatus;
/**
* The state of a subscription, derived from its fields the same way Anikeen ID derives `status`
* (so it also works with responses that only carry the fields).
*/
trait HasSubscriptionStatus
{
/**
* One of the SubscriptionStatus constants: draft, trial, active, paused, cancelling, expired
* or cancelled.
*/
public function status(): string
{
$active = (bool) $this->active;
$started = !empty($this->starts_at);
return match (true) {
(bool) $this->cancelled => SubscriptionStatus::CANCELLED,
!$active && !$started => SubscriptionStatus::DRAFT,
$active && !empty($this->cancels_at) => SubscriptionStatus::CANCELLING,
$active && !$this->autorenew => SubscriptionStatus::PAUSED,
$active && !empty($this->trial_ends_at) && strtotime($this->trial_ends_at) > time() => SubscriptionStatus::TRIAL,
$active => SubscriptionStatus::ACTIVE,
!$this->autorenew && $started => SubscriptionStatus::EXPIRED,
default => SubscriptionStatus::INACTIVE,
};
}
/**
* Not checked out or confirmed yet (third-party apps: send the user to confirmationUrl()).
*/
public function isDraft(): bool
{
return $this->status() === SubscriptionStatus::DRAFT;
}
/**
* Running and paid: active, in trial, paused or with a scheduled cancellation. Grant access
* while this is true.
*/
public function isActive(): bool
{
return in_array($this->status(), [SubscriptionStatus::TRIAL, SubscriptionStatus::ACTIVE, SubscriptionStatus::PAUSED, SubscriptionStatus::CANCELLING], true);
}
/**
* In its free trial: running, nothing billed until trial_ends_at.
*/
public function isTrial(): bool
{
return $this->status() === SubscriptionStatus::TRIAL;
}
/**
* Paused: runs until the paid period ends, then expires (resume() to continue).
*/
public function isPaused(): bool
{
return $this->status() === SubscriptionStatus::PAUSED;
}
/**
* Running until a scheduled cancellation takes effect.
*/
public function isCancelling(): bool
{
return $this->status() === SubscriptionStatus::CANCELLING;
}
/**
* Paused and ran out: resume() starts a new period.
*/
public function isExpired(): bool
{
return $this->status() === SubscriptionStatus::EXPIRED;
}
/**
* Cancelled for good.
*/
public function isCancelled(): bool
{
return $this->status() === SubscriptionStatus::CANCELLED;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Anikeen\Id\Concerns;
/**
* Reading a checkout session (see AppCheckoutSession and CheckoutSession).
*/
trait IsCheckoutSession
{
/**
* Where the user confirms (or cancels) all of the session's subscriptions, or null once the
* session is no longer open. The user then returns to the session's `return_url` with
* `status` (completed or cancelled when confirming or cancelling; expired only from the
* "back to app" link of a session that had expired) and `checkout_session` (the id). Check the
* status through the API or the checkout.* webhooks; don't trust the query string.
*/
public function confirmationUrl(): ?string
{
return $this->confirmation_url ?: null;
}
public function isOpen(): bool
{
return $this->status === self::STATUS_OPEN;
}
/**
* Confirmed: all of its subscriptions are running.
*/
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
/**
* Cancelled or expired: all of its drafts were cancelled.
*/
public function isCancelled(): bool
{
return in_array($this->status, [self::STATUS_CANCELLED, self::STATUS_EXPIRED], true);
}
/**
* @return list<string>
*/
public function subscriptionIds(): array
{
return array_values((array) ($this->subscription_ids ?? []));
}
}
+23 -7
View File
@@ -30,20 +30,36 @@ 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 and billing_limit.
*
* billing_limit is the user's monthly billing limit {object: "billing_limit", user_id,
* currency, basis, limit, used, remaining, remaining_net, vat_rate, reached, resets_at,
* version} (`limit`, `remaining` and `remaining_net` null without a limit; wrap it in
* `new \Anikeen\Id\Resources\BillingLimit(…)` for its helpers), or null while the user has no
* limit and can't set one yet. Older Anikeen ID releases don't send the key.
*
* @param float $amount Amount to charge in euros.
* @param string $paymentMethodId Payment method ID.
* @param array $options Additional options for the charge.
* @throws Throwable
*/
public function charge(float $amount, string $paymentMethodId, array $options = []): Transaction
public function billingOverview(): object
{
return $this->anikeenId()->request('GET', 'v1/billing')->data;
}
/**
* Charge given amount (deposit) from one of the current user's payment methods.
*
* @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals).
* @param string $paymentMethodId Stripe payment method ID (pm_…).
* @param array $options Ignored; Stripe options can no longer be passed through.
* @throws Throwable
*/
public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction
{
return (new Transaction(fn() => $this->anikeenId()
->request('POST', 'billing/charge', [
->request('POST', 'v1/billing/charge', [
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
'options' => $options,
])))
->setBillable($this);
}
+13
View File
@@ -60,6 +60,19 @@ trait ManagesPaymentMethods
return $this->paymentMethods()->hasDefaultPaymentMethod();
}
/**
* Set the default payment method for the current user.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::setDefault()
*/
public function setDefaultPaymentMethod(string $id): PaymentMethod
{
unset($this->paymentMethodsCache);
return $this->paymentMethods()->setDefault($id);
}
/**
* Get billing portal URL for the current user.
*
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\AppRequest;
use Anikeen\Id\Support\PreferenceRequest;
/**
* Preferences of any user, with the app's own token (client credentials, scope
* `preferences:client`). Anikeen has to register the app for each namespace it uses: its own
* namespaces (read, write, lookup), read access to another app's namespace, or access to `global`.
* Without it every call fails with 403 "client_not_trusted" or 404 "namespace_not_found".
*/
trait ManagesPreferences
{
/**
* All preferences of a user in a namespace, sorted by key.
*
* @throws ApiException 404 "namespace_not_found", "user_not_found" (also deleted users), …
*/
public function preferencesByUserId(string $userId, string $namespace): Preferences
{
return Preferences::builder(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace)));
}
/**
* One preference of a user.
*
* @throws ApiException 404 "preference_not_found" when the key isn't set, "user_not_found", …
*/
public function preferenceByUserId(string $userId, string $namespace, string $key): Preference
{
return new Preference(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace, $key)));
}
/**
* Set a preference of a user. The value is any JSON value (pass `(object) []` for `{}`), at
* most 2048 bytes and 4 levels deep. Writing the value already stored changes nothing and
* succeeds, also when a precondition would fail, so a retried write is safe.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @param bool $createOnly only if the key isn't set yet (`If-None-Match: *`); not with $ifVersion
* @throws PreconditionFailedException 412 "preference_version_mismatch" / "preference_exists",
* with currentVersion()
* @throws ApiException 403 "namespace_read_only", 409 "preference_value_taken" (a unique key),
* 422 "invalid_key", "forbidden_key", "value_too_large", "preference_limit_exceeded", …
*/
public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference
{
$headers = PreferenceRequest::conditions($ifVersion, $createOnly);
return new Preference(fn () => AppRequest::send('PUT', $this->userPreferencePath($userId, $namespace, $key), ['value' => $value], [], $headers));
}
/**
* Delete a preference of a user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void
{
AppRequest::send('DELETE', $this->userPreferencePath($userId, $namespace, $key), null, [], PreferenceRequest::conditions($ifVersion));
}
/**
* The ID of the user holding a value of a unique key in one of the app's own namespaces, e.g.
* the user of a payment provider's customer ID. The value is sent in the body, never in the URL.
*
* @return string|null null when no (not deleted) user holds the value
* @throws ApiException 422 "key_not_unique", "invalid_unique_value", 403 "lookup_not_allowed", …
*/
public function findUserIdByPreference(string $namespace, string $key, string $value): ?string
{
try {
$result = AppRequest::send('POST', PreferenceRequest::path('v1/client/preferences', $namespace, 'lookup'), [
'key' => $key,
'value' => $value,
]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "nobody holds it", not e.g. an unknown namespace
if ($e->isNotFound() && $e->error === 'preference_not_found') {
return null;
}
throw $e;
}
return $result->data->user_id;
}
private function userPreferencePath(string $userId, string $namespace, ?string $key = null): string
{
$segments = [$userId, 'preferences', $namespace];
if ($key !== null) {
$segments[] = $key;
}
return PreferenceRequest::path('v1/client/users', ...$segments);
}
}
+6
View File
@@ -32,6 +32,12 @@ trait ManagesPricing
*/
public function createOrderPreview(array $attributes = []): Result
{
// the API expects the country as part of the billing address
if (isset($attributes['country_iso']) && !isset($attributes['billing_address'])) {
$attributes['billing_address'] = ['country_iso' => $attributes['country_iso']];
unset($attributes['country_iso']);
}
return $this->post('v1/orders/preview', $attributes);
}
}␍
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
trait ManagesProducts
{
/**
* Public product catalogue.
*
* @throws Throwable
*/
public function products(): Result
{
return $this->request('GET', 'v1/products');
}
/**
* Shipping boxes (for shipping labels).
*
* @throws Throwable
*/
public function boxes(): Result
{
return $this->request('GET', 'v1/boxes');
}
}
+107
View File
@@ -2,8 +2,19 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use SplFileInfo;
use Throwable;
/**
* The user's own profile, with the user's token. Changes need the scope `user` (`user:read`
* only reads; 403 "missing_scope").
*/
trait ManagesProfile
{
use HasBillable;
@@ -23,4 +34,100 @@ trait ManagesProfile
'options' => $options,
])->data->url;
}
/**
* Change the user's profile (scope `user`). Only the fields sent change. A new email address
* doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes
* the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the
* result; user.updated follows then). Clears the cached getUserData().
*
* @param array{
* name?: string,
* username?: string,
* email?: string
* } $attributes The fields to change
* - name: The display name
* - username: The username (unique regardless of case)
* - email: A new email address (lower-cased; needs the user's confirmation)
* @return User the user as `GET v1/user` returns them, plus `pending_email` and
* `pending_email_expires_at`
* @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken",
* "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field))
* @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour)
*/
public function updateProfile(array $attributes): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes));
}
/**
* Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to
* 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears
* the cached getUserData().
*
* @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile
* @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the
* upright picture; the centre without one
* @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large",
* "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them)
* @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (also more than 10 uploads an hour)
* @throws \RuntimeException when the file can't be opened (nothing is sent then)
*/
public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User
{
$path = $file instanceof SplFileInfo ? $file->getPathname() : $file;
$filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName')
? $file->getClientOriginalName()
: basename($path);
$multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]];
foreach (['x', 'y', 'size'] as $key) {
if (isset($crop[$key])) {
$multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]];
}
}
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart));
}
/**
* Remove the uploaded profile picture (scope `user`); the initials are shown again when it
* was the one shown. Clears the cached getUserData().
*
* @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests"
*/
public function removeAvatar(): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar'));
}
/**
* @param callable(): Result $send
* @throws ApiException
*/
private function sendProfileRequest(callable $send): User
{
try {
$result = $send();
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
$this->userDataCache = null;
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
return new User($result->data);
}
}␍
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Result;
use Throwable;
/**
* Shipping labels (Deutsche Post Internetmarke). Trusted apps only, sent with the app's token.
*/
trait ManagesShippingLabels
{
/**
* Latest shipping labels (100 per page).
*
* @throws Throwable
*/
public function shippingLabels(array $parameters = []): Result
{
return $this->asApp()->request('GET', 'v1/admin/shipping-labels', null, $parameters);
}
/**
* @throws Throwable
*/
public function shippingLabel(string $id): Result
{
return $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s', $id));
}
/**
* The label as PDF (binary string), null if there is none.
*
* @throws Throwable
*/
public function shippingLabelPdf(string $id): ?string
{
$result = $this->asApp()->request('GET', sprintf('v1/admin/shipping-labels/%s/pdf', $id));
return $result->success() ? (string) $result->response()->getBody() : null;
}
/**
* Buy a shipping label; the postage is billed to the given user.
*
* @param array{
* box: array,
* weight: int,
* address: array,
* billed_user_id: string,
* test?: bool
* } $attributes
* - box: barcode, id, common_name or size of the box
* - weight: in grams
* - address: recipient address
* - billed_user_id: Anikeen ID of the user who pays the postage
* - test: validate only, no real label is bought
* @throws Throwable
*/
public function issueShippingLabel(array $attributes): Result
{
return $this->asApp()->request('POST', 'v1/admin/shipping-labels/issue', $attributes);
}
/**
* Mark the label with the scanned matrix code as shipped.
*
* @param list<string> $hex Hex bytes of the scanned Internetmarke matrix code
* @throws Throwable
*/
public function shipShippingLabel(array $hex): Result
{
return $this->asApp()->request('POST', 'v1/admin/shipping-labels/ship', ['hex' => $hex]);
}
/**
* Prices of the available franking types.
*
* @throws Throwable
*/
public function shippingPriceList(): Result
{
return $this->asApp()->request('GET', 'v1/admin/shipping-labels/price-list');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\Resources\SocialiteUsers;
use Throwable;
trait ManagesSocialiteUsers
{
use Get;
/**
* Get all socialite connections for the authenticated user.
*
* @throws Throwable
*/
public function socialiteUsers(): SocialiteUsers
{
return SocialiteUsers::builder(fn() => $this->get('v1/user/socialite-users'))
->setParent($this);
}
}
+6 -7
View File
@@ -12,17 +12,16 @@ trait ManagesSshKeys
use Get;
/**
* Get currently authed user with Bearer Token.
* SSH keys of a user, read with the app's own token (trusted apps, scope ssh-keys:read),
* e.g. to install them on a new server.
*
* @throws Throwable
*/
public function sshKeysByUserId(string $sskKeyId): SshKeys
public function sshKeysByUserId(string $userId): SshKeys
{
if (!isset($this->sshKeysCache)) {
$this->sshKeysCache = SshKeys::builder(fn() => $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId)))
->setParent($this);
}
$app = $this->asApp();
return $this->sshKeysCache;
return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
->setParent($app);
}
}
+59
View File
@@ -2,6 +2,12 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\CheckoutSessions;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
@@ -24,4 +30,57 @@ trait ManagesSubscriptions
return $this->subscriptionsCache;
}
/**
* The user's subscriptions, managed with the app's own token instead of the user's
* (trusted apps only, scope subscriptions:client). Use this in jobs and anywhere the user's
* login may have expired.
*/
public function appSubscriptions(): AppSubscriptions
{
return new AppSubscriptions($this);
}
/**
* Checkout sessions for the user, created with the app's own token (trusted apps only, scope
* subscriptions:client): the user confirms several subscriptions at once on Anikeen ID.
*/
public function appCheckoutSessions(): AppCheckoutSessions
{
return new AppCheckoutSessions($this);
}
/**
* Checkout sessions with the user's own access token (any app): the user confirms several
* subscriptions at once on Anikeen ID.
*/
public function checkoutSessions(): CheckoutSessions
{
return new CheckoutSessions($this);
}
/**
* The user's orders that bill the app's subscriptions, read with the app's own token
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
*/
public function appOrders(): AppOrders
{
return new AppOrders($this);
}
/**
* A link to the user's billing pages on Anikeen ID that needs no login, created with the app's
* own token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a
* "Manage billing" button; see AnikeenId::createBillingPortalLinkForUser() for the attributes
* (return_url, group, flow). Single use and valid for about 15 minutes: create it when the
* user clicks and redirect right away.
*
* @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes
*
* @throws ApiException
*/
public function appBillingPortalUrl(array $attributes = []): string
{
return app(AnikeenId::class)->createBillingPortalLinkForUser($this->appSubscriptions()->userId(), $attributes)->url;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\BillingLimit;
use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable;
/**
* Orders and payments on behalf of a user. Trusted apps only, sent with the app's token.
*/
trait ManagesUserBilling
{
/**
* Orders of all users that bill the app's subscriptions (e.g. ->pending() for unpaid ones).
*/
public function appOrders(): AppOrders
{
return new AppOrders();
}
/**
* Checkout sessions of the app for any user, e.g. to look one up from a checkout.* webhook
* (create() then needs a user_id).
*/
public function appCheckoutSessions(): AppCheckoutSessions
{
return new AppCheckoutSessions();
}
/**
* A link to a user's billing pages on Anikeen ID that needs no login, like Stripe's customer
* portal, e.g. for a "Manage billing" button of an app that bills another user's account (a
* team owner) without that user's token (trusted apps, scope subscriptions:client or
* orders:client). The link is single use and valid for about 15 minutes: create it when the
* user clicks and redirect right away, don't store it. It opens a portal session for that user
* only (no login, nobody signed in) with only the flow's billing pages and only what bills the
* app's subscriptions (of the group), leading back to return_url; it ends after an hour
* without activity.
*
* @param string $userId The user's Anikeen ID.
* @param array{
* return_url?: null|string,
* group?: null|string,
* flow?: null|string
* } $attributes return_url: "Back to the app" (same origin as one of the app's redirect URIs,
* 422 otherwise).
* group: only this group's subscriptions and the orders, invoices and
* transactions billing them.
* flow: overview (default), payment_method, payment_method_update (straight to
* replacing the payment method, e.g. after a failed payment), invoices,
* subscriptions or orders.
* @return object {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at}
*
* @throws ApiException 403 untrusted app or missing scope, 404 unknown user, 422 validation
*/
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
{
return AppRequest::send('POST', sprintf('v1/client/users/%s/billing-portal', $userId), $attributes)->data;
}
/**
* 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).
*
* With `confirm`, the user confirms and pays it on Anikeen ID instead (e.g. a top-up): the
* order stays a draft with `confirmation_url` (send the user there) and is cancelled if not
* confirmed within 24 hours (`expires_at`; afterwards checking it out answers 409
* "confirmation_expired"). Webhooks: order.created, order.completed once paid, or
* order.cancelled when the user declines or it expires.
*
* @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, image_url?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>,
* checkout?: bool,
* confirm?: bool,
* return_url?: null|string,
* metadata?: null|array,
* transaction_id?: string,
* transaction?: array,
* invoice_date?: string,
* currency_iso?: string
* } $attributes confirm: needs items, can't be combined with checkout or a transaction.
* return_url: where the user returns after confirming, with `status` and
* `order` (same origin as one of the app's redirect URIs, 422 otherwise).
* metadata: the app's own data (max. 50 keys), returned as is in the API and
* the order webhooks.
* @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]);
}
/**
* Book a charge onto a user's open monthly bill of the app, e.g. a shipped item: one line,
* no subscription, charged with the bill on the 1st. Trusted apps that Anikeen allowed to
* charge (with a maximum per charge), for users connected to the app (they authorised it or
* the app bills them). The user's billing limit is checked when it is booked.
*
* Retrying with the same idempotency key and the same fields returns the existing charge
* (also after the limit was reached); other fields with that key: 409
* "idempotency_key_conflict" (`charge_id` in body()).
*
* @param string $userId The user's Anikeen ID.
* @param array{
* idempotency_key: string,
* name: string,
* description?: null|string,
* type?: string,
* quantity: int,
* unit_price: float|int|string,
* currency_iso?: string,
* group?: null|string,
* occurred_at?: string,
* reference?: null|string,
* metadata?: null|array<string, string>,
* image_url?: null|string
* } $attributes idempotency_key: unique per app and user, ≤ 255 (e.g. "shipment-123").
* name: the line on the bill, ≤ 255. description: ≤ 255.
* type: digital (default), physical or physical-reduced (the VAT treatment).
* quantity: 1–10000 pieces. unit_price: net per piece, > 0, ≤ 100000, up to 6
* decimals; quantity × unit_price rounded to cents must be at least 0.01.
* currency_iso: EUR only (default).
* group: null, or a group in which the app already bills the user (a running
* subscription or an open monthly bill of the app).
* occurred_at: the service date, not in the future, at most 31 days ago
* (default now).
* reference: the app's own id (≤ 255). metadata: ≤ 20 keys (≤ 40 characters),
* string values ≤ 500. Both are not on the bill; put nothing sensitive there.
* image_url: http(s) URL of an image for the line.
*
* @throws ApiException 403 "client_not_allowed" (not a trusted app), "charges_not_enabled";
* 404 "user_not_found" (unknown, deleted or not connected); 409 "billing_limit_reached"
* (isBillingLimitReached(); body() has limit, used, remaining, remaining_net, required,
* currency, resets_at), "charge_exceeds_maximum" (+ max_charge_net),
* "group_not_allowed", "idempotency_key_conflict" (+ charge_id),
* "billing_address_missing"; 422 validation; 429 "too_many_requests"
*/
public function createChargeForUser(string $userId, array $attributes): AppCharge
{
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId), $attributes)->data);
}
/**
* A charge of the app (or of a client delegated to the same app) for a user.
*
* @throws ApiException 403 "client_not_allowed", "charges_not_enabled"; 404 "user_not_found",
* "charge_not_found"; 429 "too_many_requests"
*/
public function findChargeForUser(string $userId, string $chargeId): AppCharge
{
return new AppCharge(AppRequest::send('GET', $this->chargesPath($userId, $chargeId))->data);
}
/**
* Void a charge while its bill is open: the line is removed and the bill's total drops. An
* already voided charge is returned as it is. Once the bill is closed only a refund of the
* order by Anikeen credits it.
*
* @param string|null $reason why (≤ 255), stored with the charge
* @throws ApiException 409 "charge_already_billed" (+ charge_id); 404 "charge_not_found",
* "user_not_found"; 403 "client_not_allowed", "charges_not_enabled"; 429 "too_many_requests"
*/
public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge
{
$payload = $reason === null ? null : ['reason' => $reason];
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId, $chargeId) . '/void', $payload)->data);
}
/**
* A user's monthly billing limit as the app sees it, e.g. to pause paid features while it is
* reached. Advisory only: it reserves nothing; a charge is checked when it is booked. Trusted
* apps that may charge or that Anikeen allowed to read limits, for users connected to the app.
*
* @throws ApiException 403 "client_not_allowed", "billing_limit_not_enabled"; 404
* "user_not_found"; 429 "too_many_requests"
*/
public function billingLimitForUser(string $userId): BillingLimit
{
return new BillingLimit(AppRequest::send('GET', sprintf('v1/client/users/%s/billing-limit', rawurlencode($userId)))->data);
}
private function chargesPath(string $userId, ?string $chargeId = null): string
{
$path = sprintf('v1/client/users/%s/charges', rawurlencode($userId));
return $chargeId === null ? $path : $path . '/' . rawurlencode($chargeId);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\PreferenceRequest;
use GuzzleHttp\Exception\GuzzleException;
/**
* The user's shared preferences (`global`, e.g. SharedPreference::APPEARANCE), with the user's
* token (scope `preferences` or `preferences:read`). User tokens reach `global` only; an app's
* own namespace needs the app token (AnikeenId::preferencesByUserId()).
*/
trait ManagesUserPreferences
{
/**
* The user's shared preferences, sorted by key. Cached on this instance; set and delete clear it.
*
* @throws ApiException
*/
public function globalPreferences(): Preferences
{
$namespace = SharedPreference::NAMESPACE;
if (!isset($this->preferencesCache[$namespace])) {
$anikeenId = $this->anikeenId();
$this->preferencesCache[$namespace] = Preferences::builder(
fn () => $anikeenId->get(PreferenceRequest::path('v1/preferences', $namespace))
);
}
return $this->preferencesCache[$namespace];
}
/**
* The value of a shared preference, or $default when the user hasn't set it.
*
* @throws ApiException
*/
public function globalPreference(string $key, mixed $default = null): mixed
{
return $this->globalPreferences()->get($key, $default);
}
/**
* Set a shared preference of the user, e.g. setGlobalPreference(SharedPreference::APPEARANCE,
* Appearance::DARK). Only registered keys and values are accepted (422 "unknown_shared_key",
* "invalid_shared_value").
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException
*/
public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
$anikeenId = $this->anikeenId();
try {
return new Preference(fn () => $anikeenId->put(PreferenceRequest::path('v1/preferences', $namespace, $key), ['value' => $value], [], null, $headers));
} finally {
unset($this->preferencesCache[$namespace]);
}
}
/**
* Delete a shared preference of the user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
try {
$result = $this->anikeenId()->delete(PreferenceRequest::path('v1/preferences', $namespace, $key), [], [], null, $headers);
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
unset($this->preferencesCache[$namespace]);
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
}
}
+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);
}
}
+102 -9
View File
@@ -5,7 +5,10 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable;
trait ManagesUsers
@@ -24,25 +27,114 @@ trait ManagesUsers
}
/**
* Creates a new user on behalf of the current user.
* Finds the account of an email address or creates it (trusted apps, app token). The email
* address is matched regardless of case; a new account stores it in lower case, unverified.
* An existing account is returned unchanged (only a missing name is filled in).
*
* @param array{
* first_name: null|string,
* last_name: null|string,
* username: null|string,
* name?: null|string,
* first_name?: null|string,
* last_name?: null|string,
* username?: null|string,
* email: string,
* password: null|string
* password?: null|string,
* password_confirmation?: null|string,
* send_invitation?: bool
* } $attributes The user data
* - first_name: The first name (optional)
* - last_name: The last name (optional)
* - name: The display name (optional)
* - first_name: The first name (optional, joined with last_name into name)
* - last_name: The last name (optional, joined with first_name into name)
* - username: The username (optional)
* - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided)
* - password_confirmation: Defaults to password
* - send_invitation: Send the user an invitation mail with a link to set a password
* (optional, only when this call created the account without a
* password, never for an existing account)
* @return Result `data` is the user plus `created` (this call created the account) and
* `invitation_sent` (the invitation mail was sent); 201 when created, 200 when it existed
* @throws Throwable
*/
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);
}
/**
* Looks up the account of an email address without creating or changing anything (trusted
* apps, app token). The email address is matched regardless of case.
*
* `data` is `object{id: string, name: ?string, email: string, email_verified: bool,
* has_billing_address: bool}`.
*
* @return Result|null null when no account uses the email address
* @throws ApiException when the request fails for another reason
*/
public function findUserByEmail(string $email): ?Result
{
try {
return AppRequest::send('POST', 'v1/admin/users/lookup', ['email' => $email]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no account", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/**
* The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token),
* e.g. to catch up on a missed user.updated webhook. The email address is returned even while
* it isn't verified (emailVerified() is false then). No scope needed.
*
* @return User|null null when the user is unknown, deleted or not connected to your app
* (404 "user_not_found"; the three look the same)
* @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests",
* or another failure
*/
public function findUserById(string $userId): ?User
{
try {
return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no such user", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/**
* 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 +159,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,
]);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The values of the shared preference SharedPreference::APPEARANCE.
*/
class Appearance
{
/** Follow the operating system. */
const SYSTEM = 'system';
const LIGHT = 'light';
const DARK = 'dark';
/**
* @return list<string>
*/
public static function all(): array
{
return [self::SYSTEM, self::LIGHT, self::DARK];
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `cancellation_reason` of a subscription (and `data.reason` of `subscription.cancelled`): why
* Anikeen ID's billing ended it. Null when the user, the app or an admin cancelled it.
*/
class CancellationReason
{
/** Its free trial (`trial_payment_method` optional) ended without a payment method: cancelled at the trial's end, nothing billed. */
const TRIAL_ENDED_WITHOUT_PAYMENT_METHOD = 'trial_ended_without_payment_method';
}
+17
View File
@@ -33,8 +33,25 @@ class Scope
const TRANSACTIONS_READ = 'transactions:read';
const TRANSACTIONS_CLIENT = 'transactions:client';
// The user's own shared preferences (`global`). PREFERENCES_CLIENT labels an app token for
// the preferences of any user; access to a namespace is granted by Anikeen, not by the scope.
const PREFERENCES = 'preferences';
const PREFERENCES_READ = 'preferences:read';
const PREFERENCES_CLIENT = 'preferences:client';
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
// Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage),
// read-only. Changing products needs `user`; there is no full `products` scope.
const PRODUCTS_READ = 'products:read';
// Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not
// open these endpoints, a token must carry the reseller scope itself.
const RESELLER_CUSTOMERS = 'reseller-customers';
const RESELLER_CUSTOMERS_READ = 'reseller-customers:read';
const RESELLER_GRANTS = 'reseller-grants';
const RESELLER_ORDERS = 'reseller-orders';
const ADMIN = 'admin';
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The keys of the `global` preferences namespace, shared by all apps. Anikeen ID accepts only
* these keys there, each with its own values.
*/
class SharedPreference
{
/** The namespace of the shared preferences. */
const NAMESPACE = 'global';
/** Light or dark mode: one of the Appearance constants. */
const APPEARANCE = 'appearance';
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The state of a subscription, see Subscription::status() and AppSubscription::status().
*/
class SubscriptionStatus
{
/** Created, not checked out or confirmed by the user yet. */
const DRAFT = 'draft';
/** Running and billed, in its trial period. */
const TRIAL = 'trial';
/** Running and billed. */
const ACTIVE = 'active';
/** Paused: runs until its paid period ends, then expires. */
const PAUSED = 'paused';
/** Running until a scheduled cancellation takes effect. */
const CANCELLING = 'cancelling';
/** Paused and ran out: can be resumed (new period) or cancelled. */
const EXPIRED = 'expired';
/** Cancelled for good. */
const CANCELLED = 'cancelled';
/** None of the above. */
const INACTIVE = 'inactive';
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Enums;
/**
* `trial_payment_method` of a subscription with a free trial: whether the user needs a payment
* method to confirm it. Null on subscriptions without a trial.
*/
class TrialPaymentMethod
{
/** A payment method is needed to confirm the trial (the default). */
const REQUIRED = 'required';
/**
* The trial can be confirmed without a payment method (if nothing else has to be paid). If the
* user still has none when the trial ends, the subscription is cancelled then instead of billed
* (`cancellation_reason` CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD).
*/
const OPTIONAL = 'optional';
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Psr\Http\Message\ResponseInterface;
use Throwable;
/**
* A request to the ID service failed. `status` is the HTTP status (0 when the service could not
* be reached), `error` the service's stable error code when it sent one (e.g. "already_cancelled").
*/
class ApiException extends ResourceException
{
public function __construct(
string $message,
public readonly int $status = 0,
public readonly ?string $error = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status, $previous);
}
/**
* The exception for a failed request: Anikeen ID's message and error code, the HTTP status
* (0 when there was no response).
*/
public static function fromResult(Result $result): static
{
$body = is_object($result->data) ? $result->data : null;
$status = $result->response()?->getStatusCode() ?? 0;
// a failed conditional write; subclasses (CollectionException) keep their own type
if ($status === 412 && static::class === self::class) {
return PreconditionFailedException::fromResult($result);
}
// refused input, with per-field codes on some endpoints
if ($status === 422 && static::class === self::class) {
return ValidationFailedException::fromResult($result);
}
return new static(
rtrim((string) ($body->message ?? $result->error()), '.'),
$status,
$body->error ?? null,
$result->exception,
);
}
/**
* The exception for a request that got no response (network error, timeout).
*/
public static function unreachable(Throwable $previous): static
{
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
}
/**
* The JSON body of Anikeen ID's error response, null without one. Some errors carry details,
* e.g. `net_amount` of "elapsed_amount_exceeded".
*/
public function body(): ?object
{
$previous = $this->getPrevious();
$response = $previous !== null && method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
if (!$response instanceof ResponseInterface) {
return null;
}
$body = json_decode((string) $response->getBody());
return is_object($body) ? $body : null;
}
public function isNotFound(): bool
{
return $this->status === 404;
}
public function isConflict(): bool
{
return $this->status === 409;
}
/**
* A charge was refused because it would take the user's open monthly bill above their billing
* limit (409 "billing_limit_reached"). body() has limit, used, remaining, remaining_net,
* required, currency and resets_at.
*/
public function isBillingLimitReached(): bool
{
return $this->status === 409 && $this->error === 'billing_limit_reached';
}
/**
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/
public function isTemporary(): bool
{
return $this->status === 0 || $this->status >= 500 || $this->status === 429;
}
}
+5 -5
View File
@@ -2,10 +2,10 @@
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class CollectionException extends Exception
/**
* A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
* Anikeen ID's error code.
*/
class CollectionException extends ApiException
{
}
@@ -0,0 +1,17 @@
<?php
namespace Anikeen\Id\Exceptions;
use Throwable;
/**
* Thrown when a third-party app tries to check out an order or subscription itself: the user has
* to confirm it on Anikeen ID. Send them to the confirmation URL instead.
*/
class ConfirmationRequiredException extends ResourceException
{
public function __construct(string $message, int $code, public readonly ?string $confirmationUrl = null, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,21 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* A conditional write failed (412): the stored version wasn't the one sent with `If-Match`
* ("preference_version_mismatch"), or the key was already set and the write was create-only
* ("preference_exists"). Reload, merge and retry with currentVersion().
*/
class PreconditionFailedException extends ApiException
{
/**
* The version stored now, null when the key isn't set (or Anikeen ID didn't send one).
*/
public function currentVersion(): ?int
{
$version = $this->body()->current_version ?? null;
return is_numeric($version) ? (int) $version : null;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Anikeen\Id\Exceptions;
use Exception;
/**
* The webhook request was not signed by Anikeen ID with your endpoint's secret, or is too old.
*/
class SignatureVerificationException extends Exception
{
//
}
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* Anikeen ID refused the input (422). `error` is the first code; endpoints with per-field codes
* (the profile API) send them as `codes`: `{field: code}`, e.g. `["username" => "username_taken"]`.
* Branch on the codes, the messages are for people and may change.
*/
class ValidationFailedException extends ApiException
{
/**
* @return array<string, string> field => code; empty when the endpoint sends none
*/
public function errorCodes(): array
{
$codes = $this->body()->codes ?? null;
return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : [];
}
/**
* The code of $field, or of its first failed sub-field when the codes are keyed by those
* (`crop` finds `crop.x`, `crop.y` or `crop.size`).
*/
public function errorCodeFor(string $field): ?string
{
$codes = $this->errorCodes();
if (isset($codes[$field])) {
return $codes[$field];
}
foreach ($codes as $key => $code) {
if (str_starts_with((string) $key, $field . '.')) {
return $code;
}
}
return null;
}
/**
* Whether any field failed with $code, or the response's `error` is $code.
*/
public function hasErrorCode(string $code): bool
{
return $this->error === $code || in_array($code, $this->errorCodes(), true);
}
/**
* The messages per field (`errors`), for showing them to people.
*
* @return array<string, list<string>>
*/
public function errors(): array
{
$errors = $this->body()->errors ?? null;
return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : [];
}
}
+16 -2
View File
@@ -26,9 +26,23 @@ trait HasAnikeenTokens
*/
public function anikeenTokenCan(string $scope): bool
{
$scopes = $this->accessToken ? $this->accessToken->scopes : [];
$scopes = (array) ($this->accessToken->scopes ?? []);
return in_array('*', $scopes) || in_array($scope, $this->accessToken->scopes);
return in_array('*', $scopes, true) || in_array($scope, $scopes, true);
}
/**
* Determine if the current API token has any of the given scopes.
*/
public function anikeenTokenCanAny(array $scopes): bool
{
foreach ($scopes as $scope) {
if ($this->anikeenTokenCan($scope)) {
return true;
}
}
return false;
}
/**
+52 -10
View File
@@ -7,32 +7,74 @@ use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use RuntimeException;
use stdClass;
use Throwable;
/**
* Verifies access tokens issued by Anikeen ID (Passport, RS256): signature against
* Anikeen ID's public key plus the time claims (exp, nbf, iat; 60 seconds leeway).
*
* The key is `services.anikeen.public_key` (PEM, "\n" escapes allowed) or the file at
* `services.anikeen.public_key_path`, else the key shipped with this package for the
* configured mode (production / staging). It is read once per process.
*/
class JwtParser
{
/**
* Public keys already read, by source.
*
* @var array<string, string>
*/
private static array $keys = [];
/**
* @throws AuthenticationException
*/
public function decode(Request $request): stdClass
{
return $this->decodeToken((string) $request->bearerToken());
}
/**
* @throws AuthenticationException
*/
public function decodeToken(string $token): stdClass
{
if ($token === '') {
throw new AuthenticationException('No access token given.');
}
JWT::$leeway = 60;
// outside the try: a missing key is a configuration error, not an invalid token
$key = new Key($this->getOauthPublicKey(), 'RS256');
try {
return JWT::decode(
$request->bearerToken(),
new Key($this->getOauthPublicKey(), 'RS256')
);
} catch (Throwable $exception) {
throw (new AuthenticationException());
return JWT::decode($token, $key);
} catch (Throwable) {
throw new AuthenticationException('The access token is invalid or expired.');
}
}
private function getOauthPublicKey(): bool|string
/**
* Forget the keys read so far (e.g. after changing the configuration in tests).
*/
public static function flushKeys(): void
{
return AnikeenId::getMode() === 'staging'
? file_get_contents(dirname(__DIR__, 3) . '/oauth-public.staging.key')
: file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key');
self::$keys = [];
}
protected function getOauthPublicKey(): string
{
if ($key = config('services.anikeen.public_key')) {
return self::$keys['config:' . md5($key)] ??= str_replace('\\n', "\n", $key);
}
$path = config('services.anikeen.public_key_path') ?: (AnikeenId::getMode() === 'staging'
? dirname(__DIR__, 3) . '/oauth-public.staging.key'
: dirname(__DIR__, 3) . '/oauth-public.key');
return self::$keys['file:' . $path] ??= (is_readable($path) ? (string) file_get_contents($path) : throw new RuntimeException("Anikeen ID public key [$path] is not readable."));
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response;
class CheckForAnyScope extends UseParameters
{
@@ -17,7 +17,7 @@ class CheckForAnyScope extends UseParameters
*/
public function handle(Request $request, Closure $next, ...$scopes): Response
{
if (!$request->user() || !$request->user()->anikeenToken()) {
if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
throw new AuthenticationException;
}
+2 -2
View File
@@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException;
use Closure;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response;
class CheckScopes extends UseParameters
{
@@ -17,7 +17,7 @@ class CheckScopes extends UseParameters
*/
public function handle(Request $request, Closure $next, ...$scopes): Response
{
if (!$request->user() || !$request->user()->anikeenToken()) {
if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) {
throw new AuthenticationException;
}
+34 -13
View File
@@ -8,8 +8,11 @@ use Anikeen\Id\Auth\UserProvider;
use Anikeen\Id\Contracts;
use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Repository;
use Illuminate\Auth\AuthManager;
use Illuminate\Auth\RequestGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request;
use InvalidArgumentException;
use Illuminate\Support\ServiceProvider;
class AnikeenIdServiceProvider extends ServiceProvider
@@ -38,15 +41,28 @@ class AnikeenIdServiceProvider extends ServiceProvider
}
/**
* Register the token guard.
* Register the `anikeen` guard driver and user provider.
*
* Laravel binds non-static extend() closures to the AuthManager, so these closures are
* static and use nothing but their arguments.
*/
protected function registerGuard(): void
{
Auth::resolved(function ($auth) {
$auth->extend('anikeen', function ($app, $name, array $config) {
return tap($this->makeGuard($config), function ($guard) {
$this->app->refresh('request', $guard, 'setRequest');
});
$this->callAfterResolving('auth', static function (AuthManager $auth): void {
$auth->provider('anikeen', static fn (Application $app, array $config) => new AnikeenIdUserProvider(
$app->make(AnikeenId::class),
$app->make('request'),
$config['model'] ?? $app['config']->get('auth.providers.users.model'),
$config['fields'] ?? [],
$config['column'] ?? null,
));
$auth->extend('anikeen', static function (Application $app, string $name, array $config): RequestGuard {
$guard = AnikeenIdServiceProvider::makeGuard($app, $config);
$app->refresh('request', $guard, 'setRequest');
return $guard;
});
});
}
@@ -54,15 +70,20 @@ class AnikeenIdServiceProvider extends ServiceProvider
/**
* Make an instance of the token guard.
*/
protected function makeGuard(array $config): RequestGuard
public static function makeGuard(Application $app, array $config): RequestGuard
{
return new RequestGuard(function ($request) use ($config) {
$providerName = $config['provider'] ?? $app['config']->get('auth.defaults.provider', 'users');
$provider = $app['auth']->createUserProvider($providerName)
?? throw new InvalidArgumentException("Authentication user provider [$providerName] is not configured.");
$providerConfig = $app['config']->get("auth.providers.$providerName", []);
return new RequestGuard(static function (Request $request) use ($app, $provider, $providerName, $providerConfig, $config) {
return (new TokenGuard(
new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']),
$this->app->make('encrypter'),
$this->app->make(JwtParser::class)
new UserProvider($provider, $providerName, $config['column'] ?? $providerConfig['column'] ?? null),
$app->make('encrypter'),
$app->make(JwtParser::class)
))->user($request);
}, $this->app['request']);
}, $app['request'], $provider);
}
/**
+13 -1
View File
@@ -18,7 +18,8 @@ class AnikeenIdUserProvider implements UserProvider
private AnikeenId $anikeenId,
private Request $request,
private string $model,
private array $fields = []
private array $fields = [],
private ?string $column = null,
) {
$this->accessTokenField = AnikeenId::getAccessTokenField();
}
@@ -97,6 +98,17 @@ class AnikeenIdUserProvider implements UserProvider
->first();
}
/**
* Find the user of an Anikeen ID (the `sub` of an access token) by the provider's `column`,
* else `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`).
*/
public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable
{
$column = $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField());
return $this->newModelQuery()->where($column, $anikeenId)->first();
}
/**
* {@inheritDoc}
*/
+8
View File
@@ -32,6 +32,14 @@ class AppTokenRepository implements Repository
return $this->requestFreshAccessToken('*');
}
/**
* Drop the cached token, e.g. after the ID service rejected it.
*/
public function forgetAccessToken(): void
{
Cache::forget(self::ACCESS_TOKEN_CACHE_KEY);
}
/**
* @throws RequestFreshAccessTokenException
*/
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A charge a trusted app booked onto a user's open monthly bill, see
* AnikeenId::createChargeForUser(). Amounts are decimal strings (`unit_price` with six decimals,
* the others with two).
*
* @property string $object "charge"
* @property string $id
* @property string $user_id
* @property string $status open (its bill is open), billed (the bill was closed) or voided
* @property string $idempotency_key
* @property string $name the line on the bill
* @property ?string $description
* @property string $type digital, physical or physical-reduced (the VAT treatment)
* @property int $quantity
* @property string $unit "piece"
* @property string $unit_price net per piece
* @property string $net_amount
* @property string $gross_amount what the charge added to the bill, VAT included
* @property string $currency_iso
* @property ?string $group
* @property string $occurred_at the line's service date (ISO 8601)
* @property ?string $reference the app's own id
* @property object $metadata the app's own data (string values)
* @property ?string $order_id the monthly bill (order) the line is on; follows the line when a rollover moves it
* @property ?string $voided_at
* @property ?string $void_reason
* @property string $created_at
*/
class AppCharge implements JsonSerializable
{
public const STATUS_OPEN = 'open';
public const STATUS_BILLED = 'billed';
public const STATUS_VOIDED = 'voided';
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
public function id(): string
{
return $this->data->id;
}
/**
* open, billed or voided (the STATUS_* constants).
*/
public function status(): string
{
return $this->data->status;
}
/**
* Its bill is still open: the charge can be voided.
*/
public function isOpen(): bool
{
return $this->status() === self::STATUS_OPEN;
}
/**
* Its bill was closed (paid, waiting for payment or cancelled): only a refund of the order by
* Anikeen credits it.
*/
public function isBilled(): bool
{
return $this->status() === self::STATUS_BILLED;
}
public function isVoided(): bool
{
return $this->status() === self::STATUS_VOIDED;
}
/**
* Net, e.g. "8.00".
*/
public function netAmount(): string
{
return $this->data->net_amount;
}
/**
* Gross, what the charge added to the bill, e.g. "9.52".
*/
public function grossAmount(): string
{
return $this->data->gross_amount;
}
/**
* The monthly bill (order) the charge is on, null once its line is gone (voided).
*/
public function orderId(): ?string
{
return $this->data->order_id ?? null;
}
/**
* @return array<string, string>
*/
public function metadata(): array
{
return (array) ($this->data->metadata ?? []);
}
public function occurredAt(): ?DateTimeImmutable
{
return isset($this->data->occurred_at) ? new DateTimeImmutable($this->data->occurred_at) : null;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\IsCheckoutSession;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use JsonSerializable;
/**
* A checkout session created with the app's own token, see AppCheckoutSessions.
*
* @property string $id
* @property string $user_id
* @property ?string $oauth_client_id
* @property string $status open, completed, cancelled or expired
* @property ?string $return_url
* @property ?object $metadata
* @property ?string $confirmation_url
* @property list<string> $subscription_ids
* @property list<object> $subscriptions
* @property ?string $expires_at
* @property ?string $completed_at
* @property ?string $cancelled_at
* @property ?string $created_at
*/
class AppCheckoutSession implements JsonSerializable
{
use IsCheckoutSession;
public const STATUS_OPEN = 'open';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_EXPIRED = 'expired';
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* The session's subscriptions (drafts until the user confirms).
*
* @return list<AppSubscription>
*/
public function subscriptions(): array
{
return array_map(fn (object $subscription) => new AppSubscription($subscription), array_values((array) ($this->data->subscriptions ?? [])));
}
/**
* Withdraw an open session (e.g. the cart changed): it and its drafts are cancelled.
*
* @throws ApiException 409 "checkout_session_completed", "checkout_session_cancelled", "checkout_session_expired"
*/
public function cancel(): self
{
return $this->send('POST', '/cancel');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/checkout-sessions/%s%s', $this->id, $action))->data;
return $this;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Checkout sessions created with the app's own token (trusted apps, scope `subscriptions:client`):
* several subscriptions of one user that the user confirms together on Anikeen ID, instead of the
* app checking them out itself. For one user ($user->appCheckoutSessions()) or, to look sessions
* up e.g. from a webhook, for all users (app(AnikeenId::class)->appCheckoutSessions()).
* Nothing is requested until a method is called.
*/
class AppCheckoutSessions
{
public function __construct(protected Billable|Model|null $billable = null)
{
}
/**
* Open a checkout session with new subscriptions (their attributes, like
* AppSubscriptions::create()) and/or existing drafts of the app (`['id' => …]`).
*
* @param array{
* subscriptions: list<array{id: string}|array{
* group?: null|string,
* name: string,
* description?: null|string,
* unit: string,
* price: float|string,
* monthly_cap?: null|float|string,
* predecessor_id?: null|string,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string,
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* }>,
* return_url?: null|string,
* metadata?: null|array,
* idempotency_key?: null|string,
* user_id?: string
* } $attributes return_url must share scheme, host and port with one of the app's redirect
* URIs; the user comes back to it with `status` and `checkout_session`. A
* repeated idempotency_key returns the first session instead of a new one.
* trial_days / trial_ends_at give a subscription a free trial from the user's
* confirmation on; trial_once is allowed on one subscription per group.
* trial_payment_method `optional`: the session can be confirmed without a
* payment method only if all of its subscriptions are such trials.
* discount: see AppSubscriptions::create(); the confirmation page shows it.
*
* @throws ApiException 422 validation (e.g. return_url), 409 "idempotency_key_conflict"
*/
public function create(array $attributes): AppCheckoutSession
{
if ($this->billable) {
$attributes['user_id'] = (new AppSubscriptions($this->billable))->userId();
} elseif (empty($attributes['user_id'])) {
throw new InvalidArgumentException('A checkout session needs a user: use $user->appCheckoutSessions() or pass user_id.');
}
return new AppCheckoutSession(AppRequest::send('POST', 'v1/client/checkout-sessions', $attributes)->data);
}
/**
* A session of the app; null when it doesn't exist (or belongs to another user).
*
* @throws ApiException
*/
public function find(string $id): ?AppCheckoutSession
{
try {
$session = new AppCheckoutSession(AppRequest::send('GET', sprintf('v1/client/checkout-sessions/%s', $id))->data);
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return !$this->billable || $session->user_id === (new AppSubscriptions($this->billable))->userId() ? $session : null;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
/**
* Orders that bill the app's subscriptions, read with the app's own token (trusted apps,
* scope orders:client) - of one user ($user->appOrders()) or of all users
* (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called.
*
* Each order is an object with id, user_id, oauth_client_id (the app that bills it: collective
* orders are kept per app, even for the same group; null on legacy orders that billed several
* apps), group, metadata, status (processing|pending|completed|cancelled), is_collective,
* net_total, vat_total, gross_total, currency_iso, created_at, paid_at, payment_attempts,
* next_payment_attempt_at, last_payment_error ({code, message} or null), and
*
* - items: the order's lines of the app's subscriptions (all lines, except on legacy orders that
* also billed other apps), each {id, name, description, type, image_url, units, unit,
* price, vat_rate, subscription_id, meter, monthly_cap, discount, period_start, period_end,
* net_amount, vat_amount, gross_amount}. period_end is exclusive (the start of the next
* period); both are null for lines without a period. discount ({label, percent_off,
* amount_off}) marks the negative line of a subscription's discount, else null. The net amount
* a subscription was billed is the sum of net_amount of its lines.
* - invoice: null until the order is paid, then {id, number, type, status (pending while the PDF
* is being rendered, then completed), service_period_from, service_period_to, created_at,
* pdf_url}; see invoicePdf().
*/
class AppOrders
{
public function __construct(protected Billable|Model|null $billable = null)
{
}
/**
* All matching orders, oldest first.
*
* @param array{status?: string, group?: string} $filters
* @return list<object>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$orders = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/orders', null, [
...$filters,
...($this->billable ? ['user_id' => $this->userId()] : []),
'per_page' => 100,
'page' => $page,
]);
array_push($orders, ...$result->data);
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $orders;
}
/**
* Closed orders that aren't paid (yet), oldest first.
*
* @return list<object>
*
* @throws ApiException
*/
public function pending(array $filters = []): array
{
return $this->all([...$filters, 'status' => 'pending']);
}
/**
* @throws ApiException
*/
public function find(string $id): ?object
{
try {
$order = AppRequest::send('GET', sprintf('v1/client/orders/%s', $id))->data;
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return !$this->billable || $order->user_id === $this->userId() ? $order : null;
}
/**
* The PDF of an order's invoice (binary string, with the embedded e-invoice), the same as its
* `invoice.pdf_url` with the app's token.
*
* @throws ApiException 404 "order_not_found", 404 "invoice_not_found" (not paid yet),
* 409 "invoice_not_ready" (still being rendered, try again later)
*/
public function invoicePdf(string $orderId): string
{
if ($this->billable && !$this->find($orderId)) {
throw new ApiException('Order not found.', 404, 'order_not_found');
}
return (string) AppRequest::send('GET', sprintf('v1/client/orders/%s/invoice', $orderId))->response()?->getBody();
}
private function userId(): string
{
return (new AppSubscriptions($this->billable))->userId();
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use InvalidArgumentException;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property ?float $vat_rate
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property ?string $trial_payment_method required or optional with a trial (TrialPaymentMethod), null without
* @property ?string $cancellation_reason why the billing cancelled it (CancellationReason), null otherwise
* @property ?object $discount {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}; periods_remaining is null for `forever`, 0 once it ended
* @property ?string $starts_at
* @property ?string $ends_at
* @property ?string $accounting_at
* @property bool $is_past_due
* @property int $failed_renewal_retries
* @property ?array $payload
* @property ?string $idempotency_key
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any
* @property ?object $scheduled_change {applies_at, name, description, unit, price, discount, created_at}: a change() for the period end
*/
class AppSubscription implements JsonSerializable
{
use HasSubscriptionStatus;
/** change(): prorated, right away. */
public const APPLY_NOW = 'now';
/** change(): from the next period on (scheduled_change until then). */
public const APPLY_PERIOD_END = 'period_end';
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* Change a subscription (send only what changes). Before checkout everything can change;
* afterwards only the description, which names what the customer pays for and is shown
* on their bills, e.g. `$subscription->update(['description' => 'web-01.example.com'])`.
* Prices of running subscriptions change with change().
*
* @throws ApiException
*/
public function update(array $attributes): self
{
return $this->send('PUT', '', $attributes);
}
/**
* Change a running subscription (send only what changes), like a plan change:
*
* - `apply: now`: prorated – the unused rest of the current period is credited at the rate it
* was billed at and charged at the new price on the user's open bill. A new unit starts a
* new, fully billed period now. A downgrade whose credit exceeds the open bill is refused
* (409 "credit_exceeds_bill"): schedule it for the period end instead.
* - `apply: period_end`: nothing changes now; the subscription has `scheduled_change` and the
* next period is billed at the new values. A new scheduled change replaces the previous one.
*
* During a free trial nothing was billed, so both just change what the trial ends in.
* Webhook `subscription.updated` (with `data.previous_attributes`) when the change takes effect.
*
* @param array{
* apply: string,
* price?: float|string,
* unit?: string,
* name?: string,
* description?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* } $attributes apply: self::APPLY_NOW or self::APPLY_PERIOD_END.
* unit: a recurring unit (hour, day, week, month, year).
* discount: replaces the discount from the next billed period on, null ends it;
* required with a new unit when the current one counts in periods or is an
* amount_off. Subscriptions with a monthly cap change only at the period end.
*
* @throws ApiException 409 "not_changeable" (not running: draft, paused, cancelling, …),
* "proration_not_possible", "credit_exceeds_bill",
* "billing_address_missing"; 422 validation
*/
public function change(array $attributes): self
{
return $this->send('PUT', '/change', $attributes);
}
/**
* Drop the change scheduled for the period end (nothing happens without one).
*
* @throws ApiException
*/
public function cancelScheduledChange(): self
{
return $this->send('DELETE', '/change');
}
/**
* Activate the subscription and bill its first period.
*
* $startsAt (in the past) books a service that is already running, e.g. a domain registered
* earlier: the first period runs from $startsAt, is billed now and renews when it ends.
*
* A $startsAt older than one period is refused ("period_already_ended") unless
* $billElapsedPeriods is set: then the periods that have already ended are billed too, together
* with the running one, as long as they come to at most $maxNetAmount (net, up to two
* decimals) and $startsAt lies within the maximum age Anikeen ID accepts (about three years).
* The lines land on the current month's collective order, which is charged on the 1st of the
* next month. $billElapsedPeriods needs both $startsAt and $maxNetAmount.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended",
* "elapsed_periods_not_allowed" (the elapsed periods can't be billed for this subscription),
* "elapsed_amount_exceeded" (they come to more than $maxNetAmount; the response body has
* the booked `net_amount`), …; 422 invalid input (e.g. a start older than the maximum age)
* @throws InvalidArgumentException $billElapsedPeriods without $startsAt or $maxNetAmount
*/
public function checkout(
DateTimeInterface|string|null $startsAt = null,
bool $billElapsedPeriods = false,
float|string|null $maxNetAmount = null,
): self {
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
if (!$billElapsedPeriods) {
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
if ($startsAt === null || $startsAt === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a start date ($startsAt).');
}
if ($maxNetAmount === null || $maxNetAmount === '') {
throw new InvalidArgumentException('Billing elapsed periods needs a maximum net amount ($maxNetAmount).');
}
return $this->send('PUT', '/checkout', [
'starts_at' => $startsAt,
'bill_elapsed_periods' => true,
// floats from calculations may carry binary noise (e.g. 59.970000000000006)
'max_net_amount' => is_float($maxNetAmount) ? round($maxNetAmount, 2) : $maxNetAmount,
]);
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/**
* Subscriptions of a user, managed with the app's own token (client credentials, scope
* `subscriptions:client`) instead of the user's token, so they keep working when the user's
* login expired. The app has to be trusted by the ID service. Nothing is requested until a
* method is called.
*/
class AppSubscriptions
{
public function __construct(protected Billable|Model $billable)
{
}
/**
* The user's Anikeen ID (see AnikeenId::useUserIdField()).
*/
public function userId(): string
{
$userId = $this->billable->{AnikeenId::getUserIdField()} ?? null;
if (empty($userId)) {
throw new InvalidArgumentException(sprintf('The billable has no Anikeen ID in [%s].', AnikeenId::getUserIdField()));
}
return (string) $userId;
}
/**
* Create a subscription for the user (it is billed only after checkout()).
*
* @param array{
* group?: null|string,
* name: string,
* description?: null|string,
* unit: string,
* price: float|string,
* monthly_cap?: null|float|string,
* predecessor_id?: null|string,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string,
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int},
* idempotency_key?: null|string
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
* subscription created by the first one instead of a duplicate.
* monthly_cap: net, at most this per calendar month (hour and day units only,
* at most 2 decimals, not below price; null = no cap).
* predecessor_id: the subscription this one replaces (a resize; same user and
* app, checked out, not cancelled): both share the monthly cap within a month.
* trial_days: a free trial of 1–365 days from the checkout on (status `trial`,
* nothing billed; the first period is billed when it ends), or
* trial_ends_at: a fixed end of the trial (ISO 8601, in the future), not both.
* trial_once: refuse the trial if the user already had one with the app in the
* same group (422 here, 409 "trial_already_used" on checkout).
* trial_payment_method: TrialPaymentMethod::REQUIRED (default) or ::OPTIONAL,
* only with a trial: with `optional` the user may confirm it without a payment
* method, and without one at the trial's end it is cancelled instead of billed.
* discount: e.g. a coupon, as a negative line next to each billed period:
* label (shown to the user, max. 100 characters), percent_off (0.01–100) or
* amount_off (net per period, at most the price), duration `once` (the first
* billed period), `repeating` (duration_in_periods billed periods) or
* `forever`. Not combinable with a monthly_cap.
*
* @throws ApiException 422 validation, 409 "idempotency_key_conflict", …
*/
public function create(array $attributes): AppSubscription
{
return new AppSubscription(AppRequest::send('POST', 'v1/client/subscriptions', [
...$attributes,
'user_id' => $this->userId(),
])->data);
}
/**
* Find one of the user's subscriptions; null when it doesn't exist (or belongs to someone else).
*
* @throws ApiException
*/
public function find(string $id): ?AppSubscription
{
try {
$subscription = new AppSubscription(AppRequest::send('GET', sprintf('v1/client/subscriptions/%s', $id))->data);
} catch (ApiException $e) {
if ($e->isNotFound()) {
return null;
}
throw $e;
}
return $subscription->user_id === $this->userId() ? $subscription : null;
}
/**
* All of the user's subscriptions, optionally filtered.
*
* @param array{group?: string, active?: bool, cancelled?: bool} $filters
* @return list<AppSubscription>
*
* @throws ApiException
*/
public function all(array $filters = []): array
{
$subscriptions = [];
$page = 1;
do {
$result = AppRequest::send('GET', 'v1/client/subscriptions', null, [
...array_map(fn ($value) => is_bool($value) ? (int) $value : $value, $filters),
'user_id' => $this->userId(),
'per_page' => 100,
'page' => $page,
]);
foreach ($result->data as $data) {
$subscriptions[] = new AppSubscription($data);
}
$lastPage = (int) ($result->meta->last_page ?? $page);
} while ($page++ < $lastPage);
return $subscriptions;
}
}
+7 -1
View File
@@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Helpers\Paginator;
use Anikeen\Id\Result;
use Closure;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Response;
use JsonSerializable;
use Throwable;
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
*/
protected function __construct(callable $callable)
{
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw CollectionException::unreachable($e);
}
if (!$this->result->success()) {
throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
throw CollectionException::fromResult($this->result);
}
}
+19 -3
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable;
#[\AllowDynamicProperties]
@@ -12,14 +14,28 @@ abstract class BaseResource implements JsonSerializable
public Result $result;
/**
* @throws ResourceException
* @throws ApiException the request failed (status, error code; a ResourceException)
* @throws ConfirmationRequiredException the user has to confirm it on Anikeen ID
*/
public function __construct(callable $callable)
{
try {
$this->result = $callable();
} catch (GuzzleException $e) {
// no response at all (connection refused, timeout)
throw ApiException::unreachable($e);
}
if (!$this->result->success()) {
throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
if (($this->result->data->error ?? null) === 'confirmation_required') {
throw new ConfirmationRequiredException(
$this->result->data->message ?? 'The user has to confirm this on Anikeen ID.',
$this->result->response->getStatusCode(),
$this->result->data->confirmation_url ?? null,
);
}
throw ApiException::fromResult($this->result);
}
foreach ($this->result->data as $key => $value) {
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's monthly billing limit: one gross limit for their whole open monthly bill across all
* Anikeen services, see AnikeenId::billingLimitForUser() and the `billing_limit` key of
* billingOverview(). Amounts are decimal strings with two decimals.
*
* Without a limit `limit`, `remaining` and `remaining_net` are null; for an app `used` is null
* too (an app sees the user's whole open bill only while they have set a limit). `used_by_app`
* (the open bills of your app) is there only when an app reads the limit.
*
* @property string $object "billing_limit"
* @property string $user_id
* @property string $currency "EUR"
* @property string $basis "gross"
* @property ?string $limit
* @property ?string $used the user's open monthly bills across all Anikeen services
* @property ?string $used_by_app the open monthly bills of your app (app reads only)
* @property ?string $remaining max(0, limit − used)
* @property ?string $remaining_net remaining at the user's VAT rate, rounded down (a hint)
* @property int|float|null $vat_rate null without a billing address
* @property bool $reached used >= limit: start no new paid service
* @property string $resets_at when this month's bills are closed (ISO 8601)
* @property int $version only goes up; keep the highest seen (also in the billing_limit.* webhooks)
*/
class BillingLimit implements JsonSerializable
{
public function __construct(protected object $data)
{
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
/**
* The user has set a limit (`0.00` is one too: no new charges at all).
*/
public function hasLimit(): bool
{
return $this->limit() !== null;
}
/**
* Gross, null without a limit.
*/
public function limit(): ?string
{
return $this->data->limit ?? null;
}
/**
* The user's open monthly bills across all Anikeen services (gross). Null for an app while the
* user has no limit.
*/
public function used(): ?string
{
return $this->data->used ?? null;
}
/**
* The open monthly bills of your app (gross); null outside an app read (billingOverview()).
* `used − used_by_app` is what the user spends on their other Anikeen services.
*/
public function usedByApp(): ?string
{
return $this->data->used_by_app ?? null;
}
/**
* Gross room left (never negative), null without a limit.
*/
public function remaining(): ?string
{
return $this->data->remaining ?? null;
}
/**
* The room left as a net amount at the user's VAT rate, rounded down: a hint only (the VAT of
* a charge depends on its type). Null without a limit or a billing address.
*/
public function remainingNet(): ?string
{
return $this->data->remaining_net ?? null;
}
/**
* The user's VAT rate in percent (e.g. 19.0), null without a billing address.
*/
public function vatRate(): ?float
{
return isset($this->data->vat_rate) ? (float) $this->data->vat_rate : null;
}
public function currency(): string
{
return $this->data->currency ?? 'EUR';
}
/**
* The limit is reached (used >= limit): start no new paid service for the user.
*/
public function isReached(): bool
{
return (bool) ($this->data->reached ?? false);
}
/**
* When this month's bills are closed and `used` drops.
*/
public function resetsAt(): ?DateTimeImmutable
{
return isset($this->data->resets_at) ? new DateTimeImmutable($this->data->resets_at) : null;
}
/**
* Only goes up: keep the highest one seen and ignore webhooks with a lower or equal one.
*/
public function version(): int
{
return (int) ($this->data->version ?? 0);
}
/**
* Whether a charge of $gross (gross, e.g. "4.76") would fit as of this read: always without a
* limit, else at most `remaining`. Advisory only: it reserves nothing, the binding check is
* the booking (409 "billing_limit_reached").
*/
public function allows(string|int|float $gross): bool
{
if (!$this->hasLimit()) {
return true;
}
$remaining = $this->remaining();
return $remaining !== null && self::compare($gross, $remaining) <= 0;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Compares two non-negative decimal amounts exactly (as digit strings, no float rounding);
* negative amounts count as zero.
*/
private static function compare(string|int|float $a, string|int|float $b): int
{
return strcmp(self::micros($a), self::micros($b)) <=> 0;
}
/**
* The amount in millionths as a zero-padded digit string of a fixed width.
*/
private static function micros(string|int|float $amount): string
{
$amount = is_float($amount) ? number_format($amount, 6, '.', '') : trim((string) $amount);
if (str_starts_with($amount, '-')) {
return str_repeat('0', 40);
}
[$whole, $fraction] = array_pad(explode('.', ltrim($amount, '+'), 2), 2, '');
$digits = preg_replace('/\D/', '', $whole) . substr(str_pad(preg_replace('/\D/', '', $fraction), 6, '0'), 0, 6);
return str_pad(ltrim($digits, '0'), 40, '0', STR_PAD_LEFT);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\IsCheckoutSession;
use Throwable;
/**
* A checkout session created with the user's access token, see CheckoutSessions.
*
* @property string $id
* @property string $user_id
* @property ?string $oauth_client_id
* @property string $status open, completed, cancelled or expired
* @property ?string $return_url
* @property ?object $metadata
* @property ?string $confirmation_url
* @property list<string> $subscription_ids
* @property list<object> $subscriptions
* @property ?string $expires_at
* @property ?string $completed_at
* @property ?string $cancelled_at
* @property ?string $created_at
*/
class CheckoutSession extends BaseResource
{
use HasBillable;
use IsCheckoutSession;
public const STATUS_OPEN = 'open';
public const STATUS_COMPLETED = 'completed';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_EXPIRED = 'expired';
/**
* Withdraw an open session: it and its drafts are cancelled.
*
* @throws Throwable
*/
public function cancel(): self
{
return (new self(fn () => $this->billable->anikeenId()
->request('POST', sprintf('v1/checkout-sessions/%s/cancel', $this->id))))
->setBillable($this->billable);
}
/**
* @throws Throwable
*/
public function refresh(): self
{
return (new self(fn () => $this->billable->anikeenId()
->request('GET', sprintf('v1/checkout-sessions/%s', $this->id))))
->setBillable($this->billable);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use Illuminate\Database\Eloquent\Model;
use Throwable;
/**
* Checkout sessions of the signed-in user, with the user's access token (any app): several
* subscriptions the user confirms together on Anikeen ID. Nothing is requested until a method is
* called.
*/
class CheckoutSessions
{
use HasBillable;
public function __construct(Billable|Model $billable)
{
$this->billable = $billable;
}
/**
* Open a checkout session with new subscriptions and/or existing drafts of the app
* (`['id' => …]`); see AppCheckoutSessions::create() for the attributes (no user_id here,
* vat_rate only for trusted apps).
*
* @throws Throwable
*/
public function create(array $attributes): CheckoutSession
{
return (new CheckoutSession(fn () => $this->billable->anikeenId()
->request('POST', 'v1/checkout-sessions', $attributes)))
->setBillable($this->billable);
}
/**
* A session the app created for the user; null when it doesn't exist.
*
* @throws Throwable
*/
public function find(string $id): ?CheckoutSession
{
try {
return (new CheckoutSession(fn () => $this->billable->anikeenId()
->request('GET', sprintf('v1/checkout-sessions/%s', $id))))
->setBillable($this->billable);
} catch (ResourceException $e) {
if ($e->getCode() === 404) {
return null;
}
throw $e;
}
}
}
+3 -3
View File
@@ -17,11 +17,11 @@ class Invoice extends BaseResource
*
* @throws Throwable
*/
public function getInvoiceTemporaryUrl(): string
public function getInvoiceTemporaryUrl(): ?string
{
return $this->billable->anikeenId()
->request('PUT', sprintf('v1/invoices/%s', $this->id))
->request('GET', sprintf('v1/invoices/%s', $this->id))
->data
->temporary_url;
->temporary_url ?? null;
}
}␍
+34 -5
View File
@@ -3,14 +3,24 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl;
use Throwable;
/**
* @property string $id
* @property null|string $confirmation_url where the user confirms it (third-party apps, or `confirm`)
* @property null|string $oauth_client_id the app that bills the order: the app that created it, or for a monthly
* (collective) order the app whose subscriptions it bills; collective orders
* are kept per app. null for Anikeen's own (and legacy orders of several apps)
* @property null|string $group
* @property null|object $metadata the app's own data, returned as is
* @property null|string $return_url where the user returns after confirming
* @property null|string $expires_at when a draft created with `confirm` is cancelled unless confirmed
*/
class Order extends BaseResource
{
use HasBillable;
use HasConfirmationUrl;
/**
* Update given order from the current user.
@@ -41,19 +51,23 @@ class Order extends BaseResource
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: string,
* state: string,
* state: null|string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* }
* },
* group?: null|string,
* metadata?: null|array
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - group: The app's group (optional)
* - metadata: The app's own data (max. 50 keys), only while the order is a draft (optional)
* @throws Throwable
*/
public function update(array $attributes = []): self
@@ -66,6 +80,8 @@ class Order extends BaseResource
/**
* Checkout given order from the current user.
*
* A draft created with `confirm` can't be checked out once it expired (409 "confirmation_expired").
*
* @throws Throwable
*/
public function checkout(): self
@@ -98,6 +114,19 @@ class Order extends BaseResource
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success();
}
/**
* Deliveries of this order's goods; `create()` ships them (see Shipments::create()).
*
* @throws Throwable
*/
public function shipments(): Shipments
{
return Shipments::builder(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments', $this->id)))
->setBillable($this->billable)
->setParent($this);
}
/**
* Get order items from given order.
*
+9 -2
View File
@@ -28,12 +28,19 @@ class OrderItems extends BaseCollection
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
*
* Only trusted apps may add items, and only to draft orders. Returns the first created item.
*
* @throws Throwable
*/
public function create(string $orderId, array $attributes = []): OrderItem
{
return (new OrderItem(fn() => $this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s', $orderId), $attributes)))
return (new OrderItem(fn() => tap($this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s/items', $orderId), $attributes), function ($result) {
if (is_array($result->data)) {
$result->data = $result->data[0] ?? null;
}
})))
->setBillable($this->billable)
->setParent($this->parent);
}
+22 -7
View File
@@ -38,28 +38,43 @@ class Orders extends BaseCollection
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: string,
* state: string,
* state: null|string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* group?: null|string,
* confirm?: bool,
* return_url?: null|string,
* metadata?: null|array,
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* description?: null|string,
* image_url?: null|string,
* price?: float|int|string,
* gross_price?: float|int|string,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - group: The app's group, e.g. a team (optional)
* - confirm: Trusted apps: leave confirming and paying to the user (confirmation_url, expires after 24 hours; optional)
* - return_url: Where the user returns after confirming (same origin as one of the app's redirect URIs; optional)
* - metadata: The app's own data (max. 50 keys), returned as is in the API and webhooks (optional)
* - items: Array of order items:
* - type: digital, physical or physical-reduced (VAT category)
* - price/gross_price: net or gross unit price, one of both
* - unit: hour, day, week, month, year, onetime or piece
* - image_url: http(s) URL of a product image; Anikeen ID copies it, so it stays
* visible on the order and invoice when the shop removes it later
* @throws Throwable
*/
public function create(array $attributes = []): Order
+12
View File
@@ -10,4 +10,16 @@ use Anikeen\Id\Concerns\HasBillable;
class PaymentMethod extends BaseResource
{
use HasBillable;
/**
* Remove this payment method from the current user.
*
* @throws \Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/payment-methods/%s', $this->id))
->success();
}
}␍
+14
View File
@@ -45,6 +45,20 @@ class PaymentMethods extends BaseCollection
return $this->defaultPaymentMethodCache;
}
/**
* Set the default payment method for the current user.
*
* @throws Throwable
*/
public function setDefault(string $id): PaymentMethod
{
unset($this->defaultPaymentMethodCache);
return (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/payment-methods/%s/default', $id))))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
/**
* A preference of a user: a JSON value under a namespace and a key. `version` is opaque: it only
* goes up (per user, not consecutive per key) and is what ifVersion (`If-Match`) compares.
* JSON objects come back as stdClass, so `{}` stays an object.
*
* @property string $namespace
* @property string $key
* @property mixed $value
* @property int $version
* @property string $updated_at
* @property object{type: string, client_id: ?string} $updated_by `type` "user" (written with a user token) or "app"
*/
class Preference extends BaseResource
{
/**
* The write that returned this preference created the key (201). False for reads, updates and
* writes of the value already stored.
*/
public function wasCreated(): bool
{
return $this->result->response()?->getStatusCode() === 201;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Anikeen\Id\Resources;
/**
* The preferences of a user in one namespace, sorted by key (at most 50, no pagination).
*/
class Preferences extends BaseCollection
{
/**
* The preference of a key; null when it isn't set. Reads the list, no request.
*/
public function find(string $id): ?Preference
{
$data = $this->item($id);
if ($data === null) {
return null;
}
$result = clone $this->result;
$result->data = $data;
return new Preference(fn () => $result);
}
/**
* The value of a key, or $default when it isn't set.
*/
public function get(string $key, mixed $default = null): mixed
{
$data = $this->item($key);
return $data !== null ? $data->value : $default;
}
public function has(string $key): bool
{
return $this->item($key) !== null;
}
/**
* The values by key.
*
* @return array<string, mixed>
*/
public function values(): array
{
$values = [];
foreach ((array) $this->result->data as $data) {
$values[$data->key] = $data->value;
}
return $values;
}
private function item(string $key): ?object
{
foreach ((array) $this->result->data as $data) {
if (($data->key ?? null) === $key) {
return $data;
}
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
* @property string $number delivery note number, e.g. LS-2026-0001
* @property string $order_id
* @property null|string $carrier
* @property null|string $tracking_number
* @property null|string $tracking_url
* @property array $items order_item_id, name, description, units
* @property object $shipping_address
* @property null|string $note
* @property string $shipped_at
*/
class Shipment extends BaseResource
{
use HasBillable;
use HasParent;
/**
* The delivery note as PDF (binary string).
*
* @throws Throwable
*/
public function deliveryNote(): string
{
$result = $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s/delivery-note', $this->order_id, $this->id));
return (string) $result->response?->getBody();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* Deliveries of an order's physical goods. Each one adds a delivery note to the customer's
* order documents on Anikeen ID.
*/
class Shipments extends BaseCollection
{
use HasBillable;
use HasParent;
/**
* Ship goods of a paid order. Only the app that created the order (or a trusted app) can.
*
* Without `items`, everything physical that hasn't been shipped yet is shipped. Items and
* the shipping address are stored as they are now; the user sees them on the delivery note.
*
* @param array{
* carrier?: string,
* tracking_number?: string,
* tracking_url?: string,
* note?: string,
* shipped_at?: string,
* notify_customer?: bool,
* items?: array<array{order_item_id: string, units?: float|int|string}>
* } $attributes
* - carrier: e.g. "DHL"
* - tracking_url: http(s) link to track the parcel, shown to the user
* - shipped_at: when it was sent (default: now, not in the future)
* - notify_customer: Anikeen ID mails the user with the delivery note (default true);
* false if your shop sends its own mail
* - items: physical items of the order and how many of them (default: all left)
*
* Errors (ResourceException with the HTTP status): 409 when the order isn't paid or nothing
* is left to ship, 422 for items that aren't physical items of the order or exceed what's left.
*
* @throws Throwable
*/
public function create(array $attributes = []): Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('POST', sprintf('v1/orders/%s/shipments', $this->parent->id), $attributes)))
->setBillable($this->billable)
->setParent($this->parent);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?Shipment
{
return (new Shipment(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/shipments/%s', $this->parent->id, $id))))
->setBillable($this->billable)
->setParent($this->parent);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Anikeen\Id\Resources;
/**
* @property string $id
* @property string $user_id
* @property string $driver
* @property string $uid
* @property string|null $display_name
* @property string|null $avatar
* @property string|null $expires_at
* @property string $created_at
* @property string $updated_at
*/
class SocialiteUser extends BaseResource
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
class SocialiteUsers extends BaseCollection
{
use HasParent;
public function find(string $id): ?SocialiteUser
{
return null;
}
}
+10
View File
@@ -17,6 +17,16 @@ class SshKey extends BaseResource
*
* @throws Throwable
*/
/**
* Rename the SSH key.
*
* @throws Throwable
*/
public function update(string $name): self
{
return (new self(fn() => $this->parent->put(sprintf('v1/ssh-keys/%s', $this->id), ['name' => $name])))->setParent($this->parent);
}
public function delete(): bool
{
return $this->parent->delete(sprintf('v1/ssh-keys/%s', $this->id))->success();
+1 -1
View File
@@ -29,6 +29,6 @@ class SshKeys extends BaseCollection
*/
public function find(string $id): ?SshKey
{
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))));
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))))->setParent($this->parent);
}
}␍
+21 -7
View File
@@ -3,6 +3,8 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable;
@@ -12,15 +14,29 @@ use Throwable;
* @property string $description
* @property string $unit
* @property float $price
* @property ?float $monthly_cap net, at most this per calendar month (hour and day units)
* @property ?float $expected_monthly_price gross estimate for a month (720 hours, at most the monthly cap)
* @property ?string $predecessor_id the subscription this one replaced (a resize); they share the monthly cap
* @property float $vat_rate
* @property array $payload
* @property bool $active
* @property bool $autorenew
* @property bool $cancelled
* @property ?string $status draft, trial, active, paused, cancelling, expired or cancelled, see status()
* @property ?string $starts_at
* @property string $ends_at
* @property string $webhook_url
* @property string $webhook_secret
* @property ?string $cancels_at when a scheduled cancellation takes effect
* @property ?string $trial_ends_at
* @property ?string $trial_payment_method required or optional with a trial (TrialPaymentMethod), null without
* @property ?string $cancellation_reason why the billing cancelled it (CancellationReason), null otherwise
* @property ?object $discount {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining}; periods_remaining is null for `forever`, 0 once it ended
* @property null|string $confirmation_url where the user confirms it (third-party apps)
*/
class Subscription extends BaseResource
{
use HasBillable;
use HasConfirmationUrl;
use HasSubscriptionStatus;
/**
* Update a given subscription from the current user.
@@ -31,22 +47,20 @@ class Subscription extends BaseResource
* description: null|string,
* unit: string,
* price: float,
* monthly_cap?: null|float,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* ends_at: null|string
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name (required when set)
* - description: The description (optional)
* - unit: The unit (required when set, e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit (required when set)
* - monthly_cap: Net, at most this per calendar month (hour and day units; optional)
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws Throwable
*/
public function update(array $attributes): self
+14 -4
View File
@@ -20,22 +20,32 @@ class Subscriptions extends BaseCollection
* description: null|string,
* unit: string,
* price: float,
* monthly_cap?: null|float,
* predecessor_id?: null|string,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* trial_days?: null|int,
* trial_ends_at?: null|string,
* trial_once?: bool,
* trial_payment_method?: null|string,
* discount?: null|array{label: string, percent_off?: float|string, amount_off?: float|string, duration: string, duration_in_periods?: int}
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name
* - description: The description (optional)
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit
* - monthly_cap: Net, at most this per calendar month (hour and day units, max. 2 decimals; optional)
* - predecessor_id: The subscription this one replaces (a resize); both share the monthly cap (optional)
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* - trial_days: A free trial of 1–365 days from the user's confirmation on (optional)
* - trial_ends_at: Or a fixed end of the free trial (ISO 8601, in the future; optional)
* - trial_once: Refuse the trial if the user already had one with the app in the group (optional)
* - trial_payment_method: TrialPaymentMethod::REQUIRED (default) or ::OPTIONAL, only with a trial (optional)
* - discount: A discount per billed period, see AppSubscriptions::create() (optional)
* @throws Throwable
*/
public function create(array $attributes): Subscription
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's profile: AnikeenId::findUserById(), the result of the profile updates on Billable
* (updateProfile(), uploadAvatar(), removeAvatar()) and the `data.object` of a user.updated
* webhook (`new User($event['data']['object'])`).
*
* The REST shapes carry `email_verified_at` and `profile_version`, the webhook `email_verified`
* and `version`; emailVerified() and version() read either. The profile updates also return the
* other fields of `GET v1/user` plus `pending_email` and `pending_email_expires_at`.
*
* @property string $id
* @property ?string $name
* @property ?string $username
* @property ?string $email in a webhook only once verified (null while it isn't)
* @property ?string $email_verified_at REST shapes
* @property bool $email_verified webhook
* @property ?string $avatar a stable URL; the picture behind it can change
* @property ?string $locale REST shapes
* @property int $profile_version REST shapes
* @property int $version webhook
* @property ?string $created_at REST shapes
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
* @property ?string $pending_email_expires_at profile updates only
*/
class User implements JsonSerializable
{
protected object $data;
public function __construct(object|array $data)
{
$this->data = (object) $data;
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
public function id(): string
{
return (string) ($this->data->id ?? '');
}
public function name(): ?string
{
return $this->data->name ?? null;
}
public function username(): ?string
{
return $this->data->username ?? null;
}
public function email(): ?string
{
return $this->data->email ?? null;
}
/**
* From `email_verified` (webhooks) or `email_verified_at` (REST shapes).
*/
public function emailVerified(): bool
{
if (isset($this->data->email_verified)) {
return (bool) $this->data->email_verified;
}
return !empty($this->data->email_verified_at);
}
/**
* The URL of the profile picture. It stays the same when the picture changes (user.updated
* with `avatar` in `changed`): drop a cached copy then.
*/
public function avatar(): ?string
{
return $this->data->avatar ?? null;
}
/**
* Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks):
* keep the highest one seen and ignore older data.
*/
public function version(): int
{
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
}
/**
* A new email address waiting for the user's confirmation (profile updates only); `email`
* stays the current address until then.
*/
public function pendingEmail(): ?string
{
return $this->data->pending_email ?? null;
}
public function pendingEmailExpiresAt(): ?DateTimeImmutable
{
return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code (a 412 in a
* PreconditionFailedException, a 422 in a ValidationFailedException).
*/
final class AppRequest
{
/**
* @param array<string, string> $headers Extra request headers, e.g. `If-Match`.
* @throws ApiException
*/
public static function send(string $method, string $path, ?array $payload = null, array $query = [], array $headers = []): Result
{
$result = self::attempt($method, $path, $payload, $query, $headers);
if ($result->status === 401) {
$tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query, $headers);
}
}
if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null;
$exception = match ($result->status) {
412 => PreconditionFailedException::class,
422 => ValidationFailedException::class,
default => ApiException::class,
};
throw new $exception(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
return $result;
}
/**
* @throws ApiException
*/
private static function attempt(string $method, string $path, ?array $payload, array $query, array $headers = []): Result
{
try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query, null, false, $headers);
} catch (ApiException $e) {
throw $e;
} catch (GuzzleException|Throwable $e) {
// network errors / timeouts (ConnectException isn't a RequestException) and token errors
throw new ApiException(sprintf('Anikeen ID is not reachable: %s', $e->getMessage()), 0, null, $e);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Anikeen\Id\Support;
use InvalidArgumentException;
/**
* @internal paths and preconditions of the preferences API
*/
final class PreferenceRequest
{
/**
* The path with every segment URL-encoded, e.g. path('v1/preferences', 'global', 'appearance').
*/
public static function path(string $base, string ...$segments): string
{
return $base . '/' . implode('/', array_map('rawurlencode', $segments));
}
/**
* `If-Match: "<version>"` (only if the stored version is this one) or `If-None-Match: *`
* (only if the key isn't set yet).
*
* @return array<string, string>
*/
public static function conditions(?int $ifVersion, bool $createOnly = false): array
{
if ($ifVersion !== null && $createOnly) {
throw new InvalidArgumentException('A write is either conditional on a version or create-only, not both.');
}
return match (true) {
$ifVersion !== null => ['If-Match' => sprintf('"%d"', $ifVersion)],
$createOnly => ['If-None-Match' => '*'],
default => [],
};
}
private function __construct()
{
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Anikeen\Id\Webhooks;
/**
* The `type` of webhook events Anikeen ID sends, e.g.
* `match ($event['type']) { EventType::CHECKOUT_COMPLETED => …, … }`.
*/
final class EventType
{
public const ORDER_CREATED = 'order.created';
public const ORDER_COMPLETED = 'order.completed';
public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
public const ORDER_CANCELLED = 'order.cancelled';
public const ORDER_SHIPPED = 'order.shipped';
/**
* Money was given back for an order (a refund or a partial cancellation, with a credit note);
* see the order's `refunds` and `refunded_total`. A full refund also cancels the order,
* so order.cancelled is sent too; don't rely on the order of the two events.
*/
public const ORDER_REFUNDED = 'order.refunded';
public const SUBSCRIPTION_CREATED = 'subscription.created';
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
public const SUBSCRIPTION_PAUSED = 'subscription.paused';
public const SUBSCRIPTION_RESUMED = 'subscription.resumed';
/** `data.reason`: why the billing cancelled it (CancellationReason, e.g. a trial that ended without a payment method), else null. */
public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
public const SUBSCRIPTION_EXPIRED = 'subscription.expired';
/**
* The free trial of a running subscription ends in a few days (see trial_ends_at); billing
* starts then. `data.payment_method_missing`: the user has no payment method yet (with
* trial_payment_method `optional` the subscription then ends with the trial).
*/
public const SUBSCRIPTION_TRIAL_WILL_END = 'subscription.trial_will_end';
/** The last discounted period of a subscription was billed; the following ones are billed at the full price. */
public const SUBSCRIPTION_DISCOUNT_ENDED = 'subscription.discount_ended';
/**
* The price, unit, name, description or discount of a running subscription changed (right away
* or at the period end); `data.previous_attributes` has the changed fields' old values.
*/
public const SUBSCRIPTION_UPDATED = 'subscription.updated';
/**
* A period of a subscription was booked on its collective order, the first one included;
* `data.period` describes it, `data.period.first_period` is true for the subscription's first
* paid period (checkout or the end of a free trial). Opt-in: only endpoints that select this
* event get it (not through `*`).
*/
public const SUBSCRIPTION_RENEWED = 'subscription.renewed';
/** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */
public const CHECKOUT_COMPLETED = 'checkout.completed';
/** The user or the app cancelled a checkout session; its drafts were cancelled. */
public const CHECKOUT_CANCELLED = 'checkout.cancelled';
/** A checkout session was not confirmed within 24 hours; its drafts were cancelled. */
public const CHECKOUT_EXPIRED = 'checkout.expired';
/**
* A user's open monthly bill reached their billing limit: start no new paid service for them.
* `data.object` is the billing limit as the app reads it (with `version`), `data.reason`
* `booking` or `limit_changed`. Opt-in: only endpoints that select this event get it (not
* through `*`), and only apps that may read the limit of a user connected to them.
*/
public const BILLING_LIMIT_REACHED = 'billing_limit.reached';
/**
* A user whose billing limit was reached has room again. `data.object` is the billing limit
* (with `version`), `data.reason` `limit_changed`, `limit_removed`, `voided`, `period_reset`
* or `order_changed`. Opt-in like BILLING_LIMIT_REACHED.
*/
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
/**
* A preference of a user was set, changed or deleted. Thin, never the value: `data.object` is
* `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by:
* {type: "user"|"app", client_id}}`; fetch the preference again (or drop your copy when
* `deleted`) and ignore events with a lower version than the one you hold. Opt-in (not through
* `*`); only to trusted apps that may read the namespace, never to the app that made the change.
*/
public const PREFERENCE_UPDATED = 'preference.updated';
/**
* The profile of a user connected to your app changed: name, username, the verified email
* address, email_verified or the avatar. `data.object` is the full profile `{object: "user", id,
* name, username, email, email_verified, avatar, version}` (Resources\User), `data.changed` names
* the changed fields (never their old values). An email address appears only once verified
* (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL
* changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also
* sent to the app that made the change.
*/
public const USER_UPDATED = 'user.updated';
/**
* A user connected to your app deleted their account (or it was deleted): delete or anonymise
* it and treat it as final. `data.object` is `{object: "user", id, deleted: true, deleted_at}`.
* Sent once; no other event about the user follows. A later user.updated for the same ID means
* the account was restored. Opt-in (not through `*`).
*/
public const USER_DELETED = 'user.deleted';
public const TEST = 'webhook.test';
/**
* @return list<string>
*/
public static function all(): array
{
return array_values((new \ReflectionClass(self::class))->getConstants());
}
private function __construct()
{
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\Webhooks;
use Anikeen\Id\Exceptions\SignatureVerificationException;
/**
* Verifies webhooks from Anikeen ID, like Stripe's Webhook::constructEvent().
*
* Anikeen ID signs every request with your endpoint's signing secret (whsec_…):
* `Anikeen-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
*/
final class Webhook
{
/** Requests older (or newer) than this many seconds are rejected to prevent replays. */
public const DEFAULT_TOLERANCE = 300;
/**
* Verifies the signature and returns the event: ['id', 'type', 'created', 'data' => ['object' => …]].
*
* @param string $payload the raw request body (not re-encoded JSON)
* @param string|null $signatureHeader the Anikeen-Signature header
*
* @throws SignatureVerificationException
*/
public static function constructEvent(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): array
{
self::verify($payload, $signatureHeader, $secret, $tolerance);
$event = json_decode($payload, true);
if (!is_array($event) || !isset($event['id'], $event['type'])) {
throw new SignatureVerificationException('The webhook payload is not a valid event.');
}
return $event;
}
/**
* @throws SignatureVerificationException
*/
public static function verify(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): void
{
$timestamp = null;
$signatures = [];
foreach (explode(',', (string) $signatureHeader) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, null);
if ($key === 't' && ctype_digit((string) $value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value) {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
throw new SignatureVerificationException('The Anikeen-Signature header is missing or malformed.');
}
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
if (!array_filter($signatures, fn (string $signature) => hash_equals($expected, $signature))) {
throw new SignatureVerificationException('The webhook signature does not match. Check the signing secret.');
}
if ($tolerance > 0 && abs(time() - $timestamp) > $tolerance) {
throw new SignatureVerificationException('The webhook timestamp is outside the tolerance.');
}
}
}
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCharge;
use Anikeen\Id\Resources\BillingLimit;
use DateTimeImmutable;
class AppChargesTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function charge(array $overrides = []): array
{
return ['data' => [
'object' => 'charge', 'id' => '01JCHARGE', 'user_id' => '01JUSER', 'status' => 'open',
'idempotency_key' => 'shipment-123', 'name' => 'Signed card', 'description' => null,
'type' => 'physical', 'quantity' => 2, 'unit' => 'piece', 'unit_price' => '4.000000',
'net_amount' => '8.00', 'gross_amount' => '9.52', 'currency_iso' => 'EUR', 'group' => null,
'occurred_at' => '2026-11-06T10:00:00+00:00', 'reference' => 'order-77', 'metadata' => ['parcel' => 'p-1'],
'order_id' => '01JORDER', 'voided_at' => null, 'void_reason' => null, 'created_at' => '2026-11-06T10:00:01+00:00',
...$overrides,
]];
}
private function limit(array $overrides = []): array
{
return ['data' => [
'object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross',
'limit' => '100.00', 'used' => '82.40', 'used_by_app' => '60.00', 'remaining' => '17.60',
'remaining_net' => '14.78', 'vat_rate' => 19, 'reached' => false,
'resets_at' => '2026-12-01T02:00:00Z', 'version' => 7,
...$overrides,
]];
}
public function test_a_charge_is_booked_with_the_app_token(): void
{
$this->respond(201, $this->charge());
$attributes = [
'idempotency_key' => 'shipment-123',
'name' => 'Signed card',
'type' => 'physical',
'quantity' => 2,
'unit_price' => '4.00',
'reference' => 'order-77',
'metadata' => ['parcel' => 'p-1'],
];
$charge = app(AnikeenId::class)->createChargeForUser('01JUSER', $attributes);
$this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0));
$this->assertSame($attributes, $this->sentJson(0));
$this->assertInstanceOf(AppCharge::class, $charge);
$this->assertSame('01JCHARGE', $charge->id());
$this->assertSame('open', $charge->status());
$this->assertTrue($charge->isOpen());
$this->assertFalse($charge->isBilled());
$this->assertFalse($charge->isVoided());
$this->assertSame('8.00', $charge->netAmount());
$this->assertSame('9.52', $charge->grossAmount());
$this->assertSame('01JORDER', $charge->orderId());
$this->assertSame(['parcel' => 'p-1'], $charge->metadata());
$this->assertEquals(new DateTimeImmutable('2026-11-06T10:00:00+00:00'), $charge->occurredAt());
$this->assertSame(2, $charge->quantity);
$this->assertSame('order-77', $charge->reference);
$this->assertNull($charge->voided_at);
$this->assertSame('Signed card', $charge->jsonSerialize()['name']);
}
public function test_empty_metadata_is_an_empty_array(): void
{
$this->respond(200, $this->charge(['metadata' => new \stdClass()]));
$this->assertSame([], app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE')->metadata());
}
public function test_a_charge_is_found_with_the_app_token(): void
{
$this->respond(200, $this->charge(['status' => 'billed']));
$charge = app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE');
$this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(0));
$this->assertTrue($charge->isBilled());
}
public function test_a_charge_is_voided_with_and_without_a_reason(): void
{
$voided = ['status' => 'voided', 'order_id' => null, 'voided_at' => '2026-11-06T11:00:00+00:00', 'void_reason' => 'Parcel lost'];
$this->respond(200, $this->charge($voided));
$this->respond(200, $this->charge($voided));
$charge = app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE', 'Parcel lost');
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(0));
$this->assertSame(['reason' => 'Parcel lost'], $this->sentJson(0));
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(1));
$this->assertSame([], $this->sentJson(1));
$this->assertTrue($charge->isVoided());
$this->assertNull($charge->orderId());
$this->assertSame('Parcel lost', $charge->void_reason);
}
public function test_path_segments_are_encoded(): void
{
$this->respond(200, $this->charge());
app(AnikeenId::class)->findChargeForUser('01J/USER', '01J CHARGE');
$this->assertSame('/api/v1/client/users/01J%2FUSER/charges/01J%20CHARGE', $this->sent[0]['request']->getUri()->getPath());
}
public function test_a_charge_over_the_billing_limit_carries_the_details(): void
{
$this->respond(409, [
'message' => 'The customer\'s billing limit is reached.', 'error' => 'billing_limit_reached',
'limit' => '100.00', 'used' => '97.40', 'remaining' => '2.60', 'remaining_net' => '2.18',
'required' => '4.76', 'currency' => 'EUR', 'resets_at' => '2026-12-01T02:00:00Z',
]);
try {
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isBillingLimitReached());
$this->assertTrue($e->isConflict());
$this->assertSame(409, $e->status);
$this->assertSame('2.60', $e->body()->remaining);
$this->assertSame('4.76', $e->body()->required);
$this->assertSame('2026-12-01T02:00:00Z', $e->body()->resets_at);
}
}
public function test_other_conflicts_are_not_the_billing_limit(): void
{
$this->respond(409, ['message' => 'The charge is more than this app may charge at once.', 'error' => 'charge_exceeds_maximum', 'max_charge_net' => '50.00']);
try {
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '60.00']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertFalse($e->isBillingLimitReached());
$this->assertSame('charge_exceeds_maximum', $e->error);
$this->assertSame('50.00', $e->body()->max_charge_net);
}
}
public function test_an_already_billed_charge_cannot_be_voided(): void
{
$this->respond(409, ['message' => 'The charge is on a closed bill; only a refund of that order credits it.', 'error' => 'charge_already_billed', 'charge_id' => '01JCHARGE']);
try {
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame('charge_already_billed', $e->error);
$this->assertSame('01JCHARGE', $e->body()->charge_id);
}
}
public function test_an_unknown_charge_is_not_found(): void
{
$this->respond(404, ['message' => 'Charge not found.', 'error' => 'charge_not_found']);
try {
app(AnikeenId::class)->findChargeForUser('01JUSER', '01JOTHER');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('charge_not_found', $e->error);
}
}
public function test_the_billing_limit_is_read_with_the_app_token(): void
{
$this->respond(200, $this->limit());
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(0));
$this->assertInstanceOf(BillingLimit::class, $limit);
$this->assertTrue($limit->hasLimit());
$this->assertSame('100.00', $limit->limit());
$this->assertSame('82.40', $limit->used());
$this->assertSame('60.00', $limit->usedByApp());
$this->assertSame('17.60', $limit->remaining());
$this->assertSame('14.78', $limit->remainingNet());
$this->assertSame(19.0, $limit->vatRate());
$this->assertSame('EUR', $limit->currency());
$this->assertFalse($limit->isReached());
$this->assertEquals(new DateTimeImmutable('2026-12-01T02:00:00Z'), $limit->resetsAt());
$this->assertSame(7, $limit->version());
$this->assertSame('gross', $limit->basis);
}
public function test_without_a_limit_the_app_sees_only_its_own_share(): void
{
$this->respond(200, $this->limit(['limit' => null, 'used' => null, 'remaining' => null, 'remaining_net' => null, 'vat_rate' => null]));
$limit = app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertFalse($limit->hasLimit());
$this->assertNull($limit->limit());
$this->assertNull($limit->used());
$this->assertNull($limit->remaining());
$this->assertNull($limit->remainingNet());
$this->assertNull($limit->vatRate());
$this->assertSame('60.00', $limit->usedByApp());
$this->assertTrue($limit->allows('100000.00'));
}
public function test_allows_compares_with_the_remaining_amount(): void
{
$limit = new BillingLimit((object) $this->limit()['data']);
$this->assertTrue($limit->allows('17.60'));
$this->assertTrue($limit->allows('17.6'));
$this->assertTrue($limit->allows('4.76'));
$this->assertTrue($limit->allows(17.6));
$this->assertTrue($limit->allows(17));
$this->assertFalse($limit->allows('17.61'));
$this->assertFalse($limit->allows('17.600001'));
$this->assertFalse($limit->allows('100.00'));
$this->assertFalse($limit->allows(1000));
}
public function test_a_reached_or_zero_limit_allows_nothing(): void
{
$reached = new BillingLimit((object) $this->limit(['used' => '100.00', 'remaining' => '0.00', 'remaining_net' => '0.00', 'reached' => true])['data']);
$zero = new BillingLimit((object) $this->limit(['limit' => '0.00', 'used' => '0.00', 'remaining' => '0.00', 'reached' => true])['data']);
$this->assertTrue($reached->isReached());
$this->assertFalse($reached->allows('0.01'));
$this->assertTrue($zero->hasLimit());
$this->assertFalse($zero->allows('0.01'));
}
public function test_the_user_s_own_limit_has_no_app_share(): void
{
$limit = new BillingLimit((object) ['object' => 'billing_limit', 'user_id' => '01JUSER', 'currency' => 'EUR', 'basis' => 'gross', 'limit' => null, 'used' => '12.00', 'remaining' => null, 'remaining_net' => null, 'vat_rate' => 19.0, 'reached' => false, 'resets_at' => '2026-12-01T02:00:00Z', 'version' => 0]);
$this->assertSame('12.00', $limit->used());
$this->assertNull($limit->usedByApp());
$this->assertSame(0, $limit->version());
}
public function test_a_refused_limit_read_is_an_api_exception(): void
{
$this->respond(403, ['message' => 'This app may not read customers\' billing limits.', 'error' => 'billing_limit_not_enabled']);
try {
app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(403, $e->status);
$this->assertSame('billing_limit_not_enabled', $e->error);
$this->assertFalse($e->isBillingLimitReached());
}
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
return $owner;
}
private function page(array $orders, int $page = 1, int $last = 1): array
{
return ['data' => $orders, 'meta' => ['current_page' => $page, 'last_page' => $last, 'total' => count($orders)], 'links' => ['next' => null]];
}
private function requestQuery(int $index): array
{
parse_str($this->sent[$index]['request']->getUri()->getQuery(), $query);
return $query;
}
public function test_pending_orders_of_a_user(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => '01JUSER', 'status' => 'pending', 'gross_total' => 11.9, 'next_payment_attempt_at' => null, 'payment_attempts' => 4]]));
$orders = $this->owner()->appOrders()->pending();
$this->assertSame('o1', $orders[0]->id);
$this->assertSame(4, $orders[0]->payment_attempts);
$request = $this->sent[0]['request'];
$this->assertSame('GET /api/v1/client/orders Bearer app-token', $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'));
$this->assertSame(['status' => 'pending', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $this->requestQuery(0));
}
public function test_pending_orders_of_all_users_follow_pages(): void
{
$this->respond(200, $this->page([['id' => 'o1', 'user_id' => 'a']], 1, 2));
$this->respond(200, $this->page([['id' => 'o2', 'user_id' => 'b']], 2, 2));
$orders = app(AnikeenId::class)->appOrders()->pending(['group' => 'team-1']);
$this->assertSame(['o1', 'o2'], array_map(fn ($o) => $o->id, $orders));
$this->assertSame(['group' => 'team-1', 'status' => 'pending', 'per_page' => '100', 'page' => '2'], $this->requestQuery(1));
}
public function test_find_returns_null_for_unknown_and_other_users_orders(): void
{
$this->respond(404, ['message' => 'Order not found.', 'error' => 'order_not_found']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$orders = $this->owner()->appOrders();
$this->assertNull($orders->find('unknown'));
$this->assertNull($orders->find('o1'));
$this->assertSame('o1', $orders->find('o1')->id);
}
public function test_orders_list_their_lines_and_invoice(): void
{
$this->respond(200, ['data' => [
'id' => 'o1',
'user_id' => '01JUSER',
'group' => 'team-1',
'metadata' => ['topup' => '7'],
'items' => [
['id' => 'i1', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => 10, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => 10, 'discount' => null],
['id' => 'i2', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => -2, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => -2, 'discount' => ['label' => 'SUMMER20', 'percent_off' => 20, 'amount_off' => null]],
],
'invoice' => ['id' => 'inv1', 'number' => 'RE-2026-0001', 'status' => 'completed', 'pdf_url' => 'https://id.test/api/v1/client/orders/o1/invoice'],
]]);
$order = $this->owner()->appOrders()->find('o1');
$this->assertSame('7', $order->metadata->topup);
$this->assertSame(8, array_sum(array_map(fn ($item) => $item->net_amount, array_filter($order->items, fn ($item) => $item->subscription_id === '01JSUB'))));
$this->assertSame('SUMMER20', $order->items[1]->discount->label);
$this->assertSame('RE-2026-0001', $order->invoice->number);
}
public function test_the_invoice_pdf_is_downloaded_with_the_app_token(): void
{
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->assertSame('%PDF-1.7 invoice', app(AnikeenId::class)->appOrders()->invoicePdf('o1'));
$this->assertSame('%PDF-1.7 invoice', $this->owner()->appOrders()->invoicePdf('o1'));
$calls = array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent);
$this->assertSame([
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
'GET /api/v1/client/orders/o1 Bearer app-token',
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
], $calls);
}
public function test_an_invoice_that_is_not_ready_is_a_conflict(): void
{
$this->respond(409, ['message' => 'The invoice document is still being created.', 'error' => 'invoice_not_ready']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
try {
app(AnikeenId::class)->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('invoice_not_ready', $e->error);
}
try {
$this->owner()->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('order_not_found', $e->error);
}
}
}
+360
View File
@@ -0,0 +1,360 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\CancellationReason;
use Anikeen\Id\Enums\TrialPaymentMethod;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppSubscription;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class AppSubscriptionsTest extends TestCase
{
private function owner(?string $anikeenId = '01JUSER'): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = $anikeenId;
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function subscription(array $overrides = []): array
{
return ['data' => ['id' => '01JSUB', 'user_id' => '01JUSER', 'name' => 'Server', 'active' => false, 'cancelled' => false, ...$overrides]];
}
public function test_create_sends_the_user_id_with_the_app_token(): void
{
$this->respond(201, $this->subscription());
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1']);
$request = $this->sent[0]['request'];
$this->assertSame('POST', $request->getMethod());
$this->assertSame('/api/v1/client/subscriptions', $request->getUri()->getPath());
$this->assertSame('Bearer app-token', $request->getHeaderLine('Authorization'));
$this->assertSame(['group' => 'team-1', 'name' => 'Server', 'unit' => 'hour', 'price' => '0.0137', 'idempotency_key' => 'server:1', 'user_id' => '01JUSER'], $this->sentJson(0));
$this->assertSame('01JSUB', $subscription->id);
}
public function test_nothing_is_requested_until_a_method_is_called(): void
{
$this->owner()->appSubscriptions();
$this->assertCount(0, $this->sent);
}
public function test_actions_use_the_app_endpoints(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => false]));
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true]));
$this->respond(200, $this->subscription(['cancelled' => true]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
$this->assertTrue($subscription->checkout()->active);
$this->assertFalse($subscription->pause()->autorenew);
$this->assertTrue($subscription->resume()->autorenew);
$this->assertTrue($subscription->revoke(refund: true)->cancelled);
$this->assertSame([
'POST /api/v1/client/subscriptions',
'PUT /api/v1/client/subscriptions/01JSUB/checkout',
'PUT /api/v1/client/subscriptions/01JSUB/pause',
'PUT /api/v1/client/subscriptions/01JSUB/resume',
'PUT /api/v1/client/subscriptions/01JSUB/revoke',
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath(), $this->sent));
$this->assertSame(['refund' => true], $this->sentJson(4));
}
public function test_a_running_service_is_checked_out_from_its_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
$subscription->checkout(new \DateTimeImmutable('2026-05-19 11:33:58', new \DateTimeZone('UTC')));
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
}
public function test_a_checkout_without_arguments_sends_no_body(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1])->checkout();
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame('', (string) $request->getBody());
$this->assertFalse($request->hasHeader('Content-Type'));
}
public function test_a_checkout_from_a_date_sends_only_the_start(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout('2026-05-19T11:33:58+00:00');
$this->assertSame('{"starts_at":"2026-05-19T11:33:58+00:00"}', (string) $this->sent[1]['request']->getBody());
}
public function test_elapsed_periods_are_billed_up_to_the_ceiling(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19'])
->checkout(new \DateTimeImmutable('2024-05-19 11:33:58', new \DateTimeZone('UTC')), billElapsedPeriods: true, maxNetAmount: '15.57');
$request = $this->sent[1]['request'];
$this->assertSame('PUT /api/v1/client/subscriptions/01JSUB/checkout', $request->getMethod() . ' ' . $request->getUri()->getPath());
$this->assertSame(['starts_at' => '2024-05-19T11:33:58+00:00', 'bill_elapsed_periods' => true, 'max_net_amount' => '15.57'], $this->sentJson(1));
}
public function test_a_float_ceiling_is_sent_with_two_decimals(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true]));
$this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => 19.99])
->checkout('2024-05-19', true, 19.99 * 3);
$this->assertSame(['starts_at' => '2024-05-19', 'bill_elapsed_periods' => true, 'max_net_amount' => 59.97], $this->sentJson(1));
}
public function test_billing_elapsed_periods_needs_a_start_and_a_ceiling(): void
{
$this->respond(201, $this->subscription());
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
foreach ([[null, '10.00'], ['2024-05-19', null], ['', '10.00'], ['2024-05-19', '']] as [$startsAt, $maxNetAmount]) {
try {
$subscription->checkout($startsAt, billElapsedPeriods: true, maxNetAmount: $maxNetAmount);
$this->fail('Expected an InvalidArgumentException.');
} catch (InvalidArgumentException) {
}
}
$this->assertCount(1, $this->sent);
}
public function test_an_exceeded_ceiling_carries_the_net_amount(): void
{
$this->respond(201, $this->subscription());
$this->respond(409, ['message' => 'The elapsed periods come to 20.76 net, more than 15.57.', 'error' => 'elapsed_amount_exceeded', 'net_amount' => '20.76']);
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
try {
$subscription->checkout('2024-05-19', billElapsedPeriods: true, maxNetAmount: '15.57');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('elapsed_amount_exceeded', $e->error);
$this->assertSame('20.76', $e->body()->net_amount);
}
}
public function test_errors_carry_status_and_error_code(): void
{
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
try {
$this->owner()->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(409, $e->status);
$this->assertSame('already_cancelled', $e->error);
$this->assertTrue($e->isConflict());
$this->assertFalse($e->isTemporary());
}
}
public function test_find_returns_null_for_unknown_subscriptions_and_other_users(): void
{
$this->respond(404, ['message' => 'Subscription not found.', 'error' => 'subscription_not_found']);
$this->respond(200, $this->subscription(['user_id' => '01JSOMEONEELSE']));
$this->respond(200, $this->subscription());
$subscriptions = $this->owner()->appSubscriptions();
$this->assertNull($subscriptions->find('01JUNKNOWN'));
$this->assertNull($subscriptions->find('01JSUB'));
$this->assertSame('01JSUB', $subscriptions->find('01JSUB')->id);
}
public function test_all_follows_pages_and_filters_by_user(): void
{
$this->respond(200, ['data' => [['id' => 'a', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 1, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
$this->respond(200, ['data' => [['id' => 'b', 'user_id' => '01JUSER']], 'meta' => ['current_page' => 2, 'last_page' => 2, 'total' => 2], 'links' => ['next' => null]]);
$all = $this->owner()->appSubscriptions()->all(['group' => 'team-1', 'active' => true]);
$this->assertSame(['a', 'b'], array_map(fn ($s) => $s->id, $all));
parse_str($this->sent[0]['request']->getUri()->getQuery(), $query);
$this->assertSame(['group' => 'team-1', 'active' => '1', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $query);
}
public function test_a_rejected_app_token_is_renewed_once(): void
{
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
$this->assertSame([], $this->owner()->appSubscriptions()->all());
$this->assertSame('Bearer app-token', $this->sent[0]['request']->getHeaderLine('Authorization'));
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
}
public function test_an_unreachable_service_is_a_temporary_api_exception(): void
{
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/client/subscriptions')));
try {
$this->owner()->appSubscriptions()->all();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_the_shared_client_keeps_the_users_token(): void
{
$this->respond(200, ['data' => [], 'meta' => ['last_page' => 1], 'links' => ['next' => null]]);
app(AnikeenId::class)->withToken('user-token');
$this->owner()->appSubscriptions()->all();
$this->assertSame('user-token', app(AnikeenId::class)->getToken());
}
public function test_the_user_id_field_is_configurable(): void
{
AnikeenId::useUserIdField('id_service_user');
$owner = $this->owner(null);
$owner->id_service_user = '01JOTHER';
try {
$this->assertSame('01JOTHER', $owner->appSubscriptions()->userId());
} finally {
AnikeenId::useUserIdField('anikeen_id');
}
}
public function test_a_subscription_starts_with_a_free_trial(): void
{
$this->respond(201, $this->subscription());
$this->respond(200, $this->subscription(['active' => true, 'autorenew' => true, 'status' => 'trial', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => date(DATE_ATOM, time() + 14 * 86400)]));
$subscription = $this->owner()->appSubscriptions()->create(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true]);
$subscription->checkout();
$this->assertSame(['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true, 'user_id' => '01JUSER'], $this->sentJson(0));
$this->assertTrue($subscription->isTrial());
$this->assertTrue($subscription->isActive());
}
public function test_a_trial_can_be_confirmed_without_a_payment_method_and_end_cancelled(): void
{
$this->respond(201, $this->subscription(['trial_days' => 14, 'trial_payment_method' => 'optional']));
$this->respond(200, $this->subscription(['active' => false, 'cancelled' => true, 'status' => 'cancelled', 'starts_at' => '2026-11-04T10:00:00+00:00', 'trial_ends_at' => '2026-11-18T10:00:00+00:00', 'cancels_at' => '2026-11-18T10:00:00+00:00', 'trial_payment_method' => 'optional', 'cancellation_reason' => 'trial_ended_without_payment_method']));
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_payment_method' => TrialPaymentMethod::OPTIONAL]);
$this->assertSame('optional', $this->sentJson(0)['trial_payment_method']);
$this->assertSame(TrialPaymentMethod::OPTIONAL, $subscription->trial_payment_method);
$ended = $this->owner()->appSubscriptions()->find('01JSUB');
$this->assertTrue($ended->isCancelled());
$this->assertSame(CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD, $ended->cancellation_reason);
}
public function test_a_used_trial_is_refused_on_checkout(): void
{
$this->respond(201, $this->subscription());
$this->respond(409, ['message' => 'The user already had a free trial.', 'error' => 'trial_already_used']);
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_ends_at' => '2026-12-01T00:00:00+01:00', 'trial_once' => true]);
try {
$subscription->checkout();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('trial_already_used', $e->error);
}
}
public function test_a_subscription_is_created_with_a_discount(): void
{
$discount = ['label' => 'SUMMER20', 'percent_off' => 20, 'duration' => 'repeating', 'duration_in_periods' => 3];
$this->respond(201, $this->subscription(['discount' => [...$discount, 'amount_off' => null, 'periods_remaining' => 3]]));
$subscription = $this->owner()->appSubscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'discount' => $discount]);
$this->assertSame($discount, $this->sentJson(0)['discount']);
$this->assertSame('SUMMER20', $subscription->discount->label);
$this->assertSame(3, $subscription->discount->periods_remaining);
}
public function test_a_running_subscription_is_changed_now_or_at_the_period_end(): void
{
$change = ['applies_at' => '2026-12-01T00:00:00+00:00', 'name' => 'Pro', 'description' => null, 'unit' => 'month', 'price' => 14.99, 'discount' => null, 'created_at' => '2026-11-04T10:00:00+00:00'];
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99]));
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => $change]));
$this->respond(200, $this->subscription(['active' => true, 'price' => 14.99, 'scheduled_change' => null]));
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
$this->assertSame(14.99, $subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '14.99', 'discount' => null])->price);
$this->assertSame('2026-12-01T00:00:00+00:00', $subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'unit' => 'month', 'name' => 'Pro'])->scheduled_change->applies_at);
$this->assertNull($subscription->cancelScheduledChange()->scheduled_change);
$this->assertSame([
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
'PUT /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
'DELETE /api/v1/client/subscriptions/01JSUB/change Bearer app-token',
], array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent));
$this->assertSame(['apply' => 'now', 'price' => '14.99', 'discount' => null], $this->sentJson(0));
$this->assertSame(['apply' => 'period_end', 'unit' => 'month', 'name' => 'Pro'], $this->sentJson(1));
}
public function test_a_refused_change_carries_its_error_code(): void
{
$this->respond(409, ['message' => 'The credit would exceed the open bill.', 'error' => 'credit_exceeds_bill']);
$subscription = new AppSubscription((object) $this->subscription(['active' => true])['data']);
try {
$subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '1.99']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('credit_exceeds_bill', $e->error);
}
}
public function test_a_missing_user_id_is_reported(): void
{
$this->expectException(InvalidArgumentException::class);
$this->owner(null)->appSubscriptions()->create(['name' => 'Server', 'unit' => 'hour', 'price' => 1]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
class BillableModelTest extends TestCase
{
public function test_cached_api_results_are_not_model_attributes(): void
{
$user = new class extends Model {
use Billable;
protected $guarded = [];
public function cacheAll(): void
{
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
$this->{$name.'Cache'} = 'cached';
}
$this->preferencesCache['global'] = 'cached';
}
};
$user->email = 'ada@example.com';
$user->cacheAll();
// save() would otherwise write them as columns ("no such column: ordersCache")
$this->assertSame(['email' => 'ada@example.com'], $user->getAttributes());
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Illuminate\Database\Eloquent\Model;
class CheckoutConfirmationTest 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;
}
public function test_a_draft_order_links_to_its_confirmation_with_the_return_url(): void
{
$this->respond(200, ['data' => []]); // orders() loads the list first
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$order = $this->owner()->orders()->create(['billing_address_id' => '01JADDR', 'items' => []]);
$this->assertSame('https://id.anikeen.com/checkout/orders/01JORD', $order->confirmationUrl());
$this->assertSame(
'https://id.anikeen.com/checkout/orders/01JORD?return_url=https%3A%2F%2Fshop.test%2Fdone%3Fcart%3D1',
$order->confirmationUrl('https://shop.test/done?cart=1'),
);
$this->assertSame('POST /api/v1/orders', $this->sent[1]['request']->getMethod() . ' ' . $this->sent[1]['request']->getUri()->getPath());
}
public function test_checked_out_bookings_have_no_confirmation_url(): void
{
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'completed', 'confirmation_url' => null]]);
$this->assertNull($this->owner()->orders()->create([])->confirmationUrl('https://shop.test/done'));
}
public function test_checking_out_a_draft_as_a_third_party_app_points_to_the_confirmation(): void
{
$this->respond(200, ['data' => []]); // subscriptions() loads the list first
$this->respond(201, ['data' => ['id' => '01JSUB', 'active' => false, 'confirmation_url' => 'https://id.anikeen.com/checkout/subscriptions/01JSUB']]);
$this->respond(403, ['message' => 'The user has to confirm this on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.anikeen.com/checkout/subscriptions/01JSUB']);
$subscription = $this->owner()->subscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]);
try {
$subscription->checkout();
$this->fail('Expected a ConfirmationRequiredException.');
} catch (ConfirmationRequiredException $e) {
$this->assertSame(403, $e->getCode());
$this->assertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl);
}
}
public function test_a_trusted_app_lets_the_user_confirm_its_order(): void
{
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'group' => 'team-1', 'metadata' => ['topup' => '7'], 'expires_at' => '2026-11-05T10:00:00+00:00', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$result = app(AnikeenId::class)->createOrderForUser('01JUSER', [
'group' => 'team-1',
'confirm' => true,
'return_url' => 'https://console.test/billing/topup',
'metadata' => ['topup' => '7'],
'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']],
]);
$this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sent[0]['request']->getMethod() . ' ' . $this->sent[0]['request']->getUri()->getPath() . ' ' . $this->sent[0]['request']->getHeaderLine('Authorization'));
$this->assertTrue($this->sentJson(0)['confirm']);
$this->assertSame('https://console.test/billing/topup', $this->sentJson(0)['return_url']);
$this->assertSame(['topup' => '7'], $this->sentJson(0)['metadata']);
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame('https://id.anikeen.com/checkout/orders/01JORD', $result->data->confirmation_url);
$this->assertSame('7', $result->data->metadata->topup);
}
public function test_an_expired_confirmation_is_a_conflict(): void
{
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$this->respond(409, ['message' => 'The order was not confirmed in time.', 'error' => 'confirmation_expired']);
$order = $this->owner()->orders()->create(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []]);
$this->assertSame(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []], $this->sentJson(1));
try {
$order->checkout();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('confirmation_expired', $e->error);
}
}
}
+192
View File
@@ -0,0 +1,192 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSession;
use Anikeen\Id\Resources\AppSubscription;
use Anikeen\Id\Resources\CheckoutSession;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class CheckoutSessionsTest 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 sessionData(array $overrides = []): array
{
return ['data' => [
'object' => 'checkout_session',
'id' => '01JCS',
'user_id' => '01JUSER',
'status' => 'open',
'return_url' => 'https://console.test/builder/done',
'metadata' => ['cart' => '42'],
'confirmation_url' => 'https://id.anikeen.com/checkout/sessions/01JCS',
'subscription_ids' => ['01JS1', '01JS2'],
'subscriptions' => [
['id' => '01JS1', 'user_id' => '01JUSER', 'name' => 'Cloud Server S', 'active' => false, 'payload' => ['product' => 'server-s']],
['id' => '01JS2', 'user_id' => '01JUSER', 'name' => 'Backup', 'active' => false],
],
...$overrides,
]];
}
private function request(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_a_trusted_app_opens_a_session_with_its_own_token(): void
{
$this->respond(201, $this->sessionData());
$session = $this->owner()->appCheckoutSessions()->create([
'return_url' => 'https://console.test/builder/done',
'idempotency_key' => 'cart:42',
'subscriptions' => [
['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']],
['id' => '01JS2'],
],
]);
$this->assertSame('POST /api/v1/client/checkout-sessions Bearer app-token', $this->request(0));
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame([['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']], ['id' => '01JS2']], $this->sentJson(0)['subscriptions']);
$this->assertInstanceOf(AppCheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertTrue($session->isOpen());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$this->assertContainsOnlyInstancesOf(AppSubscription::class, $session->subscriptions());
$this->assertSame('server-s', $session->subscriptions()[0]->payload->product);
$this->assertSame('42', $session->metadata->cart);
}
public function test_the_app_finds_refreshes_and_cancels_sessions(): void
{
$this->respond(200, $this->sessionData(['status' => 'completed', 'confirmation_url' => null]));
$this->respond(200, $this->sessionData(['user_id' => 'someone-else']));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$this->respond(200, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'cancelled', 'confirmation_url' => null]));
$this->respond(409, ['message' => 'The checkout session is cancelled.', 'error' => 'checkout_session_cancelled']);
$sessions = $this->owner()->appCheckoutSessions();
$completed = $sessions->find('01JCS');
$this->assertTrue($completed->isCompleted());
$this->assertNull($completed->confirmationUrl());
$this->assertNull($sessions->find('01JCS'), 'another user\'s session');
$this->assertNull($sessions->find('unknown'));
$session = app(AnikeenId::class)->appCheckoutSessions()->find('01JCS'); // e.g. from a webhook
$session->cancel();
$this->assertTrue($session->isCancelled());
$this->assertSame('POST /api/v1/client/checkout-sessions/01JCS/cancel Bearer app-token', $this->request(4));
try {
$session->cancel();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('checkout_session_cancelled', $e->error);
}
}
public function test_without_a_billable_the_user_id_is_required(): void
{
$this->expectException(InvalidArgumentException::class);
app(AnikeenId::class)->appCheckoutSessions()->create(['subscriptions' => [['id' => '01JS1']]]);
}
public function test_any_app_opens_a_session_with_the_users_token(): void
{
$this->respond(201, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'expired', 'confirmation_url' => null]));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$user = $this->owner();
$session = $user->checkoutSessions()->create(['subscriptions' => [['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]]]);
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
$this->assertArrayNotHasKey('user_id', $this->sentJson(0));
$this->assertInstanceOf(CheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$expired = $user->checkoutSessions()->find('01JCS');
$this->assertTrue($expired->isCancelled());
$this->assertSame(CheckoutSession::STATUS_EXPIRED, $expired->status);
$this->assertNull($user->checkoutSessions()->find('unknown'));
}
public function test_session_subscriptions_carry_a_free_trial(): void
{
$this->respond(201, $this->sessionData());
$this->owner()->appCheckoutSessions()->create(['subscriptions' => [
['group' => 'team-1', 'name' => 'Pro', 'unit' => 'month', 'price' => '9.99', 'trial_days' => 14, 'trial_once' => true],
['group' => 'team-1', 'name' => 'Backup', 'unit' => 'month', 'price' => '1.99', 'trial_ends_at' => '2026-12-01T00:00:00+00:00'],
]]);
$this->assertSame(14, $this->sentJson(0)['subscriptions'][0]['trial_days']);
$this->assertTrue($this->sentJson(0)['subscriptions'][0]['trial_once']);
$this->assertSame('2026-12-01T00:00:00+00:00', $this->sentJson(0)['subscriptions'][1]['trial_ends_at']);
}
public function test_session_subscriptions_carry_a_discount(): void
{
$this->respond(201, $this->sessionData());
$this->owner()->checkoutSessions()->create(['subscriptions' => [
['name' => 'Pro', 'unit' => 'month', 'price' => 4.99, 'discount' => ['label' => 'WELCOME', 'amount_off' => '1.00', 'duration' => 'once']],
]]);
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
$this->assertSame(['label' => 'WELCOME', 'amount_off' => '1.00', 'duration' => 'once'], $this->sentJson(0)['subscriptions'][0]['discount']);
}
public function test_nothing_is_requested_until_a_method_is_called(): void
{
$this->owner()->checkoutSessions();
$this->owner()->appCheckoutSessions();
$this->assertCount(0, $this->sent);
}
public function test_checkout_webhooks_carry_the_session(): void
{
$payload = json_encode(['id' => '01JEV', 'type' => 'checkout.completed', 'created' => time(), 'data' => ['object' => $this->sessionData(['status' => 'completed'])['data']]]);
$timestamp = time();
$signature = 't=' . $timestamp . ',v1=' . hash_hmac('sha256', "{$timestamp}.{$payload}", 'whsec_test');
$event = Webhook::constructEvent($payload, $signature, 'whsec_test');
$this->assertSame(EventType::CHECKOUT_COMPLETED, $event['type']);
$this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']);
$this->assertContains('checkout.expired', EventType::all());
$this->assertContains('subscription.activated', EventType::all());
$this->assertContains('subscription.trial_will_end', EventType::all());
$this->assertContains('subscription.discount_ended', EventType::all());
$this->assertContains('subscription.updated', EventType::all());
}
}
+445
View File
@@ -0,0 +1,445 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
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_billing_overview_carries_the_billing_limit(): void
{
$this->respond(200, ['data' => ['current_charges' => 11.9, 'billing_limit' => ['object' => 'billing_limit', 'limit' => '50.00', 'used' => '11.90', 'remaining' => '38.10', 'reached' => false, 'version' => 3]]]);
$limit = new \Anikeen\Id\Resources\BillingLimit($this->owner()->billingOverview()->billing_limit);
$this->assertSame('38.10', $limit->remaining());
$this->assertTrue($limit->allows('38.10'));
$this->assertNull($limit->usedByApp());
}
public function test_charges_and_billing_limits_of_users_use_the_app_token(): void
{
$charge = ['data' => ['object' => 'charge', 'id' => '01JCHARGE', 'status' => 'open']];
$this->respond(201, $charge);
$this->respond(200, $charge);
$this->respond(200, $charge);
$this->respond(200, ['data' => ['object' => 'billing_limit', 'limit' => null]]);
app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']);
app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE');
app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE');
app(AnikeenId::class)->billingLimitForUser('01JUSER');
$this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0));
$this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(1));
$this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(2));
$this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(3));
}
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_create_user_can_send_an_invitation(): void
{
$this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]);
$result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]);
$this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0));
$this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0));
$this->assertTrue($result->data->created);
$this->assertTrue($result->data->invitation_sent);
}
public function test_find_user_by_email_uses_the_lookup_endpoint(): void
{
$this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]);
$result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com');
$this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0));
$this->assertSame('01JUSER', $result->data->id);
$this->assertFalse($result->data->has_billing_address);
}
public function test_find_user_by_email_returns_null_without_an_account(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com'));
}
public function test_find_user_by_email_throws_other_failures(): void
{
$this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup
$this->respond(500, ['message' => 'Server Error']);
foreach ([404, 500] as $status) {
try {
app(AnikeenId::class)->findUserByEmail('erika@example.com');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame($status, $e->status);
}
}
}
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_billing_portal_links_for_users_use_the_app_token(): void
{
$link = ['object' => 'billing_portal_link', 'url' => 'https://id.test/billing/portal/01JUSER?signature=x', 'user_id' => '01JUSER', 'flow' => 'invoices', 'group' => 'team-1', 'return_url' => 'https://console.test/billing', 'expires_at' => '2026-11-04T10:15:00+00:00'];
$this->respond(201, ['data' => $link]);
$this->respond(201, ['data' => $link]);
$created = app(AnikeenId::class)->createBillingPortalLinkForUser('01JUSER', ['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices']);
$url = $this->owner()->appBillingPortalUrl(['flow' => 'payment_method_update']);
$this->assertSame('billing_portal_link', $created->object);
$this->assertSame($link['url'], $url);
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(0));
$this->assertSame(['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices'], $this->sentJson(0));
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(1));
$this->assertSame(['flow' => 'payment_method_update'], $this->sentJson(1));
}
public function test_a_refused_billing_portal_link_is_an_api_exception(): void
{
$this->respond(422, ['message' => 'The return URL has to point to one of the app\'s redirect URIs.', 'errors' => ['return_url' => ['…']]]);
try {
$this->owner()->appBillingPortalUrl(['return_url' => 'https://elsewhere.test']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(422, $e->status);
}
}
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));
}
private function preference(string $namespace, string $key, mixed $value, int $version = 7): array
{
return ['namespace' => $namespace, 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'app', 'client_id' => 'app-client']];
}
private function conditions(int $index): array
{
$request = $this->sent[$index]['request'];
return [$request->getHeaderLine('If-Match'), $request->getHeaderLine('If-None-Match')];
}
public function test_global_preferences_of_the_user(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'dark')]]);
$preferences = $this->owner()->globalPreferences();
$this->assertSame('dark', $preferences->get('appearance'));
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_global_preference_with_a_default(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'light')]]);
$owner = $this->owner();
$this->assertSame('light', $owner->globalPreference('appearance'));
$this->assertSame('system', $owner->globalPreference('unknown', 'system'));
$this->assertCount(1, $this->sent);
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_set_global_preference(): void
{
$this->respond(201, ['data' => $this->preference('global', 'appearance', 'dark', 8)]);
$this->respond(200, ['data' => $this->preference('global', 'appearance', 'light', 9)]);
$created = $this->owner()->setGlobalPreference('appearance', 'dark');
$updated = $this->owner()->setGlobalPreference('appearance', 'light', ifVersion: 8);
$this->assertTrue($created->wasCreated());
$this->assertFalse($updated->wasCreated());
$this->assertSame(9, $updated->version);
$this->assertSame('PUT /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame(['value' => 'dark'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"8"', ''], $this->conditions(1));
}
public function test_delete_global_preference(): void
{
$this->respond(204);
$this->respond(204);
$this->owner()->deleteGlobalPreference('appearance');
$this->owner()->deleteGlobalPreference('appearance', ifVersion: 9);
$this->assertSame('DELETE /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame('', (string) $this->sent[0]['request']->getBody());
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"9"', ''], $this->conditions(1));
}
public function test_preferences_by_user_id(): void
{
$this->respond(200, ['data' => [$this->preference('acme', 'plan', 'pro'), $this->preference('acme', 'theme', ['accent' => 'blue'])]]);
$preferences = app(AnikeenId::class)->preferencesByUserId('01JUSER', 'acme');
$this->assertEquals(['plan' => 'pro', 'theme' => (object) ['accent' => 'blue']], $preferences->values());
$this->assertSame('blue', $preferences->find('theme')->value->accent);
$this->assertNull($preferences->find('missing'));
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme Bearer app-token', $this->sentCall(0));
}
public function test_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$preference = app(AnikeenId::class)->preferenceByUserId('01JUSER', 'acme', 'plan');
$this->assertSame('pro', $preference->value);
$this->assertSame(7, $preference->version);
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
}
public function test_set_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(201, ['data' => $this->preference('acme', 'plan', 'pro')]);
$anikeenId = app(AnikeenId::class);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro');
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->assertSame('PUT /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['value' => 'pro'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
$this->assertSame(['', '*'], $this->conditions(2));
}
public function test_delete_preference_by_user_id(): void
{
$this->respond(204);
$this->respond(204);
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan');
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan', ifVersion: 7);
$this->assertSame('DELETE /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
}
public function test_find_user_id_by_preference(): void
{
$this->respond(200, ['data' => ['user_id' => '01JUSER', 'namespace' => 'acme', 'key' => 'customer_id', 'version' => 7, 'updated_at' => '2026-11-06T10:00:00.000000Z']]);
$this->assertSame('01JUSER', app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
$this->assertSame('POST /api/v1/client/preferences/acme/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['key' => 'customer_id', 'value' => 'cus_123'], $this->sentJson(0));
$this->assertSame('', $this->sent[0]['request']->getUri()->getQuery());
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class PreferencesTest 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 preference(string $key, mixed $value, int $version = 7): array
{
return ['data' => ['namespace' => 'global', 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'user', 'client_id' => 'app-client']]];
}
private function path(int $index): string
{
return $this->sent[$index]['request']->getUri()->getPath();
}
public function test_the_lookup_returns_null_when_nobody_holds_the_value(): void
{
$this->respond(404, ['message' => 'The preference is not set.', 'error' => 'preference_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
}
public function test_the_lookup_throws_on_other_not_found_answers(): void
{
$this->respond(404, ['message' => 'The namespace does not exist.', 'error' => 'namespace_not_found']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(404, $e->status);
$this->assertSame('namespace_not_found', $e->error);
}
}
public function test_the_lookup_throws_on_a_404_without_an_error_code(): void
{
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
}
public function test_the_lookup_throws_on_validation_errors(): void
{
$this->respond(422, ['message' => 'The key is not unique.', 'error' => 'key_not_unique']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'plan', 'pro');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame('key_not_unique', $e->error);
}
}
public function test_a_failed_app_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_create_only_write_of_a_set_key_fails(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => 3]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame('preference_exists', $e->error);
$this->assertSame(3, $e->currentVersion());
}
}
public function test_a_failed_user_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => null]);
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 5]);
foreach ([
fn () => $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 4),
fn () => $this->owner()->deleteGlobalPreference('appearance', ifVersion: 4),
] as $i => $call) {
try {
$call();
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame([null, 5][$i], $e->currentVersion());
}
}
}
public function test_a_write_is_either_conditional_or_create_only(): void
{
$this->expectException(InvalidArgumentException::class);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7, createOnly: true);
} finally {
$this->assertCount(0, $this->sent);
}
}
public function test_writes_clear_the_cached_preferences(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(200, $this->preference('appearance', 'dark', 8));
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 8)['data']]]);
$this->respond(204);
$this->respond(200, ['data' => []]);
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE));
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE)); // cached
$owner->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$this->assertSame('dark', $owner->globalPreference(SharedPreference::APPEARANCE));
$owner->deleteGlobalPreference(SharedPreference::APPEARANCE);
$this->assertSame(Appearance::SYSTEM, $owner->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$this->assertCount(5, $this->sent);
$this->assertSame(['GET', 'PUT', 'GET', 'DELETE', 'GET'], array_map(fn ($sent) => $sent['request']->getMethod(), $this->sent));
}
public function test_a_failed_write_clears_the_cached_preferences_too(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(412, ['message' => 'x', 'error' => 'preference_version_mismatch', 'current_version' => 9]);
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 9)['data']]]);
$owner->globalPreferences();
try {
$owner->setGlobalPreference('appearance', 'dark', ifVersion: 7);
} catch (PreconditionFailedException) {
}
$this->assertSame('dark', $owner->globalPreference('appearance'));
$this->assertCount(3, $this->sent);
}
public function test_path_segments_are_encoded(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$this->respond(200, ['data' => ['user_id' => '01JUSER']]);
$anikeenId = app(AnikeenId::class);
$anikeenId->preferencesByUserId('a/b', 'x y');
$anikeenId->deletePreferenceByUserId('01JUSER', 'acme', '../key?');
$anikeenId->findUserIdByPreference('ac/me', 'k', 'v');
$this->assertSame('/api/v1/client/users/a%2Fb/preferences/x%20y', $this->path(0));
$this->assertSame('/api/v1/client/users/01JUSER/preferences/acme/..%2Fkey%3F', $this->path(1));
$this->assertSame('/api/v1/client/preferences/ac%2Fme/lookup', $this->path(2));
}
public function test_values_round_trip_as_json(): void
{
$this->respond(201, $this->preference('settings', (object) []));
$preference = app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'settings', (object) []);
$this->assertSame('{"value":{}}', (string) $this->sent[0]['request']->getBody());
$this->assertEquals((object) [], $preference->value);
}
public function test_a_deleted_user_is_not_found(): void
{
$this->respond(404, ['message' => 'The user does not exist.', 'error' => 'user_not_found']);
try {
app(AnikeenId::class)->preferenceByUserId('01JGONE', 'acme', 'plan');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('user_not_found', $e->error);
}
}
public function test_an_unreachable_service_is_temporary(): void
{
$this->http->append(new \GuzzleHttp\Exception\ConnectException('down', new \GuzzleHttp\Psr7\Request('DELETE', 'x')));
try {
$this->owner()->deleteGlobalPreference('appearance');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isTemporary());
}
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\SshKeys;
use Anikeen\Id\Support\AppRequest;
class RequestHeadersTest extends TestCase
{
public function test_requests_without_extra_headers_send_the_same_headers_as_before(): void
{
$this->respond(200, ['data' => []]);
AppRequest::send('GET', 'v1/client/subscriptions', null, ['page' => 1]);
$request = $this->sent[0]['request'];
$this->assertSame(['Bearer app-token'], $request->getHeader('Authorization'));
$this->assertSame(['app-client'], $request->getHeader('Client-ID'));
$this->assertFalse($request->hasHeader('If-Match'));
$this->assertFalse($request->hasHeader('If-None-Match'));
$this->assertSame('page=1', $request->getUri()->getQuery());
}
public function test_app_requests_send_extra_headers_also_on_the_retry_with_a_fresh_token(): void
{
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->respond(200, ['data' => []]);
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->assertSame('"7"', $this->sent[0]['request']->getHeaderLine('If-Match'));
$this->assertSame('"7"', $this->sent[1]['request']->getHeaderLine('If-Match'));
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
}
public function test_extra_headers_do_not_replace_the_client_and_token_headers(): void
{
$this->respond(200, ['data' => []]);
app(AnikeenId::class)->withToken('user-token')
->put('v1/x', ['value' => 1], [], null, ['If-None-Match' => '*', 'Authorization' => 'Bearer other']);
$request = $this->sent[0]['request'];
$this->assertSame('*', $request->getHeaderLine('If-None-Match'));
$this->assertSame('Bearer user-token', $request->getHeaderLine('Authorization'));
}
public function test_get_and_delete_helpers_take_headers(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
$anikeenId->get('v1/x', [], null, ['If-None-Match' => '"3"']);
$anikeenId->delete('v1/x', [], [], null, ['If-Match' => '"3"']);
$this->assertSame('"3"', $this->sent[0]['request']->getHeaderLine('If-None-Match'));
$this->assertSame('"3"', $this->sent[1]['request']->getHeaderLine('If-Match'));
}
public function test_a_412_of_an_app_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_412_of_a_user_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => null]);
$result = app(AnikeenId::class)->withToken('user-token')->put('v1/x', ['value' => 1]);
$e = ApiException::fromResult($result);
$this->assertInstanceOf(PreconditionFailedException::class, $e);
$this->assertSame('preference_exists', $e->error);
$this->assertNull($e->currentVersion());
}
public function test_list_errors_keep_their_collection_exception_type(): void
{
$this->respond(412, ['message' => 'x', 'error' => 'x']);
$this->expectException(CollectionException::class);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
SshKeys::builder(fn () => $anikeenId->get('v1/ssh-keys'));
}
public function test_other_errors_stay_plain_api_exceptions(): void
{
$this->respond(409, ['message' => 'Taken.', 'error' => 'preference_value_taken']);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1]);
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(ApiException::class, $e::class);
$this->assertTrue($e->isConflict());
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Scope;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionClass;
class ScopeTest extends TestCase
{
public static function newScopes(): array
{
return [
'products:read' => [Scope::PRODUCTS_READ, 'products:read'],
'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'],
'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'],
'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'],
'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'],
];
}
#[DataProvider('newScopes')]
public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void
{
$this->assertSame($expected, $constant);
}
public function test_the_preference_scopes_match_anikeen_id(): void
{
$this->assertSame('preferences', Scope::PREFERENCES);
$this->assertSame('preferences:read', Scope::PREFERENCES_READ);
$this->assertSame('preferences:client', Scope::PREFERENCES_CLIENT);
}
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
{
// the Console's product APIs accept `user`, `user:read` and `products:read` only
$this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants());
}
public function test_every_scope_follows_the_resource_and_level_schema(): void
{
$scopes = array_values((new ReflectionClass(Scope::class))->getConstants());
$this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique');
foreach ($scopes as $scope) {
// "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope);
}
}
public function test_each_reseller_resource_is_its_own_group(): void
{
$groups = collect((new ReflectionClass(Scope::class))->getConstants())
->filter(fn (string $scope) => str_starts_with($scope, 'reseller-'))
->groupBy(fn (string $scope) => explode(':', $scope)[0])
->map(fn ($scopes) => $scopes->sort()->values()->all())
->all();
$this->assertSame([
'reseller-customers' => ['reseller-customers', 'reseller-customers:read'],
'reseller-grants' => ['reseller-grants'],
'reseller-orders' => ['reseller-orders'],
], $groups);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
class SharedPreferenceTest extends TestCase
{
public function test_the_shared_keys_and_values_match_anikeen_id(): void
{
$this->assertSame('global', SharedPreference::NAMESPACE);
$this->assertSame('appearance', SharedPreference::APPEARANCE);
$this->assertSame(['system', 'light', 'dark'], Appearance::all());
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model;
class ShipmentsTest 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 order(): \Anikeen\Id\Resources\Order
{
$this->respond(200, ['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0], 'links' => ['next' => null]]);
$this->respond(200, ['data' => ['id' => 'ord1', 'status' => 'completed']]);
return $this->owner()->orders()->find('ord1');
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_an_order_is_shipped_with_carrier_tracking_and_items(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1', 'carrier' => 'DHL']]);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '0034',
'items' => [['order_item_id' => 'item1', 'units' => 1]],
]);
$this->assertSame('LS-2026-0001', $shipment->number);
$this->assertSame('GET /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(2));
$this->assertSame('POST /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(3));
$this->assertSame(['carrier' => 'DHL', 'tracking_number' => '0034', 'items' => [['order_item_id' => 'item1', 'units' => 1]]], $this->sentJson(3));
}
public function test_shipments_are_listed_and_the_delivery_note_is_downloaded(): void
{
$order = $this->order();
$this->respond(200, ['data' => [['id' => 'shp1', 'number' => 'LS-2026-0001']]]);
$this->respond(200, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 test'));
$shipments = $order->shipments();
$this->assertSame('LS-2026-0001', $shipments->toArray()[0]->number);
$pdf = $shipments->find('shp1')->deliveryNote();
$this->assertSame('%PDF-1.7 test', $pdf);
$this->assertSame('GET /api/v1/orders/ord1/shipments/shp1/delivery-note Bearer user-token', $this->sentCall(4));
}
public function test_refusals_keep_their_status(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(409, ['message' => 'Only paid orders can be shipped.', 'error' => 'order_not_paid']);
try {
$order->shipments()->create();
$this->fail('expected a ResourceException');
} catch (ResourceException $e) {
$this->assertSame(409, $e->getCode());
$this->assertStringContainsString('Only paid orders can be shipped', $e->getMessage());
}
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\SubscriptionStatus;
use Anikeen\Id\Resources\AppSubscription;
use Illuminate\Database\Eloquent\Model;
use PHPUnit\Framework\Attributes\DataProvider;
class SubscriptionStatusTest extends TestCase
{
private const BASE = ['id' => '01JSUB', 'active' => false, 'autorenew' => true, 'cancelled' => false, 'starts_at' => null, 'cancels_at' => null, 'trial_ends_at' => null];
public static function states(): array
{
$started = ['starts_at' => '2026-10-01T00:00:00+00:00'];
return [
'draft' => [[], SubscriptionStatus::DRAFT, false],
'active' => [['active' => true, ...$started], SubscriptionStatus::ACTIVE, true],
'trial' => [['active' => true, 'trial_ends_at' => date(DATE_ATOM, time() + 86400), ...$started], SubscriptionStatus::TRIAL, true],
'trial over' => [['active' => true, 'trial_ends_at' => '2026-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::ACTIVE, true],
'paused' => [['active' => true, 'autorenew' => false, ...$started], SubscriptionStatus::PAUSED, true],
'cancelling' => [['active' => true, 'cancels_at' => '2027-01-01T00:00:00+00:00', ...$started], SubscriptionStatus::CANCELLING, true],
'expired' => [['autorenew' => false, ...$started], SubscriptionStatus::EXPIRED, false],
'cancelled' => [['autorenew' => false, 'cancelled' => true, ...$started], SubscriptionStatus::CANCELLED, false],
];
}
#[DataProvider('states')]
public function test_the_status_follows_the_fields(array $fields, string $status, bool $running): void
{
$data = [...self::BASE, ...$fields];
$this->respond(200, ['data' => [$data]]); // subscriptions() loads the list
$this->respond(200, ['data' => $data]);
$owner = new class extends Model {
use Billable;
};
$owner->anikeen_id_access_token = 'user-token';
$subscription = $owner->subscriptions()->find('01JSUB');
$app = new AppSubscription((object) $data);
foreach ([$subscription, $app] as $resource) {
$this->assertSame($status, $resource->status());
$this->assertSame($running, $resource->isActive());
$this->assertSame($status === SubscriptionStatus::DRAFT, $resource->isDraft());
$this->assertSame($status === SubscriptionStatus::TRIAL, $resource->isTrial());
$this->assertSame($status === SubscriptionStatus::PAUSED, $resource->isPaused());
$this->assertSame($status === SubscriptionStatus::CANCELLING, $resource->isCancelling());
$this->assertSame($status === SubscriptionStatus::EXPIRED, $resource->isExpired());
$this->assertSame($status === SubscriptionStatus::CANCELLED, $resource->isCancelled());
}
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Providers\AnikeenIdServiceProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
protected MockHandler $http;
/** @var list<array{request: \Psr\Http\Message\RequestInterface}> */
protected array $sent = [];
protected function getPackageProviders($app): array
{
return [AnikeenIdServiceProvider::class];
}
protected function setUp(): void
{
parent::setUp();
config(['services.anikeen.client_id' => 'app-client', 'services.anikeen.client_secret' => 'secret']);
$this->http = new MockHandler();
$stack = HandlerStack::create($this->http);
$stack->push(Middleware::history($this->sent));
$this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new Client(['handler' => $stack, 'base_uri' => 'https://id.test/api/'])));
$this->app->instance(AppTokenRepository::class, new class implements AppTokenRepository {
public int $forgotten = 0;
public function getAccessToken(): string
{
return $this->forgotten ? 'fresh-app-token' : 'app-token';
}
public function forgetAccessToken(): void
{
$this->forgotten++;
}
});
}
protected function respond(int $status, array $body = []): void
{
$this->http->append(new Response($status, ['Content-Type' => 'application/json'], json_encode($body)));
}
protected function sentJson(int $index): array
{
return json_decode((string) $this->sent[$index]['request']->getBody(), true) ?? [];
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\HasAnikeenTokens;
use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
use Anikeen\Id\Http\Middleware\CheckScopes;
use Firebase\JWT\JWT;
use Illuminate\Auth\RequestGuard;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class TokenGuardTest extends TestCase
{
private static ?string $privateKey = null;
private static string $publicKey;
protected function setUp(): void
{
parent::setUp();
if (self::$privateKey === null) {
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($key, $private);
self::$privateKey = $private;
self::$publicKey = openssl_pkey_get_details($key)['key'];
}
JwtParser::flushKeys();
config([
'services.anikeen.public_key' => self::$publicKey,
'app.key' => 'base64:'.base64_encode(str_repeat('k', 32)),
'database.default' => 'testing',
'auth.guards.api' => ['driver' => 'anikeen', 'provider' => 'anikeen'],
'auth.providers.anikeen' => ['driver' => 'anikeen', 'model' => GuardUser::class],
]);
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('anikeen_id')->nullable();
$table->string('email');
});
GuardUser::query()->insert([
['id' => 1, 'anikeen_id' => 'ulid-of-alice', 'email' => 'alice@example.com'],
['id' => 2, 'anikeen_id' => '1', 'email' => 'bob@example.com'],
]);
Route::middleware('auth:api')->get('/me', fn () => ['email' => auth()->user()->email, 'scopes' => auth()->user()->anikeenToken()->scopes]);
Route::middleware(['auth:api', CheckScopes::using('user')])->post('/write', fn () => ['ok' => true]);
Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get('/read', fn () => ['ok' => true]);
}
/**
* Like a fresh request in production: the guard remembers its user per application.
*/
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
Auth::forgetGuards();
return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content);
}
private function token(array $claims = [], ?string $privateKey = null): string
{
$now = time();
return JWT::encode(array_merge([
'aud' => '019a0000-0000-7000-8000-000000000001',
'jti' => Str::random(40),
'iat' => $now,
'nbf' => $now,
'exp' => $now + 3600,
'sub' => 'ulid-of-alice',
'scopes' => ['user'],
], $claims), $privateKey ?? self::$privateKey, 'RS256');
}
public function test_the_api_guard_resolves(): void
{
$this->assertInstanceOf(RequestGuard::class, Auth::guard('api'));
$this->assertNull(Auth::guard('api')->user());
}
public function test_a_valid_token_resolves_the_user_by_anikeen_id(): void
{
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
->assertOk()
->assertExactJson(['email' => 'alice@example.com', 'scopes' => ['user']]);
}
public function test_the_user_is_not_looked_up_by_primary_key(): void
{
// sub "2" is bob's primary key, but nobody has the Anikeen ID "2"
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '2'])])->assertUnauthorized();
// sub "1" is bob's Anikeen ID (alice's primary key)
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '1'])])
->assertOk()->assertJsonPath('email', 'bob@example.com');
}
public function test_the_lookup_column_is_configurable(): void
{
config(['auth.providers.anikeen.column' => 'email']);
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => 'bob@example.com'])])
->assertOk()->assertJsonPath('email', 'bob@example.com');
}
public function test_the_eloquent_provider_works_too(): void
{
config([
'auth.guards.api.provider' => 'users',
'auth.providers.users' => ['driver' => 'eloquent', 'model' => GuardUser::class],
]);
$this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()])
->assertOk()->assertJsonPath('email', 'alice@example.com');
}
public function test_invalid_tokens_answer_401_json(): void
{
$other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($other, $otherPrivate);
$cases = [
'no token' => [],
'garbage' => ['Authorization' => 'Bearer not-a-jwt'],
'expired' => ['Authorization' => 'Bearer '.$this->token(['exp' => time() - 120])],
'not yet valid' => ['Authorization' => 'Bearer '.$this->token(['nbf' => time() + 600])],
'foreign key' => ['Authorization' => 'Bearer '.$this->token([], $otherPrivate)],
'unknown user' => ['Authorization' => 'Bearer '.$this->token(['sub' => 'nobody'])],
'client credentials' => ['Authorization' => 'Bearer '.$this->token(['sub' => ''])],
];
foreach ($cases as $case => $headers) {
$this->getJson('/me', $headers)->assertUnauthorized()->assertJson(['message' => 'Unauthenticated.'], $case);
}
}
public function test_scopes_are_enforced(): void
{
$read = $this->token(['scopes' => ['user:read']]);
$billing = $this->token(['scopes' => ['billing']]);
$all = $this->token(['scopes' => ['*']]);
$this->getJson('/read', ['Authorization' => "Bearer $read"])->assertOk();
$this->postJson('/write', [], ['Authorization' => "Bearer $read"])->assertForbidden();
$this->getJson('/read', ['Authorization' => "Bearer $billing"])->assertForbidden();
$this->postJson('/write', [], ['Authorization' => "Bearer $all"])->assertOk();
$this->postJson('/write', [], ['Authorization' => 'Bearer '.$this->token()])->assertOk();
}
public function test_token_can(): void
{
$user = GuardUser::query()->find(1)->withAnikeenAccessToken((object) ['scopes' => ['user:read']]);
$this->assertTrue($user->anikeenTokenCan('user:read'));
$this->assertFalse($user->anikeenTokenCan('user'));
$this->assertTrue($user->anikeenTokenCanAny(['user', 'user:read']));
$this->assertFalse((new GuardUser())->anikeenTokenCan('user'));
}
public function test_revoked_tokens_are_rejected_when_revocation_is_verified(): void
{
config(['services.anikeen.verify_revocation' => true]);
$active = $this->token();
$revoked = $this->token();
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
// cached: no second request to Anikeen ID
$this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk();
$this->assertCount(1, $this->sent);
$this->assertSame("Bearer $active", $this->sent[0]['request']->getHeaderLine('Authorization'));
$this->assertStringEndsWith('v1/user', (string) $this->sent[0]['request']->getUri());
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->getJson('/me', ['Authorization' => "Bearer $revoked"])->assertUnauthorized();
// Anikeen ID down: fail closed, not cached
$other = $this->token();
$this->respond(503);
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertUnauthorized();
$this->respond(200, ['data' => ['id' => 'ulid-of-alice']]);
$this->getJson('/me', ['Authorization' => "Bearer $other"])->assertOk();
}
}
class GuardUser extends Authenticatable
{
use HasAnikeenTokens;
protected $table = 'users';
public $timestamps = false;
protected $guarded = [];
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserApiExceptionsTest extends TestCase
{
private function user(): Model
{
$user = new class extends Model {
use Billable;
};
$user->anikeen_id_access_token = 'user-token';
return $user;
}
public function test_a_refused_request_carries_status_and_error_code(): void
{
$this->respond(200, ['data' => []]); // orders() loads the list
$this->respond(422, ['message' => 'The billing address is missing.', 'error' => 'billing_address_missing']);
try {
$this->user()->orders()->create(['items' => []]);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertInstanceOf(ResourceException::class, $e, 'existing catch (ResourceException) blocks keep working');
$this->assertSame(422, $e->status);
$this->assertSame(422, $e->getCode());
$this->assertSame('billing_address_missing', $e->error);
$this->assertSame('The billing address is missing', $e->getMessage());
$this->assertFalse($e->isTemporary());
}
}
public function test_an_unknown_id_is_not_found(): void
{
$this->respond(200, ['data' => []]);
$this->respond(404, ['message' => 'Not found.']);
try {
$this->user()->orders()->find('01JMISSING');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertNull($e->error);
}
}
public function test_a_failed_list_is_an_api_exception_too(): void
{
$this->respond(503, ['message' => 'Maintenance.']);
try {
$this->user()->invoices();
$this->fail('Expected a CollectionException.');
} catch (CollectionException $e) {
$this->assertInstanceOf(ApiException::class, $e);
$this->assertSame(503, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_no_response_is_temporary_with_status_zero(): void
{
$this->respond(200, ['data' => []]);
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/orders/01J')));
try {
$this->user()->orders()->find('01J');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
$this->assertInstanceOf(ConnectException::class, $e->getPrevious());
}
}
public function test_confirmation_required_stays_its_own_exception(): void
{
$this->respond(200, ['data' => []]);
$this->respond(200, ['data' => ['id' => '01JORDER', 'status' => 'draft']]);
$this->respond(403, ['message' => 'Confirm it on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.test/confirm/01JORDER']);
$this->expectException(ConfirmationRequiredException::class);
$this->user()->orders()->find('01JORDER')->checkout();
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Webhooks\EventType;
class UserByIdTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void
{
$this->respond(200, $this->profile());
$user = app(AnikeenId::class)->findUserById('01JUSER');
$this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0));
$this->assertInstanceOf(User::class, $user);
$this->assertSame('01JUSER', $user->id());
$this->assertSame('Ada King', $user->name());
$this->assertSame('ada', $user->username());
$this->assertSame('ada@example.com', $user->email());
$this->assertTrue($user->emailVerified());
$this->assertSame('https://id.test/avatars/01JUSER', $user->avatar());
$this->assertSame(8, $user->version());
$this->assertSame('en', $user->locale);
$this->assertNull($user->pendingEmail());
$this->assertSame('Ada King', $user->jsonSerialize()['name']);
}
public function test_find_user_by_id_encodes_the_id(): void
{
$this->respond(200, $this->profile());
app(AnikeenId::class)->findUserById('a/b');
$this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath());
}
public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER'));
}
public function test_find_user_by_id_throws_other_failures(): void
{
$this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']);
try {
app(AnikeenId::class)->findUserById('01JUSER');
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(403, $e->status);
$this->assertSame('client_not_allowed', $e->error);
}
// a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user"
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserById('01JUSER');
}
public function test_an_unverified_address_is_not_verified(): void
{
$this->respond(200, $this->profile(['email_verified_at' => null]));
$this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified());
}
public function test_a_user_updated_webhook_object_is_a_user(): void
{
$user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada',
'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]);
$this->assertSame(9, $user->version());
$this->assertFalse($user->emailVerified());
$this->assertNull($user->email());
$this->assertSame('user', $user->object);
$this->assertSame('user.updated', EventType::USER_UPDATED);
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use DateTimeImmutable;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserProfileTest 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 profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
private function validationError(array $codes): array
{
return [
'message' => 'The given data was invalid.',
'error' => array_values($codes)[0],
'errors' => array_map(fn () => ['The value is invalid.'], $codes),
'codes' => $codes,
];
}
public function test_update_profile_patches_the_fields_with_the_user_token(): void
{
$this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9,
'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z']));
$user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']);
$this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0));
$this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0));
$this->assertSame('Ada Lovelace', $user->name());
$this->assertSame('ada@example.com', $user->email());
$this->assertSame('new@example.com', $user->pendingEmail());
$this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt());
$this->assertSame(9, $user->version());
}
public function test_update_profile_clears_the_cached_user_data(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->assertSame('Ada King', $owner->getUserData()->name);
$owner->updateProfile(['name' => 'Ada Lovelace']);
$this->assertSame('Ada Lovelace', $owner->getUserData()->name);
$this->assertCount(3, $this->sent);
$this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2));
}
public function test_refused_profile_changes_carry_codes_per_field(): void
{
$this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid']));
try {
$this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('username_taken', $e->error);
$this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes());
$this->assertSame('email_invalid', $e->errorCodeFor('email'));
$this->assertNull($e->errorCodeFor('name'));
$this->assertTrue($e->hasErrorCode('email_invalid'));
$this->assertFalse($e->hasErrorCode('email_taken'));
$this->assertSame(['The value is invalid.'], $e->errors()['username']);
$this->assertInstanceOf(ApiException::class, $e);
}
}
public function test_a_client_credentials_token_is_refused(): void
{
$this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']);
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertNotInstanceOf(ValidationFailedException::class, $e);
$this->assertSame('user_token_required', $e->error);
}
}
public function test_an_unreachable_service_is_an_api_exception(): void
{
$this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile')));
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_upload_avatar_sends_the_picture_as_multipart(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile(['profile_version' => 10]));
try {
$user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]);
} finally {
unlink($path);
}
$request = $this->sent[0]['request'];
$body = (string) $request->getBody();
$this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0));
$this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type'));
$this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body);
$this->assertStringContainsString('picture-bytes', $body);
$this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body);
$this->assertStringContainsString('name="crop[y]"', $body);
$this->assertStringContainsString('name="crop[size]"', $body);
$this->assertSame(10, $user->version());
}
public function test_upload_avatar_without_crop_sends_only_the_picture(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile());
try {
$this->owner()->uploadAvatar(new \SplFileInfo($path));
} finally {
unlink($path);
}
$body = (string) $this->sent[0]['request']->getBody();
$this->assertStringContainsString('name="avatar"', $body);
$this->assertStringNotContainsString('crop', $body);
}
public function test_a_refused_upload_carries_its_code(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'not a picture');
$this->respond(422, $this->validationError(['avatar' => 'avatar_invalid']));
$this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']);
try {
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('avatar_invalid', $e->errorCodeFor('avatar'));
}
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(413, $e->status);
$this->assertSame('avatar_too_large', $e->error);
}
} finally {
unlink($path);
}
}
public function test_crop_codes_are_found_under_the_crop_field(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'a picture');
$this->respond(422, $this->validationError(['crop.size' => 'crop_invalid']));
try {
$this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('crop_invalid', $e->errorCodeFor('crop'));
$this->assertSame('crop_invalid', $e->errorCodeFor('crop.size'));
$this->assertNull($e->errorCodeFor('cro'));
$this->assertNull($e->errorCodeFor('avatar'));
} finally {
unlink($path);
}
}
public function test_an_unreadable_file_throws_before_anything_is_sent(): void
{
try {
$this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png');
$this->fail('No exception thrown');
} catch (\RuntimeException $e) {
$this->assertNotInstanceOf(ApiException::class, $e);
$this->assertCount(0, $this->sent);
}
}
public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['profile_version' => 11]));
$this->respond(200, $this->profile(['profile_version' => 11]));
$owner->getUserData();
$user = $owner->removeAvatar();
$owner->getUserData();
$this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1));
$this->assertSame(11, $user->version());
$this->assertCount(3, $this->sent);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Illuminate\Database\Eloquent\Model;
class ValidationFailedTest 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;
}
public function test_422_of_app_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10));
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('value_too_large', $e->error);
$this->assertSame([], $e->errorCodes());
$this->assertTrue($e->hasErrorCode('value_too_large'));
}
}
public function test_412_stays_a_precondition_failure(): void
{
$this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]);
$this->expectException(PreconditionFailedException::class);
$this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2);
}
public function test_422_of_user_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']);
try {
$this->owner()->setGlobalPreference('appearance', 'purple');
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('invalid_shared_value', $e->error);
$this->assertSame([], $e->errorCodes());
}
}
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more