Add checkout sessions and webhook event type constants

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Maurice PreußandClaude Opus 5.5 committed 2026-11-02 17:26:24 +01:00
1 parent a0470111fa
commit 3acd36ca29
12 files changed
+708 -7

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
<?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, cancelled or 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 ?? []));
}
}
+21 -1
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Concerns;
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;
@@ -37,6 +39,24 @@ trait ManagesSubscriptions
return new AppSubscriptions($this);
}
/**
* Checkout sessions for the user, created with the app's own token (trusted apps only, scope
* subscriptions:client): the user confirms several subscriptions at once on Anikeen ID.
*/
public function appCheckoutSessions(): AppCheckoutSessions
{
return new AppCheckoutSessions($this);
}
/**
* Checkout sessions with the user's own access token (any app): the user confirms several
* subscriptions at once on Anikeen ID.
*/
public function checkoutSessions(): CheckoutSessions
{
return new CheckoutSessions($this);
}
/**
* The user's orders that bill the app's subscriptions, read with the app's own token
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
@@ -45,4 +65,4 @@ trait ManagesSubscriptions
{
return new AppOrders($this);
}
}␍
}
+10
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result;
use Throwable;
@@ -19,6 +20,15 @@ trait ManagesUserBilling
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();
}
/**
* 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
+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;
}
}
+79
View File
@@ -0,0 +1,79 @@
<?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,
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string
* }>,
* 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.
*
* @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;
}
}
+1
View File
@@ -29,6 +29,7 @@ use JsonSerializable;
* @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
*/
class AppSubscription implements JsonSerializable
{
+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;
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<?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';
public const SUBSCRIPTION_CANCELLED = 'subscription.cancelled';
public const SUBSCRIPTION_EXPIRED = 'subscription.expired';
/** 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()
{
}
}