Compare commits

..
Author SHA1 Message Date
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
envoyr bc9c202c3b update composer.json 2025-09-21 17:15:45 +00:00
envoyr cecf8560ff Merge pull request #1 from anikeen-com/laravel-10
Update composer.json
2025-09-21 19:13:39 +02:00
envoyr 25248e7822 refactor code 2025-09-21 17:12:19 +00:00
envoyr c641ec725d add refund to revoke endpoint 2025-09-21 12:13:56 +00:00
envoyr 8eb0c25582 fix api path 2025-09-19 17:21:23 +00:00
envoyr 30ac4ae4f9 update provider 2025-09-19 16:37:22 +00:00
envoyr e1a6af11a3 introduce new scopes 2025-09-19 15:36:28 +00:00
envoyr bb5df7f115 fix mode
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:28:08 +00:00
envoyr 297404b05d add AllowDynamicProperties attribute
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:22:11 +00:00
envoyr 5ab57dcdfe add staging key
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-18 19:17:50 +00:00
envoyr 0f14fa1b4c remove endpoint
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:32:06 +00:00
envoyr 437e78770c fix typo
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:23:00 +00:00
envoyr 0dbb27fc94 update transaction description
Signed-off-by: Maurice Preuß <hello@envoyr.com>
2025-09-06 13:22:23 +00:00
René Preuß 8232de4003 Update OauthTrait.php 2025-07-30 23:08:13 +02:00
René Preuß ac3e28f67f Update composer.json 2025-07-30 21:52:09 +02:00
envoyr 63e3f0a4a2 add refresh token method
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-02 07:29:43 +02:00
envoyr 1d2119a32b update resources
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-02 06:27:53 +02:00
envoyr 1725ec68de update subscriptions
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-05-01 18:16:51 +02:00
envoyr dcda4b990e update user provider
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 12:00:34 +02:00
envoyr 937fde603b update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:03:27 +02:00
envoyr 80b1f003b2 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:02:53 +02:00
envoyr aff901de4e update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:02:23 +02:00
envoyr 92eadcca08 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 10:00:27 +02:00
envoyr 8b874f540c update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 09:08:29 +02:00
envoyr 3bcebd9d45 update subscription, add alias
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 09:02:24 +02:00
envoyr 71663bffd8 small fixes
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 06:43:07 +02:00
envoyr 5b2b3c72cc add serializable
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 04:23:10 +02:00
envoyr 235918f0c0 fix some issues
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 04:12:54 +02:00
envoyr 4b23f6ddbb refactored code
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-30 03:45:10 +02:00
envoyr 85702fcb2c update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:50:12 +02:00
envoyr 65d4b12006 update identifier
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:47:21 +02:00
envoyr 5d621b7cdc update config
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:46:44 +02:00
envoyr 4d6fe7c325 update provider
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:39:49 +02:00
envoyr d447a88430 add mode
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-29 21:33:02 +02:00
envoyr d9a330222b update billable
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 17:26:02 +02:00
envoyr 21946e3a22 update docs
Signed-off-by: Maurice Preuß (envoyr) <hello@envoyr.com>
2025-04-28 05:32:44 +02:00
109 changed files with 7339 additions and 1075 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
+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`.
+946 -155
View File
File diff suppressed because it is too large. Load diff
+843 -122
View File
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -1,28 +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.0",
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^11.0|^12.0",
"illuminate/console": "^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": "^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;
+14
View File
@@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
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/api/"/>
<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>
+82 -23
View File
@@ -3,7 +3,11 @@
namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels;
use Anikeen\Id\Concerns\ManagesSocialiteUsers;
use Anikeen\Id\Concerns\ManagesSshKeys;
use Anikeen\Id\Concerns\ManagesUserBilling;
use Anikeen\Id\Concerns\ManagesUsers;
use Anikeen\Id\Exceptions\RequestRequiresAuthenticationException;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
@@ -20,6 +24,10 @@ class AnikeenId
{
use OauthTrait;
use ManagesPricing;
use ManagesProducts;
use ManagesShippingLabels;
use ManagesUserBilling;
use ManagesSocialiteUsers;
use ManagesSshKeys;
use ManagesUsers;
@@ -46,11 +54,6 @@ class AnikeenId
*/
public static bool $unserializesCookies = false;
/**
* The base URL for Anikeen ID API.
*/
private static string $baseUrl = 'https://id.anikeen.com/api/';
/**
* The key for the access token.
*/
@@ -61,6 +64,8 @@ class AnikeenId
*/
private static string $refreshTokenField = 'anikeen_id_refresh_token';
private static string $userIdField = 'anikeen_id';
/**
* Guzzle is used to make http requests.
*/
@@ -91,6 +96,16 @@ class AnikeenId
*/
protected ?string $redirectUri = null;
/**
* The base URL for Anikeen ID.
*/
protected string $baseUrl = 'https://id.anikeen.com';
/**
* The staging base URL for Anikeen ID.
*/
protected string $stagingBaseUrl = 'https://staging.id.anikeen.com';
/**
* Constructor.
*/
@@ -105,22 +120,25 @@ class AnikeenId
if ($redirectUri = config('services.anikeen.redirect')) {
$this->setRedirectUri($redirectUri);
}
if (self::getMode() === 'staging' && !config('services.anikeen.base_url')) {
self::setBaseUrl($this->stagingBaseUrl);
}
if ($baseUrl = config('services.anikeen.base_url')) {
self::setBaseUrl($baseUrl);
}
$this->client = new Client([
'base_uri' => self::$baseUrl,
'base_uri' => $this->baseUrl . '/api/',
]);
}
/**
* @param string $baseUrl
*
* @internal only for internal and debug purposes.
*/
public static function setBaseUrl(string $baseUrl): void
protected function setBaseUrl(string $baseUrl): void
{
self::$baseUrl = $baseUrl;
$this->baseUrl = $baseUrl;
}
public function getBaseUrl(): string
{
return rtrim($this->baseUrl, '/');
}
public static function useAccessTokenField(string $accessTokenField): void
@@ -133,6 +151,46 @@ 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';
}
public static function useRefreshTokenField(string $refreshTokenField): void
{
self::$refreshTokenField = $refreshTokenField;
@@ -149,7 +207,7 @@ class AnikeenId
* @param string|null $cookie
* @return string|static
*/
public static function cookie(string $cookie = null): string|static
public static function cookie(?string $cookie = null): string|static
{
if (is_null($cookie)) {
return static::$cookie;
@@ -314,7 +372,7 @@ class AnikeenId
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result
{
if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor();
@@ -322,7 +380,7 @@ class AnikeenId
try {
$response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload),
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
'query' => Query::build($parameters),
'json' => $payload ?: null,
]);
@@ -340,14 +398,15 @@ class AnikeenId
*
* @throws RequestRequiresClientIdException
*/
private function buildHeaders(bool $json = false): array
private function buildHeaders(bool $json = false, bool $useClientSecret = false): array
{
$headers = [
'Client-ID' => $this->getClientId(),
'Accept' => 'application/json',
];
if ($this->token) {
$headers['Authorization'] = 'Bearer ' . $this->token;
// public endpoints (products, order preview, …) work without a token
if ($bearerToken = $useClientSecret ? $this->getClientSecret() : $this->token) {
$headers['Authorization'] = 'Bearer ' . $bearerToken;
}
if ($json) {
$headers['Content-Type'] = 'application/json';
@@ -360,7 +419,7 @@ 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): Result
{
return $this->request('GET', $path, null, $parameters, $paginator);
}
@@ -369,7 +428,7 @@ class AnikeenId
* @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): Result
{
return $this->request('POST', $path, $payload, $parameters, $paginator);
}
@@ -378,7 +437,7 @@ class AnikeenId
* @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): Result
{
return $this->request('PUT', $path, $payload, $parameters, $paginator);
}
@@ -387,7 +446,7 @@ class AnikeenId
* @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): Result
{
return $this->request('DELETE', $path, $payload, $parameters, $paginator);
}
+1 -1
View File
@@ -15,5 +15,5 @@ trait Delete
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function delete(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result;
abstract public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
}
+1 -1
View File
@@ -15,5 +15,5 @@ trait Get
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function get(string $path, array $parameters = [], Paginator $paginator = null): Result;
abstract public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result;
}
+1 -1
View File
@@ -15,5 +15,5 @@ trait Post
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function post(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result;
abstract public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
}
+1 -1
View File
@@ -15,5 +15,5 @@ trait Put
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function put(string $path, array $payload = [], array $parameters = [], Paginator $paginator = null): Result;
abstract public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result;
}
+1 -1
View File
@@ -15,5 +15,5 @@ trait Request
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
abstract public function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result;
abstract public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null): Result;
}
+109 -60
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);
$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);
$request->attributes->set('oauth_scopes', $decoded->scopes);
return $decoded;
} catch (AuthenticationException $e) {
$request->headers->set('Authorization', '', true);
Container::getInstance()->make(
ExceptionHandler::class
)->report($e);
} catch (AuthenticationException) {
return null;
}
$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;
}
/**
* 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(),
@@ -171,8 +220,8 @@ class TokenGuard
protected function validCsrf(array $token, Request $request): bool
{
return isset($token['csrf']) && hash_equals(
$token['csrf'], $this->getTokenFromRequest($request)
);
$token['csrf'], $this->getTokenFromRequest($request)
);
}
/**
@@ -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);
}
}
+32 -21
View File
@@ -2,57 +2,68 @@
namespace Anikeen\Id;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Concerns\ManagesAddresses;
use Anikeen\Id\Concerns\ManagesBalance;
use Anikeen\Id\Concerns\ManagesCountries;
use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Helpers\Paginator;
use GuzzleHttp\Exception\GuzzleException;
use stdClass;
use Anikeen\Id\Concerns\ManagesUserSshKeys;
use Throwable;
trait Billable
{
use ManagesAddresses;
use ManagesBalance;
use ManagesCountries;
use ManagesInvoices;
use ManagesOrders;
use ManagesPaymentMethods;
use ManagesProfile;
use ManagesSubscriptions;
use ManagesTaxation;
use ManagesTransactions;
use Request;
use ManagesUserSshKeys;
protected stdClass|null $userData = null;
/*
* 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;
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
/**
* Get the currently authenticated user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
protected function getUserData(): stdClass
public function getUserData(): object
{
if (!$this->userData) {
$this->userData = $this->request('GET', 'v1/user')->data;
if (!isset($this->userDataCache)) {
$this->userDataCache = $this->anikeenId()->request('GET', 'v1/user')->data;
}
return $this->userData;
return $this->userDataCache;
}
/**
* Make a request to the Anikeen API.
* Get the AnikeenId class.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
protected function request(string $method, string $path, null|array $payload = null, array $parameters = [], Paginator $paginator = null): Result
public function anikeenId(): AnikeenId
{
$anikeenId = new AnikeenId();
$anikeenId->withToken($this->{AnikeenId::getAccessTokenField()});
return $anikeenId->request($method, $path, $payload, $parameters, $paginator);
return app(AnikeenId::class)->withToken($this->{AnikeenId::getAccessTokenField()});
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Contracts\Billable;
use Illuminate\Database\Eloquent\Model;
trait HasBillable
{
public Billable|Model $billable;
public function setBillable(Billable|Model $billable): self
{
$this->billable = $billable;
return $this;
}
public function getBillable(): Billable
{
return $this->billable;
}
}
+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]);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Concerns;
trait HasParent
{
protected mixed $parent;
public function setParent(mixed $parent): self
{
$this->parent = $parent;
return $this;
}
public function getParent()
{
return $this->parent;
}
}
+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 ?? []));
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Addresses;
use Throwable;
trait ManagesAddresses
{
use HasBillable;
/**
* Get addresses from the current user.
*
* @throws Throwable
*/
public function addresses(): Addresses
{
if (!isset($this->addressesCache)) {
$this->addressesCache = Addresses::builder(fn() => $this->anikeenId()
->request('GET', 'v1/addresses'))
->setBillable($this);
}
return $this->addressesCache;
}
/**
* Check if the current user has a default billing address.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\Addresses::hasDefaultBillingAddress()
*/
public function hasDefaultBillingAddress(): bool
{
return $this->addresses()->hasDefaultBillingAddress();
}
}
+28 -21
View File
@@ -2,20 +2,17 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Anikeen\Id\Resources\Transaction;
use Throwable;
trait ManagesBalance
{
use Request;
use HasBillable;
/**
* Get balance from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function balance(): float
{
@@ -25,8 +22,7 @@ trait ManagesBalance
/**
* Get charges from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function charges(): float
{
@@ -34,20 +30,31 @@ trait ManagesBalance
}
/**
* Charge given amount from bank to current user.
* Billing overview of the current user: current_balance, current_charges, vat_rate,
* has_billing_address, has_default_payment_method, open_orders.
*
* @param float $amount Amount to charge in euros.
* @param string $paymentMethodId Payment method ID.
* @param array $options Additional options for the charge.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function charge(float $amount, string $paymentMethodId, array $options = []): Result
public function billingOverview(): object
{
return $this->request('POST', 'billing/charge', [
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
'options' => $options,
]);
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', 'v1/billing/charge', [
'amount' => $amount,
'payment_method_id' => $paymentMethodId,
])))
->setBillable($this);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\Countries;
use Throwable;
trait ManagesCountries
{
use HasBillable;
/**
* Get available countries for the current user.
*
* @throws Throwable
*/
public function countries(): Countries
{
if (!isset($this->countriesCache)) {
$this->countriesCache = Countries::builder(fn() => $this->anikeenId()
->request('GET', 'v1/countries'))
->setBillable($this);
}
return $this->countriesCache;
}
}
+11 -32
View File
@@ -2,47 +2,26 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Anikeen\Id\Resources\Invoices;
use Throwable;
trait ManagesInvoices
{
use Request;
use HasBillable;
/**
* Get invoices from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function invoices(): Result
public function invoices(array $parameters = []): Invoices
{
return $this->request('GET', 'v1/invoices');
}
if (!isset($this->invoicesCache)) {
$this->invoicesCache = Invoices::builder(fn() => $this->anikeenId()
->request('GET', 'v1/invoices', [], $parameters))
->setBillable($this);
}
/**
* Get given invoice from the current user.
*
* @param string $invoiceId The invoice ID
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function invoice(string $invoiceId): Result
{
return $this->request('GET', sprintf('v1/invoices/%s', $invoiceId));
}
/**
* Get download url from given invoice.
*
* @param string $invoiceId The invoice ID
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function getInvoiceDownloadUrl(string $invoiceId): string
{
return $this->request('PUT', sprintf('v1/invoices/%s', $invoiceId))->data->download_url;
return $this->invoicesCache;
}
}
+11 -244
View File
@@ -2,259 +2,26 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Anikeen\Id\Resources\Orders;
use Throwable;
trait ManagesOrders
{
use Request;
use HasBillable;
/**
* Get orders from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function orders(): Result
public function orders(array $parameters = []): Orders
{
return $this->request('GET', 'v1/orders');
}
if (!isset($this->ordersCache)) {
$this->ordersCache = Orders::builder(fn() => $this->anikeenId()
->request('GET', 'v1/orders', [], $parameters))
->setBillable($this);
}
/**
* Creates a new order for the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* 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)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createOrder(array $attributes = []): Result
{
return $this->request('POST', 'v1/orders', $attributes);
}
/**
* Get given order from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function order(string $orderId): Result
{
return $this->request('GET', sprintf('v1/orders/%s', $orderId));
}
/**
* Update given order from the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* @param string $orderId The order ID.
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* house_number: null|string,
* city: string,
* state: string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* }
* } $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)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function updateOrder(string $orderId, array $attributes = []): Result
{
return $this->request('PUT', sprintf('v1/orders/%s', $orderId), $attributes);
}
/**
* Checkout given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function checkoutOrder(string $orderId): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/checkout', $orderId));
}
/**
* Revoke given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function revokeOrder(string $orderId): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/revoke', $orderId));
}
/**
* Delete given order from the current user.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteOrder(string $orderId): Result
{
return $this->request('DELETE', sprintf('v1/orders/%s', $orderId));
}
/**
* Get order items from given order.
*
* @param string $orderId The order ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function orderItems(string $orderId): Result
{
return $this->request('GET', sprintf('v1/orders/%s/items', $orderId));
}
/**
* Create a new order item for given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createOrderItem(string $orderId, array $attributes = []): Result
{
return $this->request('POST', sprintf('v1/orders/%s', $orderId), $attributes);
}
/**
* Get given order item from given order.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function orderItem(string $orderId, string $orderItemId): Result
{
return $this->request('GET', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId));
}
/**
* Update given order item from given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function updateOrderItem(string $orderId, string $orderItemId, array $attributes = []): Result
{
return $this->request('PUT', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId), $attributes);
}
/**
* Delete given order item from given order.
*
* @param string $orderId The order ID.
* @param string $orderItemId The order item ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteOrderItem(string $orderId, string $orderItemId): Result
{
return $this->request('DELETE', sprintf('v1/orders/%s/items/%s', $orderId, $orderItemId));
return $this->ordersCache;
}
}
+60 -40
View File
@@ -2,86 +2,106 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Resources\PaymentMethod;
use Anikeen\Id\Resources\PaymentMethods;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
trait ManagesPaymentMethods
{
use Request;
/**
* Check if current user has at least one payment method.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function hasPaymentMethod(): bool
{
return $this->paymentMethods()->count() > 0;
}
use HasBillable;
/**
* Get payment methods from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function paymentMethods(): Result
public function paymentMethods(): PaymentMethods
{
return $this->request('GET', 'v1/payment-methods');
if (!isset($this->paymentMethodsCache)) {
$this->paymentMethodsCache = PaymentMethods::builder(fn() => $this->anikeenId()
->request('GET', 'v1/payment-methods'))
->setBillable($this);
}
return $this->paymentMethodsCache;
}
/**
* Check if current user has at least one payment method.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::hasPaymentMethod()
*/
public function hasPaymentMethod(): ?PaymentMethod
{
return $this->paymentMethods()->hasPaymentMethod();
}
/**
* Get default payment method from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::defaultPaymentMethod()
*/
public function defaultPaymentMethod(): ?PaymentMethod
{
return $this->paymentMethods()->defaultPaymentMethod();
}
/**
* Check if the current user has a default payment method.
*
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::hasDefaultPaymentMethod()
*/
public function hasDefaultPaymentMethod(): bool
{
return $this->defaultPaymentMethod()->count() > 0;
return $this->paymentMethods()->hasDefaultPaymentMethod();
}
/**
* Get default payment method from the current user.
* Set the default payment method for the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
* @see \Anikeen\Id\Resources\PaymentMethods::setDefault()
*/
public function defaultPaymentMethod(): Result
public function setDefaultPaymentMethod(string $id): PaymentMethod
{
return $this->request('GET', 'v1/payment-methods/default');
unset($this->paymentMethodsCache);
return $this->paymentMethods()->setDefault($id);
}
/**
* Get billing portal URL for the current user.
*
* @param string $returnUrl The URL to redirect to after the user has finished in the billing portal.
* @param string|null $returnUrl The URL to redirect to after the user has finished in the billing portal.
* @param array $options Additional options for the billing portal.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function billingPortalUrl(string $returnUrl, array $options): string
public function billingPortalUrl(?string $returnUrl = null, array $options = []): string
{
return $this->request('POST', 'v1/stripe/billing-portal', [
'return_url' => $returnUrl,
'options' => $options,
])->data->url;
return $this->anikeenId()
->request('POST', 'v1/billing/portal', [
'return_url' => $returnUrl,
'options' => $options,
])
->data
->url;
}
/**
* Create a new setup intent.
*
* @param array $options Additional options for the setup intent.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function createSetupIntent(array $options = []): Result
{
return $this->request('POST', 'v1/payment-methods', [
'options' => $options,
]);
return $this->anikeenId()
->request('POST', 'v1/payment-methods', [
'options' => $options,
]);
}
}
+8 -4
View File
@@ -3,9 +3,8 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
trait ManagesPricing
{
@@ -29,11 +28,16 @@ trait ManagesPricing
* } $attributes The order data:
* - country_iso: ISO 3166-1 alpha-2 country code
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
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');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Anikeen\Id\Concerns;
use Throwable;
trait ManagesProfile
{
use HasBillable;
/**
* Get the profile url for the current user.
*
* @param string|null $returnUrl The URL to redirect to after the user has completed their profile.
* @param array $options Additional options for the profile URL.
* @return string
* @throws Throwable
*/
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
{
return $this->anikeenId()->request('POST', 'v1/user/profile', [
'return_url' => $returnUrl,
'options' => $options,
])->data->url;
}
}
+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);
}
}
+10 -37
View File
@@ -3,52 +3,25 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Delete;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Anikeen\Id\Resources\SshKeys;
use Throwable;
trait ManagesSshKeys
{
use Get, Post, Delete;
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 RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function sshKeysByUserId(string $sskKeyId): Result
public function sshKeysByUserId(string $userId): SshKeys
{
return $this->get(sprintf('v1/users/%s/ssh-keys/json', $sskKeyId));
}
$app = $this->asApp();
/**
* Creates ssh key for the currently authed user.
*
* @param string $publicKey The public key to be added
* @param string|null $name The name of the key (optional)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function createSshKey(string $publicKey, string $name = null): Result
{
return $this->post('v1/ssh-keys', [
'public_key' => $publicKey,
'name' => $name,
]);
}
/**
* Deletes a given ssh key for the currently authed user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function deleteSshKey(int $sshKeyId): Result
{
return $this->delete(sprintf('v1/ssh-keys/%s', $sshKeyId));
return SshKeys::builder(fn() => $app->get(sprintf('v1/client/users/%s/ssh-keys', $userId)))
->setParent($app);
}
}
+45 -62
View File
@@ -2,102 +2,85 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
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;
trait ManagesSubscriptions
{
use Request;
use HasBillable;
/**
* Get subscriptions from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function subscriptions(): Result
public function subscriptions(): Subscriptions
{
return $this->request('GET', 'v1/subscriptions');
if (!isset($this->subscriptionsCache)) {
$this->subscriptionsCache = Subscriptions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/subscriptions'))
->setBillable($this);
}
return $this->subscriptionsCache;
}
/**
* Get given subscription from the current user.
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* 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 subscription(string $subscriptionId): Result
public function appSubscriptions(): AppSubscriptions
{
return $this->request('GET', sprintf('v1/subscriptions/%s', $subscriptionId));
return new AppSubscriptions($this);
}
/**
* Create a new subscription for the current user.
*
* @param array{
* name: null,
* description: string,
* unit: string,
* price: float,
* vat: null|float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* } $attributes The subscription data:
* - name: The name
* - description: The description
* - unit: The unit (e.g. "hour", "day", "week", "month", "year")
* - price: The price per unit
* - vat: The VAT (optional)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* 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 createSubscription(array $attributes): Result
public function appCheckoutSessions(): AppCheckoutSessions
{
return $this->request('POST', 'v1/subscriptions', $attributes);
return new AppCheckoutSessions($this);
}
/**
* Force given subscription to check out (trusted apps only).
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* Checkout sessions with the user's own access token (any app): the user confirms several
* subscriptions at once on Anikeen ID.
*/
public function checkoutSubscription(string $subscriptionId): Result
public function checkoutSessions(): CheckoutSessions
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/checkout', $subscriptionId));
return new CheckoutSessions($this);
}
/**
* Revoke a given running subscription from the current user.
*
* @param string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* 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 revokeSubscription(string $subscriptionId): Result
public function appOrders(): AppOrders
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/revoke', $subscriptionId));
return new AppOrders($this);
}
/**
* Resume a given running subscription from the current user.
* 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 string $subscriptionId The subscription ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes
*
* @throws ApiException
*/
public function resumeSubscription(string $subscriptionId): Result
public function appBillingPortalUrl(array $attributes = []): string
{
return $this->request('PUT', sprintf('v1/subscriptions/%s/resume', $subscriptionId));
return app(AnikeenId::class)->createBillingPortalLinkForUser($this->appSubscriptions()->userId(), $attributes)->url;
}
}
+5 -8
View File
@@ -2,22 +2,19 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
trait ManagesTaxation
{
use Request;
use HasBillable;
/**
* Get VAT for the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function vat(): float
public function vatRate(): float
{
return $this->getUserData()->vat;
return $this->getUserData()->vat_rate;
}
}
+11 -33
View File
@@ -2,48 +2,26 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Request;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Anikeen\Id\Resources\Transactions;
use Throwable;
trait ManagesTransactions
{
use Request;
use HasBillable;
/**
* Get transactions from the current user.
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function transactions(): Result
public function transactions(): Transactions
{
return $this->request('GET', 'v1/transactions');
}
if (!isset($this->transactionsCache)) {
$this->transactionsCache = Transactions::builder(fn() => $this->anikeenId()
->request('GET', 'v1/transactions'))
->setBillable($this);
}
/**
* Create a new transaction for the current user.
*
* @param array $attributes The attributes for the transaction.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @todo Add type hinting for the attributes array.
*/
public function createTransaction(array $attributes = []): Result
{
return $this->request('POST', 'v1/transactions', $attributes);
}
/**
* Get given transaction from current current user.
*
* @param string $transactionId The transaction ID.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
*/
public function transaction(string $transactionId): Result
{
return $this->request('GET', sprintf('v1/transactions/%s', $transactionId));
return $this->transactionsCache;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders;
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]);
}
}
+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);
}
}
+51 -10
View File
@@ -5,19 +5,18 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\RequestRequiresClientIdException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
trait ManagesUsers
{
use Get, Post;
use HasParent;
/**
* Get currently authed user with Bearer Token
*
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
public function getAuthedUser(): Result
{
@@ -39,24 +38,66 @@ trait ManagesUsers
* - username: The username (optional)
* - email: The email (required)
* - password: The password (optional, can be reset by the user if not provided)
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @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);
}
/**
* 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));
}
/**
* Refreshes the access token using the refresh token.
*/
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
{
return $this->post('../oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $storedRefreshToken,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => $scope,
]);
}
/**
* Checks if the given email exists.
*
* @param string $email The email to check.
* @throws RequestRequiresClientIdException
* @throws GuzzleException
* @throws Throwable
*/
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,
]);
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Anikeen\Id\Contracts;
use Anikeen\Id\AnikeenId;
interface Billable
{
public function getUserData(): object;
public function anikeenId(): AnikeenId;
}
+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';
}
+28 -4
View File
@@ -6,11 +6,35 @@ class Scope
{
const USER = 'user';
const USER_READ = 'user:read';
const ORDERS = 'orders';
const ORDERS_READ = 'orders:read';
const PRODUCTS = 'products';
const PRODUCTS_READ = 'products:read';
const ADDRESSES = 'addresses';
const ADDRESSES_READ = 'addresses:read';
const BILLING = 'billing';
const BILLING_READ = 'billing:read';
const BILLING_CLIENT = 'billing:client';
const INVOICES = 'invoices';
const INVOICES_READ = 'invoices:read';
const INVOICES_CLIENT = 'invoices:client';
const ORDERS = 'orders';
const ORDERS_READ = 'orders:read';
const ORDERS_CLIENT = 'orders:client';
const PAYMENT_METHODS = 'payment-methods';
const PAYMENT_METHODS_READ = 'payment-methods:read';
const SUBSCRIPTIONS = 'subscriptions';
const SUBSCRIPTIONS_READ = 'subscriptions:read';
const SUBSCRIPTIONS_CLIENT = 'subscriptions:client';
const TRANSACTIONS = 'transactions';
const TRANSACTIONS_READ = 'transactions:read';
const TRANSACTIONS_CLIENT = 'transactions:client';
const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read';
const ADMIN = 'admin';
}
+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';
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
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;
return new static(
rtrim((string) ($body->message ?? $result->error()), '.'),
$result->response()?->getStatusCode() ?? 0,
$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);
}
public function isNotFound(): bool
{
return $this->status === 404;
}
public function isConflict(): bool
{
return $this->status === 409;
}
/**
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
*/
public function isTemporary(): bool
{
return $this->status === 0 || $this->status >= 500 || $this->status === 429;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* 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);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Exception;
class ResourceException extends Exception
{
//
}
@@ -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
{
//
}
+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;
}
/**
+53 -8
View File
@@ -2,34 +2,79 @@
namespace Anikeen\Id\Helpers;
use Anikeen\Id\AnikeenId;
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 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."));
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
use stdClass;
abstract class CheckCredentials
abstract class CheckCredentials extends UseParameters
{
/**
* Handle an incoming request.
+3 -3
View File
@@ -6,9 +6,9 @@ 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
class CheckForAnyScope extends UseParameters
{
/**
* Handle the incoming request.
@@ -17,7 +17,7 @@ class CheckForAnyScope
*/
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;
}
+3 -3
View File
@@ -6,9 +6,9 @@ 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
class CheckScopes extends UseParameters
{
/**
* Handle the incoming request.
@@ -17,7 +17,7 @@ class CheckScopes
*/
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;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Http\Middleware;
abstract class UseParameters
{
/**
* Specify the parameters for the middleware.
*
* @param string[]|string $param
*/
public static function using(array|string $param, string ...$params): string
{
if (is_array($param)) {
return static::class . ':' . implode(',', $param);
}
return static::class . ':' . implode(',', [$param, ...$params]);
}
}
+2 -4
View File
@@ -23,13 +23,11 @@ trait OauthTrait
],
]);
$result = new Result($response, null);
$result = new Result($response, null, $this);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception);
$result = new Result($exception->getResponse(), $exception, $this);
}
$result->anikeenId = $this;
return $result;
}
}
+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-id', 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);
}
/**
@@ -1,113 +0,0 @@
<?php
namespace Anikeen\Id\Providers;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class AnikeenIdSsoUserProvider implements UserProvider
{
private ?string $accessTokenField = null;
public function __construct(
private AnikeenId $anikeenId,
private Request $request,
private string $model,
private array $fields
)
{
$this->accessTokenField = AnikeenId::getAccessTokenField();
}
public function retrieveById(mixed $identifier): ?Authenticatable
{
$model = $this->createModel();
$token = $this->request->bearerToken();
$user = $this->newModelQuery($model)
->where($model->getAuthIdentifierName(), $identifier)
->first();
// Return user when found
if ($user) {
// Update access token when updated
if ($this->accessTokenField) {
$user[$this->accessTokenField] = $token;
if ($user->isDirty()) {
$user->save();
}
}
return $user;
}
// Create new user
$this->anikeenId->setToken($token);
$result = $this->anikeenId->getAuthedUser();
if (!$result->success()) {
return null;
}
$attributes = Arr::only((array)$result->data(), $this->fields);
$attributes[$model->getAuthIdentifierName()] = $result->data->id;
if ($this->accessTokenField) {
$attributes[$this->accessTokenField] = $token;
}
return $this->newModelQuery($model)->create($attributes);
}
/**
* Create a new instance of the model.
*/
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class;
}
/**
* Get a new query builder for the model instance.
*/
protected function newModelQuery(?Model $model = null): Builder
{
return is_null($model)
? $this->createModel()->newQuery()
: $model->newQuery();
}
public function retrieveByToken($identifier, $token)
{
return null;
}
public function updateRememberToken(Authenticatable $user, $token)
{
// void
}
public function retrieveByCredentials(array $credentials)
{
return null;
}
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
return false;
}
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// TODO: Implement rehashPasswordIfRequired() method.
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Anikeen\Id\Providers;
use Anikeen\Id\AnikeenId;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class AnikeenIdUserProvider implements UserProvider
{
private ?string $accessTokenField;
public function __construct(
private AnikeenId $anikeenId,
private Request $request,
private string $model,
private array $fields = [],
private ?string $column = null,
) {
$this->accessTokenField = AnikeenId::getAccessTokenField();
}
/**
* {@inheritDoc}
*/
public function retrieveByToken($identifier, $token): ?Authenticatable
{
// Token from request (if not already pass from $token):
$token = $token ?: $this->request->bearerToken();
if (! $token) {
return null;
}
// Set token in SSO client and request user info
$this->anikeenId->setToken($token);
$result = $this->anikeenId->getAuthedUser();
if (! $result->success()) {
return null;
}
// Only the desired fields
$data = Arr::only((array)$result->data(), $this->fields);
// Primary key (e.g. $user->id)
$pk = $this->createModel()->getAuthIdentifierName();
$data[$pk] = $result->data->id;
// Fill in access token field, if available
if ($this->accessTokenField) {
$data[$this->accessTokenField] = $token;
}
// Local eloquent model: either find or create a new one
/** @var Model $modelInstance */
$modelInstance = $this->newModelQuery()
->firstOrNew([$pk => $data[$pk]]);
$modelInstance->fill($data);
$modelInstance->save();
return $modelInstance;
}
/**
* {@inheritDoc}
*/
public function updateRememberToken(Authenticatable $user, $token): void
{
// no-op
}
/**
* {@inheritDoc}
*/
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
return null;
}
/**
* {@inheritDoc}
*/
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function retrieveById($identifier): ?Authenticatable
{
return $this->newModelQuery()
->where($this->createModel()->getAuthIdentifierName(), $identifier)
->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}
*/
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void
{
// no-op
}
/**
* @return Model
*/
protected function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class;
}
/**
* @return Builder
*/
protected function newModelQuery(): Builder
{
return $this->createModel()->newQuery();
}
}
+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
*/
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
/**
* @property string $id
* @property null|string $company_name
* @property null|string $first_name
* @property null|string $last_name
* @property null|string $address_2
* @property null|string $address
* @property null|string $house_number
* @property null|string $postal_code
* @property null|string $city
* @property null|string $state
* @property string $country_iso
* @property null|string $phone_number
* @property null|string $email
* @property bool $primary
* @property bool $primary_billing_address
*/
class Address extends BaseResource
{
use HasBillable;
/**
* Update given address from the current user.
*
* VAT is calculated based on the billing address and shown in the address response.
*
* @param array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address_2: null|string,
* address: string,
* house_number: null|string,
* postal_code: string,
* city: string,
* state: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string,
* primary: bool,
* primary_billing_address: bool
* } $attributes The address data:
* - company_name: Company name (optional)
* - first_name: First name (required when set)
* - last_name: Last name (required when set)
* - address: Address line 1 (e.g. street address, P.O. Box, etc.)
* - address_2: Address line 2 (optional, e.g. apartment number, c/o, etc.)
* - house_number: House number (optional)
* - postal_code: Postal code (required when set)
* - city: City (required when set)
* - state: State (optional, e.g. province, region, etc.)
* - country_iso: Country ISO code (required when set, e.g. US, CA, etc.)
* - phone_number: Phone number (optional)
* - email: Email address (optional, e.g. for delivery notifications)
* - primary: Whether this address should be the primary address (optional)
* - primary_billing_address: Whether this address should be the primary billing address (optional)
* @throws Throwable
*/
public function update(array $attributes = []): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/addresses/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Delete given address from the current user.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/addresses/%s', $this->id))->success();
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class Addresses extends BaseCollection
{
use HasBillable;
/**
* Creates a new address for the current user.
*
* @param array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* postal_code: string,
* city: string,
* state: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string,
* primary: bool,
* primary_billing_address: bool
* } $attributes The address data:
*   - company_name: Company name (optional)
*   - first_name: First name
*   - last_name: Last name
*   - address: Address line 1 (e.g. street address, P.O. Box, etc.)
*   - address_2: Address line 2 (optional, e.g. apartment number, c/o, etc.)
*   - house_number: House number (optional)
*   - postal_code: Postal code
*   - city: City
*   - state: State (optional, e.g. province, region, etc.)
*   - country_iso: Country ISO code (e.g. US, CA, etc.)
*   - phone_number: Phone number (optional)
*   - email: Email address (optional, e.g. for delivery notifications)
*   - primary: Whether this address should be the primary address (optional)
*   - primary_billing_address: Whether this address should be the primary billing address (optional)
* @throws Throwable
*/
public function create(array $attributes = []): Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('POST', 'v1/addresses', $attributes)))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/addresses/%s', $id))))
->setBillable($this->billable);
}
/**
* Get default address from the current user.
*
* @throws Throwable
*/
public function defaultBillingAddress(): Address
{
return (new Address(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/addresses/%s', $this->billable->getUserData()->billing_address_id))))
->setBillable($this->billable);
}
/**
* Check if the current user has a default billing address.
*
* @throws Throwable
*/
public function hasDefaultBillingAddress(): bool
{
return $this->billable->getUserData()->billing_address_id !== null;
}
}
+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();
}
}
+201
View File
@@ -0,0 +1,201 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasSubscriptionStatus;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use JsonSerializable;
/**
* A subscription managed by the app itself (client credentials), see AppSubscriptions.
*
* @property string $id
* @property string $user_id
* @property ?string $group
* @property string $name
* @property ?string $description
* @property string $unit
* @property float $price
* @property ?float $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.
*
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", …
*/
public function checkout(DateTimeInterface|string|null $startsAt = null): self
{
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
/**
* Cancel the subscription. With $refund (only within 14 days of its start), its not yet
* charged usage is credited.
*
* @throws ApiException 409 "already_cancelled", 404 "subscription_not_found", …
*/
public function revoke(bool $refund = false): self
{
return $this->send('PUT', '/revoke', ['refund' => $refund]);
}
/**
* Stop renewing; the subscription expires when its paid period ends.
*
* @throws ApiException
*/
public function pause(): self
{
return $this->send('PUT', '/pause');
}
/**
* Renew again (an expired subscription starts a new period).
*
* @throws ApiException
*/
public function resume(): self
{
return $this->send('PUT', '/resume');
}
/**
* @throws ApiException
*/
public function refresh(): self
{
return $this->send('GET', '');
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @throws ApiException
*/
private function send(string $method, string $action, ?array $payload = null): self
{
$this->data = AppRequest::send($method, sprintf('v1/client/subscriptions/%s%s', $this->id, $action), $payload)->data;
return $this;
}
}
+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;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
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;
/**
* @property bool $success
* @property mixed $data
* @property int $total
* @property int $status
* @property null|array $links
* @property null|array $meta
* @property null|Paginator $paginator
* @property AnikeenId $anikeenId
* @property Response $response
* @property null|Throwable $exception
*/
#[\AllowDynamicProperties]
abstract class BaseCollection implements JsonSerializable
{
private Closure $callable;
public ?Result $result = null;
/**
* @throws CollectionException
*/
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 CollectionException::fromResult($this->result);
}
}
/**
* @throws CollectionException
*/
public static function builder(callable $callable): static
{
return new static($callable, false);
}
/**
* Returns the collection of resources as an array.
*/
public function toArray(): array
{
return (array)$this->result->data;
}
/**
* Returns the collection of resources as a JSON string.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Returns the collection of resources.
*/
public function paginate(): Result
{
return $this->result;
}
/**
* Returns the Resource based on the ID.
*/
abstract public function find(string $id): ?BaseResource;
public function __get(string $name)
{
return $this->result->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->result->{$name});
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable;
#[\AllowDynamicProperties]
abstract class BaseResource implements JsonSerializable
{
public Result $result;
/**
* @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()) {
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) {
if (!property_exists($this, $key)) {
$this->{$key} = $value;
}
}
}
/**
* Returns the collection of resources as a JSON string.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Returns the collection of resources as an array.
*/
public function toArray(): array
{
return (array)$this->result->data;
}
public function __get(string $name)
{
return null;
}
public function __isset(string $name): bool
{
return false;
}
}
+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;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Countries extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?BaseResource
{
return null;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
/**
* @property string $id
*/
class Invoice extends BaseResource
{
use HasBillable;
/**
* Get temporary download url from given invoice.
*
* @throws Throwable
*/
public function getInvoiceTemporaryUrl(): ?string
{
return $this->billable->anikeenId()
->request('GET', sprintf('v1/invoices/%s', $this->id))
->data
->temporary_url ?? null;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Invoices extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?Invoice
{
return (new Invoice(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/invoices/%s', $id))))
->setBillable($this->billable);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
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.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* The billing and shipping addresses are each persisted as standalone Address entities
* in the database, but are also embedded (deep-copied) into the Order object itself
* rather than merely referenced. This guarantees that the order retains its own snapshot
* of both addresses for future reference.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: 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 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
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* 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
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/checkout', $this->id))))
->setBillable($this->billable);
}
/**
* Revoke given order from the current user.
*
* @throws Throwable
*/
public function revoke(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/revoke', $this->id))))
->setBillable($this->billable);
}
/**
* Delete given order from the current user.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->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.
*
* @throws Throwable
*/
public function orderItems(array $parameters = []): OrderItems
{
return OrderItems::builder(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s/items', $this->id), [], $parameters))
->setBillable($this->billable)
->setParent($this);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
*/
class OrderItem extends BaseResource
{
use HasBillable;
use HasParent;
/**
* Update given order item from given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - items: Array of order items, each with type, name, description, price, unit, and quantity
* @throws Throwable
*/
public function update(array $attributes = []): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/orders/%s/items/%s', $this->parent->id, $this->id), $attributes)))
->setBillable($this->billable)
->setParent($this->parent);
}
/**
* Delete given order item from given order.
*
* @throws Throwable
*/
public function delete(): bool
{
return $this->billable->anikeenId()
->request('DELETE', sprintf('v1/orders/%s/items/%s', $this->parent->id, $this->id))->success();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
class OrderItems extends BaseCollection
{
use HasBillable;
use HasParent;
/**
* Create a new order item for given order.
*
* VAT is calculated based on the billing address and shown in the order item response.
*
* @param string $orderId The order ID.
* @param array{
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* unit: string,
* units: int
* }>
* } $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() => 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);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?OrderItem
{
return (new OrderItem(fn() => $this->parent->anikeenId()
->request('GET', sprintf('v1/orders/%s/items/%s', $this->parent->id, $id))))
->setBillable($this->billable)
->setParent($this->parent);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class Orders extends BaseCollection
{
use HasBillable;
/**
* Creates a new order for the current user.
*
* VAT is calculated based on the billing address and shown in the order response.
*
* The billing and shipping addresses are each persisted as standalone Address entities
* in the database, but are also embedded (deep-copied) into the Order object itself
* rather than merely referenced. This guarantees that the order retains its own snapshot
* of both addresses for future reference.
*
* @param array{
* billing_address: array{
* company_name: null|string,
* first_name: null|string,
* last_name: null|string,
* address: null|string,
* address_2: null|string,
* house_number: null|string,
* city: null|string,
* state: null|string,
* postal_code: null|string,
* country_iso: string,
* phone_number: null|string,
* email: null|string
* },
* shipping_address: null|array{
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: 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?: 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 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
{
return (new Order(fn() => $this->billable->anikeenId()
->request('POST', 'v1/orders', $attributes)))
->setBillable($this->billable);
}
/**
* Get given order from the current user.
*
* @throws Throwable
*/
public function find(string $id): ?Order
{
return (new Order(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/orders/%s', $id))))
->setBillable($this->billable);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
/**
* @property string $id
*/
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();
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Throwable;
class PaymentMethods extends BaseCollection
{
use HasBillable;
/**
* Check if current user has at least one payment method.
*
* @throws Throwable
*/
public function hasPaymentMethod(): bool
{
return $this->result->count() > 0;
}
/**
* Check if the current user has a default payment method.
*
* @throws Throwable
*/
public function hasDefaultPaymentMethod(): bool
{
return $this->defaultPaymentMethod()?->id !== null;
}
/**
* Get default payment method from the current user.
*
* @throws Throwable
*/
public function defaultPaymentMethod(): PaymentMethod
{
if (!isset($this->defaultPaymentMethodCache)) {
$this->defaultPaymentMethodCache = (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('GET', 'v1/payment-methods/default')))
->setBillable($this->billable);
}
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}
*
* @throws Throwable
*/
public function find(string $id): ?PaymentMethod
{
return (new PaymentMethod(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/payment-methods/%s', $id))))
->setBillable($this->billable);
}
}
+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;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
/**
* @property string $id
*/
class SshKey extends BaseResource
{
use HasParent;
/**
* Deletes a given ssh key for the currently authed user.
*
* @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();
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasParent;
use Throwable;
class SshKeys extends BaseCollection
{
use HasParent;
/**
* Creates ssh key for the currently authed user.
*
* @param string $publicKey The public key to be added
* @param string|null $name The name of the key (optional)
* @throws Throwable
*/
public function create(string $publicKey, ?string $name = null): SshKey
{
return (new SshKey(fn() => $this->parent->post('v1/ssh-keys', [
'public_key' => $publicKey,
'name' => $name,
])))->setParent($this->parent);
}
/**
* {@inheritDoc}
*/
public function find(string $id): ?SshKey
{
return (new SshKey(fn() => $this->parent->get(sprintf('v1/ssh-keys/%s', $id))))->setParent($this->parent);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
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;
/**
* @property string $id
* @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 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 $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.
*
* @param array{
* group: string,
* name: string,
* description: null|string,
* unit: string,
* price: float,
* monthly_cap?: null|float,
* vat_rate: float,
* payload: null|array,
* 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)
* @throws Throwable
*/
public function update(array $attributes): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Force given subscription to check out (trusted apps only).
*
* @throws Throwable
*/
public function checkout(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/checkout', $this->id))))
->setBillable($this->billable);
}
/**
* Revoke a given running subscription from the current user.
*
* @throws Throwable
*/
public function revoke(bool $refund = false): self
{
$attributes = [
'refund' => $refund,
];
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/revoke', $this->id), $attributes)))
->setBillable($this->billable);
}
/**
* Pause a given running subscription from the current user.
*
* @throws Throwable
*/
public function pause(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/pause', $this->id))))
->setBillable($this->billable);
}
/**
* Resume a given running subscription from the current user.
*
* @throws Throwable
*/
public function resume(): self
{
return (new self(fn() => $this->billable->anikeenId()
->request('PUT', sprintf('v1/subscriptions/%s/resume', $this->id))))
->setBillable($this->billable);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ResourceException;
use Throwable;
class Subscriptions extends BaseCollection
{
use HasBillable;
/**
* Create a new subscription for the current user.
*
* @param array{
* group: null|string,
* name: string,
* 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,
* 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)
* - 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
{
return (new Subscription(fn() => $this->billable->anikeenId()
->request('POST', 'v1/subscriptions', $attributes)))
->setBillable($this->billable);
}
/**
* {@inheritDoc}
*
* @throws ResourceException
*/
public function find(string $id): ?Subscription
{
return (new Subscription(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/subscriptions/%s', $id))))
->setBillable($this->billable);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Transaction extends BaseResource
{
use HasBillable;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
class Transactions extends BaseCollection
{
use HasBillable;
/**
* {@inheritDoc}
*/
public function find(string $id): ?Transaction
{
return (new Transaction(fn() => $this->billable->anikeenId()
->request('GET', sprintf('v1/transactions/%s', $id))))
->setBillable($this->billable);
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ class Result
/**
* Rate limit info from headers
*/
public function rateLimit(string $key = null): array|int|string|null
public function rateLimit(?string $key = null): array|int|string|null
{
if (!$this->response) {
return null;
@@ -8,6 +8,6 @@ class AnikeenIdExtendSocialite
{
public function handle(SocialiteWasCalled $socialiteWasCalled): void
{
$socialiteWasCalled->extendSocialite('anikeen-id', Provider::class);
$socialiteWasCalled->extendSocialite('anikeen', Provider::class);
}
}
+22 -4
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Socialite;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Enums\Scope;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Laravel\Socialite\Two\ProviderInterface;
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
@@ -14,7 +16,7 @@ class Provider extends AbstractProvider implements ProviderInterface
/**
* Unique Provider Identifier.
*/
const IDENTIFIER = 'ANIKEEN_ID';
const IDENTIFIER = 'ANIKEEN';
/**
* {@inheritdoc}
@@ -26,13 +28,21 @@ class Provider extends AbstractProvider implements ProviderInterface
*/
protected $scopeSeparator = ' ';
/**
* Get the base URL for the API.
*/
protected function getBaseUrl(): string
{
return app(AnikeenId::class)->getBaseUrl();
}
/**
* {@inheritdoc}
*/
protected function getAuthUrl($state): string
{
return $this->buildAuthUrlFromBase(
'https://id.anikeen.com/oauth/authorize', $state
$this->getBaseUrl() . '/oauth/authorize', $state
);
}
@@ -41,7 +51,7 @@ class Provider extends AbstractProvider implements ProviderInterface
*/
protected function getTokenUrl(): string
{
return 'https://id.anikeen.com/oauth/token';
return $this->getBaseUrl() . '/oauth/token';
}
/**
@@ -52,7 +62,7 @@ class Provider extends AbstractProvider implements ProviderInterface
protected function getUserByToken($token)
{
$response = $this->getHttpClient()->get(
'https://id.anikeen.com/api/v1/user', [
$this->getBaseUrl() . '/api/v1/user', [
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
@@ -85,4 +95,12 @@ class Provider extends AbstractProvider implements ProviderInterface
'grant_type' => 'authorization_code',
]);
}
/**
* Returns the user logout url for the provider.
*/
public function getLogoutUrl(string $redirect = null): string
{
return app(AnikeenId::class)->getBaseUrl() . '/logout?redirect=' . urlencode($redirect ?: '/');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code.
*/
final class AppRequest
{
/**
* @throws ApiException
*/
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result
{
$result = self::attempt($method, $path, $payload, $query);
if ($result->status === 401) {
$tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query);
}
}
if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null;
throw new ApiException(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0,
$body->error ?? null,
$result->exception,
);
}
return $result;
}
/**
* @throws ApiException
*/
private static function attempt(string $method, string $path, ?array $payload, array $query): Result
{
try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query);
} catch (ApiException $e) {
throw $e;
} catch (GuzzleException|Throwable $e) {
// network errors / timeouts (ConnectException isn't a RequestException) and token errors
throw new ApiException(sprintf('Anikeen ID is not reachable: %s', $e->getMessage()), 0, null, $e);
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\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';
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';
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.');
}
}
}
+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);
}
}
}
+279
View File
@@ -0,0 +1,279 @@
<?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_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]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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';
}
}
};
$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());
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more