Compare commits

...
Author SHA1 Message Date
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
19 changed files with 1022 additions and 26 deletions

No files matched your search

+102 -3
View File
@@ -157,6 +157,63 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
sessions are opt-in.
Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices:
```php
use Anikeen\Id\Exceptions\ApiException;
$session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
'return_url' => route('builder.done'), // same origin as one of your redirect URIs
'metadata' => ['cart' => $cart->id], // returned as is
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
'subscriptions' => $cart->items->map(fn ($item) => [
'group' => $team->id,
'name' => $item->product->name,
'description' => $item->label,
'unit' => $item->unit, // hour, day, week, month, year
'price' => $item->priceAfterCoupon(), // net, per unit
'payload' => ['cart_item' => $item->id],
])->all(),
]);
$cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
return redirect($session->confirmationUrl());
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
```
Provision when the webhook arrives (or after checking the session through the API; never trust the
query string):
```php
use Anikeen\Id\Webhooks\EventType;
match ($event['type']) {
EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
default => null,
};
app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
$session->refresh()->subscriptions(); // AppSubscription objects
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
```
While a session is open, its subscriptions can't be checked out or repriced by the app (409
`in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session.
### Selling as a third-party app (user confirms on Anikeen ID) ### Selling as a third-party app (user confirms on Anikeen ID)
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), Apps that are not trusted can create orders and subscriptions for the signed-in user (user token),
@@ -180,17 +237,55 @@ instead of trusting the query string. Subscriptions work the same way
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`). (`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
Unconfirmed bookings are cancelled after 24 hours. Unconfirmed bookings are cancelled after 24 hours.
Physical goods need a shipping address (`first_name`, `last_name`, `address`, `postal_code`,
`city`, `country_iso`). The user can change both addresses on the confirmation page, so read the
shipping address from the order (or the `order.completed` webhook) before you ship. An item's
`image_url` is copied to Anikeen ID and stays visible on the order and invoice:
```php
$order = $user->orders()->create([
'billing_address_id' => $addressId,
'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address' => 'Main St', 'house_number' => '1', 'postal_code' => '10115', 'city' => 'Berlin', 'country_iso' => 'DE'],
'items' => [
['type' => 'physical', 'name' => 'T-shirt', 'unit' => 'piece', 'units' => 1, 'gross_price' => '24.90', 'image_url' => 'https://shop.test/img/shirt.png'],
['type' => 'physical-reduced', 'name' => 'Artbook', 'unit' => 'piece', 'units' => 1, 'gross_price' => '19.95'],
],
]);
```
Once a paid order is sent, create a shipment. Anikeen ID adds its delivery note to the user's
order documents (with the tracking link), mails it to the user (`'notify_customer' => false` if
your shop sends its own mail) and sends `order.shipped` to your webhook endpoints.
Without `items`, everything physical that is left is shipped; partial deliveries name the items:
```php
$order = $user->orders()->find($orderId);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '00340434161234567890',
'tracking_url' => 'https://www.dhl.de/…?piececode=00340434161234567890',
// 'items' => [['order_item_id' => $itemId, 'units' => 1]],
]);
$shipment->number; // LS-2026-0001
$shipment->deliveryNote(); // the delivery note as PDF
```
Only the app that created the order (or a trusted app) can ship it, and only once it is paid.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly. points to the same page. Trusted apps keep checking out directly.
### Webhooks ### Webhooks
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
```php ```php
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook; use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) { Route::post('webhooks/anikeen', function (Request $request) {
@@ -201,8 +296,9 @@ Route::post('webhooks/anikeen', function (Request $request) {
} }
match ($event['type']) { match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']), EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']),
EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']),
default => null, default => null,
}; };
@@ -434,6 +530,7 @@ public function shippingPriceList(): Result
```php ```php
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appCheckoutSessions(): AppCheckoutSessions
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
``` ```
@@ -536,6 +633,8 @@ public function getBillable(): Billable
```php ```php
public function subscriptions(): Subscriptions public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions public function appSubscriptions(): AppSubscriptions
public function appCheckoutSessions(): AppCheckoutSessions
public function checkoutSessions(): CheckoutSessions
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
+62 -3
View File
@@ -157,6 +157,63 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the
app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first
period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`.
Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly;
sessions are opt-in.
Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices:
```php
use Anikeen\Id\Exceptions\ApiException;
$session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token
'return_url' => route('builder.done'), // same origin as one of your redirect URIs
'metadata' => ['cart' => $cart->id], // returned as is
'idempotency_key' => "cart:{$cart->id}", // retries return the same session
'subscriptions' => $cart->items->map(fn ($item) => [
'group' => $team->id,
'name' => $item->product->name,
'description' => $item->label,
'unit' => $item->unit, // hour, day, week, month, year
'price' => $item->priceAfterCoupon(), // net, per unit
'payload' => ['cart_item' => $item->id],
])->all(),
]);
$cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]);
return redirect($session->confirmationUrl());
// later: https://console.test/builder/done?status=completed&checkout_session=01K…
```
Provision when the webhook arrives (or after checking the session through the API; never trust the
query string):
```php
use Anikeen\Id\Webhooks\EventType;
match ($event['type']) {
EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running
EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null),
default => null,
};
app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session
$session->refresh()->subscriptions(); // AppSubscription objects
$session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed)
```
While a session is open, its subscriptions can't be checked out or repriced by the app (409
`in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`,
`cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired).
`subscription.activated` / `subscription.cancelled` are still sent per subscription, before the
`checkout.*` event of the session.
### Selling as a third-party app (user confirms on Anikeen ID) ### Selling as a third-party app (user confirms on Anikeen ID)
Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), Apps that are not trusted can create orders and subscriptions for the signed-in user (user token),
@@ -185,12 +242,13 @@ points to the same page. Trusted apps keep checking out directly.
### Webhooks ### Webhooks
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
```php ```php
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook; use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) { Route::post('webhooks/anikeen', function (Request $request) {
@@ -201,8 +259,9 @@ Route::post('webhooks/anikeen', function (Request $request) {
} }
match ($event['type']) { match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']), EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']),
EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']),
default => null, default => null,
}; };
+15
View File
@@ -29,6 +29,21 @@ trait Billable
use ManagesTransactions; use ManagesTransactions;
use ManagesUserSshKeys; use ManagesUserSshKeys;
/*
* API results cached per instance. Declared, so Eloquent models don't take them for
* attributes (an undeclared property goes through Model::__set and save() then fails
* with "no such column").
*/
protected mixed $addressesCache = null;
protected mixed $countriesCache = null;
protected mixed $defaultPaymentMethodCache = null;
protected mixed $invoicesCache = null;
protected mixed $ordersCache = null;
protected mixed $paymentMethodsCache = null;
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
/** /**
* Get the currently authenticated user. * Get the currently authenticated user.
* *
+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 ?? []));
}
}
+21 -1
View File
@@ -2,8 +2,10 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\CheckoutSessions;
use Anikeen\Id\Resources\Subscriptions; use Anikeen\Id\Resources\Subscriptions;
use Throwable; use Throwable;
@@ -37,6 +39,24 @@ trait ManagesSubscriptions
return new AppSubscriptions($this); 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 * 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. * (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
@@ -45,4 +65,4 @@ trait ManagesSubscriptions
{ {
return new AppOrders($this); return new AppOrders($this);
} }
}␍ }
+11 -1
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Throwable; use Throwable;
@@ -19,6 +20,15 @@ trait ManagesUserBilling
return new 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();
}
/** /**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right * 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 * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
@@ -31,7 +41,7 @@ trait ManagesUserBilling
* billing_address?: array, * billing_address?: array,
* shipping_address_id?: string, * shipping_address_id?: string,
* shipping_address?: array, * shipping_address?: array,
* items: array<array{type: string, name: string, description?: string, price?: float|string, gross_price?: float|string, unit: string, units: int}>, * 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, * checkout?: bool,
* transaction_id?: string, * transaction_id?: string,
* transaction?: array, * transaction?: array,
+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;
}
}
+4 -1
View File
@@ -29,6 +29,7 @@ use JsonSerializable;
* @property int $failed_renewal_retries * @property int $failed_renewal_retries
* @property ?array $payload * @property ?array $payload
* @property ?string $idempotency_key * @property ?string $idempotency_key
* @property ?string $checkout_session_id the checkout session the user confirms it in, if any
*/ */
class AppSubscription implements JsonSerializable class AppSubscription implements JsonSerializable
{ {
@@ -47,7 +48,9 @@ class AppSubscription implements JsonSerializable
} }
/** /**
* Change a subscription that wasn't checked out yet. * 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'])`.
* *
* @throws ApiException * @throws ApiException
*/ */
+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 -5
View File
@@ -42,13 +42,13 @@ class Order extends BaseResource
* }, * },
* shipping_address: null|array{ * shipping_address: null|array{
* company_name: null|string, * company_name: null|string,
* first_name: string, * first_name: string,
* last_name: string, * last_name: string,
* address: null|string, * address: string,
* address_2: string, * address_2: null|string,
* house_number: null|string, * house_number: null|string,
* city: string, * city: string,
* state: string, * state: null|string,
* postal_code: string, * postal_code: string,
* country_iso: string, * country_iso: string,
* phone_number: null|string, * phone_number: null|string,
@@ -56,7 +56,7 @@ class Order extends BaseResource
* } * }
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* @throws Throwable * @throws Throwable
*/ */
public function update(array $attributes = []): self public function update(array $attributes = []): self
@@ -101,6 +101,19 @@ class Order extends BaseResource
->request('DELETE', sprintf('v1/orders/%s', $this->id))->success(); ->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. * Get order items from given order.
* *
+19 -12
View File
@@ -36,30 +36,37 @@ class Orders extends BaseCollection
* }, * },
* shipping_address: null|array{ * shipping_address: null|array{
* company_name: null|string, * company_name: null|string,
* first_name: string, * first_name: string,
* last_name: string, * last_name: string,
* address: null|string, * address: string,
* address_2: string, * address_2: null|string,
* house_number: null|string, * house_number: null|string,
* city: string, * city: string,
* state: string, * state: null|string,
* postal_code: string, * postal_code: string,
* country_iso: string, * country_iso: string,
* phone_number: null|string, * phone_number: null|string,
* email: null|string * email: null|string
* }, * },
* items: array<array{ * items: array<array{
* type: string, * type: string,
* name: string, * name: string,
* description: string, * description?: null|string,
* price: float|int, * image_url?: null|string,
* unit: string, * price?: float|int|string,
* units: int * gross_price?: float|int|string,
* unit: string,
* units: int
* }> * }>
* } $attributes The order data: * } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code) * - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code) * - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity) * - 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 * @throws Throwable
*/ */
public function create(array $attributes = []): Order public function create(array $attributes = []): Order
+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);
}
}
+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()
{
}
}
+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());
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSession;
use Anikeen\Id\Resources\AppSubscription;
use Anikeen\Id\Resources\CheckoutSession;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class CheckoutSessionsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function sessionData(array $overrides = []): array
{
return ['data' => [
'object' => 'checkout_session',
'id' => '01JCS',
'user_id' => '01JUSER',
'status' => 'open',
'return_url' => 'https://console.test/builder/done',
'metadata' => ['cart' => '42'],
'confirmation_url' => 'https://id.anikeen.com/checkout/sessions/01JCS',
'subscription_ids' => ['01JS1', '01JS2'],
'subscriptions' => [
['id' => '01JS1', 'user_id' => '01JUSER', 'name' => 'Cloud Server S', 'active' => false, 'payload' => ['product' => 'server-s']],
['id' => '01JS2', 'user_id' => '01JUSER', 'name' => 'Backup', 'active' => false],
],
...$overrides,
]];
}
private function request(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_a_trusted_app_opens_a_session_with_its_own_token(): void
{
$this->respond(201, $this->sessionData());
$session = $this->owner()->appCheckoutSessions()->create([
'return_url' => 'https://console.test/builder/done',
'idempotency_key' => 'cart:42',
'subscriptions' => [
['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']],
['id' => '01JS2'],
],
]);
$this->assertSame('POST /api/v1/client/checkout-sessions Bearer app-token', $this->request(0));
$this->assertSame('01JUSER', $this->sentJson(0)['user_id']);
$this->assertSame([['group' => 'team-1', 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => '0.0137', 'payload' => ['product' => 'server-s']], ['id' => '01JS2']], $this->sentJson(0)['subscriptions']);
$this->assertInstanceOf(AppCheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertTrue($session->isOpen());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$this->assertContainsOnlyInstancesOf(AppSubscription::class, $session->subscriptions());
$this->assertSame('server-s', $session->subscriptions()[0]->payload->product);
$this->assertSame('42', $session->metadata->cart);
}
public function test_the_app_finds_refreshes_and_cancels_sessions(): void
{
$this->respond(200, $this->sessionData(['status' => 'completed', 'confirmation_url' => null]));
$this->respond(200, $this->sessionData(['user_id' => 'someone-else']));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$this->respond(200, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'cancelled', 'confirmation_url' => null]));
$this->respond(409, ['message' => 'The checkout session is cancelled.', 'error' => 'checkout_session_cancelled']);
$sessions = $this->owner()->appCheckoutSessions();
$completed = $sessions->find('01JCS');
$this->assertTrue($completed->isCompleted());
$this->assertNull($completed->confirmationUrl());
$this->assertNull($sessions->find('01JCS'), 'another user\'s session');
$this->assertNull($sessions->find('unknown'));
$session = app(AnikeenId::class)->appCheckoutSessions()->find('01JCS'); // e.g. from a webhook
$session->cancel();
$this->assertTrue($session->isCancelled());
$this->assertSame('POST /api/v1/client/checkout-sessions/01JCS/cancel Bearer app-token', $this->request(4));
try {
$session->cancel();
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('checkout_session_cancelled', $e->error);
}
}
public function test_without_a_billable_the_user_id_is_required(): void
{
$this->expectException(InvalidArgumentException::class);
app(AnikeenId::class)->appCheckoutSessions()->create(['subscriptions' => [['id' => '01JS1']]]);
}
public function test_any_app_opens_a_session_with_the_users_token(): void
{
$this->respond(201, $this->sessionData());
$this->respond(200, $this->sessionData(['status' => 'expired', 'confirmation_url' => null]));
$this->respond(404, ['message' => 'Checkout session not found.', 'error' => 'checkout_session_not_found']);
$user = $this->owner();
$session = $user->checkoutSessions()->create(['subscriptions' => [['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]]]);
$this->assertSame('POST /api/v1/checkout-sessions Bearer user-token', $this->request(0));
$this->assertArrayNotHasKey('user_id', $this->sentJson(0));
$this->assertInstanceOf(CheckoutSession::class, $session);
$this->assertSame('https://id.anikeen.com/checkout/sessions/01JCS', $session->confirmationUrl());
$this->assertSame(['01JS1', '01JS2'], $session->subscriptionIds());
$expired = $user->checkoutSessions()->find('01JCS');
$this->assertTrue($expired->isCancelled());
$this->assertSame(CheckoutSession::STATUS_EXPIRED, $expired->status);
$this->assertNull($user->checkoutSessions()->find('unknown'));
}
public function test_nothing_is_requested_until_a_method_is_called(): void
{
$this->owner()->checkoutSessions();
$this->owner()->appCheckoutSessions();
$this->assertCount(0, $this->sent);
}
public function test_checkout_webhooks_carry_the_session(): void
{
$payload = json_encode(['id' => '01JEV', 'type' => 'checkout.completed', 'created' => time(), 'data' => ['object' => $this->sessionData(['status' => 'completed'])['data']]]);
$timestamp = time();
$signature = 't=' . $timestamp . ',v1=' . hash_hmac('sha256', "{$timestamp}.{$payload}", 'whsec_test');
$event = Webhook::constructEvent($payload, $signature, 'whsec_test');
$this->assertSame(EventType::CHECKOUT_COMPLETED, $event['type']);
$this->assertSame(['01JS1', '01JS2'], $event['data']['object']['subscription_ids']);
$this->assertContains('checkout.expired', EventType::all());
$this->assertContains('subscription.activated', EventType::all());
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model;
class ShipmentsTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function order(): \Anikeen\Id\Resources\Order
{
$this->respond(200, ['data' => [], 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0], 'links' => ['next' => null]]);
$this->respond(200, ['data' => ['id' => 'ord1', 'status' => 'completed']]);
return $this->owner()->orders()->find('ord1');
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
public function test_an_order_is_shipped_with_carrier_tracking_and_items(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1', 'carrier' => 'DHL']]);
$shipment = $order->shipments()->create([
'carrier' => 'DHL',
'tracking_number' => '0034',
'items' => [['order_item_id' => 'item1', 'units' => 1]],
]);
$this->assertSame('LS-2026-0001', $shipment->number);
$this->assertSame('GET /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(2));
$this->assertSame('POST /api/v1/orders/ord1/shipments Bearer user-token', $this->sentCall(3));
$this->assertSame(['carrier' => 'DHL', 'tracking_number' => '0034', 'items' => [['order_item_id' => 'item1', 'units' => 1]]], $this->sentJson(3));
}
public function test_shipments_are_listed_and_the_delivery_note_is_downloaded(): void
{
$order = $this->order();
$this->respond(200, ['data' => [['id' => 'shp1', 'number' => 'LS-2026-0001']]]);
$this->respond(200, ['data' => ['id' => 'shp1', 'number' => 'LS-2026-0001', 'order_id' => 'ord1']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 test'));
$shipments = $order->shipments();
$this->assertSame('LS-2026-0001', $shipments->toArray()[0]->number);
$pdf = $shipments->find('shp1')->deliveryNote();
$this->assertSame('%PDF-1.7 test', $pdf);
$this->assertSame('GET /api/v1/orders/ord1/shipments/shp1/delivery-note Bearer user-token', $this->sentCall(4));
}
public function test_refusals_keep_their_status(): void
{
$order = $this->order();
$this->respond(200, ['data' => []]);
$this->respond(409, ['message' => 'Only paid orders can be shipped.', 'error' => 'order_not_paid']);
try {
$order->shipments()->create();
$this->fail('expected a ResourceException');
} catch (ResourceException $e) {
$this->assertSame(409, $e->getCode());
$this->assertStringContainsString('Only paid orders can be shipped', $e->getMessage());
}
}
}