Compare commits

..
12 Commits
Author SHA1 Message Date
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
22 changed files with 689 additions and 29 deletions

No files matched your search

+89
View File
@@ -111,6 +111,7 @@ $subscription = $subscriptions->create([
]);
$subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel
@@ -142,6 +143,92 @@ try {
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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),
but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`:
```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
$order = $user->orders()->create([
'billing_address_id' => $addressId,
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']],
]);
return redirect($order->confirmationUrl(route('shop.done')));
// later: https://your-app.test/shop/done?status=completed&order=01J…
```
`return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`,
`pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)`
instead of trusting the query string. Subscriptions work the same way
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
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'],
],
]);
```
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
### Webhooks
Anikeen ID sends `order.*` and `subscription.*` 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
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
```php
use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) {
try {
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret'));
} catch (SignatureVerificationException) {
abort(400);
}
match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
default => null,
};
return response()->noContent();
})->withoutMiddleware(VerifyCsrfToken::class);
```
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and
reload the object through the API when the current state matters.
### Change the default access token / refresh token field name
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
@@ -362,6 +449,7 @@ public function shippingPriceList(): Result
### ManagesUserBilling
```php
public function appOrders(): AppOrders
public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result
```
@@ -464,6 +552,7 @@ public function getBillable(): Billable
```php
public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions
public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable
```
+71
View File
@@ -111,6 +111,7 @@ $subscription = $subscriptions->create([
]);
$subscription->checkout(); // bill the first period
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
$subscription->pause(); // expires when the paid period ends
$subscription->resume(); // expired subscriptions start a new period
$subscription->revoke(refund: false); // cancel
@@ -142,6 +143,76 @@ try {
}
```
### App orders (trusted apps, no user token)
Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`):
```php
$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first
app(AnikeenId::class)->appOrders()->pending(); // of all users
$team->owner->appOrders()->find($orderId); // null if unknown or not this user's
```
Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`.
### 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),
but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`:
```php
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
$order = $user->orders()->create([
'billing_address_id' => $addressId,
'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']],
]);
return redirect($order->confirmationUrl(route('shop.done')));
// later: https://your-app.test/shop/done?status=completed&order=01J…
```
`return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`,
`pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)`
instead of trusting the query string. Subscriptions work the same way
(`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`).
Unconfirmed bookings are cancelled after 24 hours.
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
points to the same page. Trusted apps keep checking out directly.
### Webhooks
Anikeen ID sends `order.*` and `subscription.*` 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
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
```php
use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\Webhook;
Route::post('webhooks/anikeen', function (Request $request) {
try {
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret'));
} catch (SignatureVerificationException) {
abort(400);
}
match ($event['type']) {
'order.completed' => Shop::fulfil($event['data']['object']['id']),
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
default => null,
};
return response()->noContent();
})->withoutMiddleware(VerifyCsrfToken::class);
```
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and
reload the object through the API when the current state matters.
### Change the default access token / refresh token field name
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
+15
View File
@@ -29,6 +29,21 @@ trait Billable
use ManagesTransactions;
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.
*
+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]);
}
}
+10
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\Subscriptions;
use Throwable;
@@ -35,4 +36,13 @@ trait ManagesSubscriptions
{
return new AppSubscriptions($this);
}
/**
* The user's orders that bill the app's subscriptions, read with the app's own token
* (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones.
*/
public function appOrders(): AppOrders
{
return new AppOrders($this);
}
}␍
+10 -1
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result;
use Throwable;
@@ -10,6 +11,14 @@ use Throwable;
*/
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();
}
/**
* 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
@@ -22,7 +31,7 @@ trait ManagesUserBilling
* billing_address?: array,
* shipping_address_id?: string,
* 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,
* transaction_id?: string,
* transaction?: array,
@@ -0,0 +1,17 @@
<?php
namespace Anikeen\Id\Exceptions;
use Throwable;
/**
* Thrown when a third-party app tries to check out an order or subscription itself: the user has
* to confirm it on Anikeen ID. Send them to the confirmation URL instead.
*/
class ConfirmationRequiredException extends ResourceException
{
public function __construct(string $message, int $code, public readonly ?string $confirmationUrl = null, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,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
{
//
}
+88
View File
@@ -0,0 +1,88 @@
<?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, group, 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 and last_payment_error ({code, message} or null).
*/
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;
}
private function userId(): string
{
return (new AppSubscriptions($this->billable))->userId();
}
}
+11 -3
View File
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use DateTimeInterface;
use JsonSerializable;
/**
@@ -58,11 +59,18 @@ class AppSubscription implements JsonSerializable
/**
* Activate the subscription and bill its first period.
*
* @throws ApiException 409 "already_active", "billing_address_missing", …
* $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(): self
public function checkout(DateTimeInterface|string|null $startsAt = null): self
{
return $this->send('PUT', '/checkout');
if ($startsAt instanceof DateTimeInterface) {
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
}
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
}
/**
-2
View File
@@ -47,8 +47,6 @@ class AppSubscriptions
* vat_rate?: float,
* payload?: null|array,
* ends_at?: null|string,
* webhook_url?: null|string,
* webhook_secret?: null|string,
* 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.
+9
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result;
use JsonSerializable;
@@ -19,6 +20,14 @@ abstract class BaseResource implements JsonSerializable
$this->result = $callable();
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 new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
}
+7 -4
View File
@@ -3,14 +3,17 @@
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)
*/
class Order extends BaseResource
{
use HasBillable;
use HasConfirmationUrl;
/**
* Update given order from the current user.
@@ -41,11 +44,11 @@ class Order extends BaseResource
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: string,
* state: string,
* state: null|string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
@@ -53,7 +56,7 @@ class Order extends BaseResource
* }
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* @throws Throwable
*/
public function update(array $attributes = []): self
+14 -7
View File
@@ -38,11 +38,11 @@ class Orders extends BaseCollection
* company_name: null|string,
* first_name: string,
* last_name: string,
* address: null|string,
* address_2: string,
* address: string,
* address_2: null|string,
* house_number: null|string,
* city: string,
* state: string,
* state: null|string,
* postal_code: string,
* country_iso: string,
* phone_number: null|string,
@@ -51,15 +51,22 @@ class Orders extends BaseCollection
* items: array<array{
* type: string,
* name: string,
* description: string,
* price: float|int,
* description?: null|string,
* image_url?: null|string,
* price?: float|int|string,
* gross_price?: float|int|string,
* unit: string,
* units: int
* }>
* } $attributes The order data:
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
* - shipping_address: Shipping address (first name, last name, ISO 3166-1 alpha-2 country code)
* - items: Array of order items (each with type, name, price, unit, units, and quantity)
* - shipping_address: Shipping address for physical items (first name, last name, address, postal code, city, ISO 3166-1 alpha-2 country code)
* - 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
+4 -7
View File
@@ -3,6 +3,7 @@
namespace Anikeen\Id\Resources;
use Anikeen\Id\Concerns\HasBillable;
use Anikeen\Id\Concerns\HasConfirmationUrl;
use Anikeen\Id\Contracts\AppTokenRepository;
use Throwable;
@@ -15,12 +16,12 @@ use Throwable;
* @property float $vat_rate
* @property array $payload
* @property string $ends_at
* @property string $webhook_url
* @property string $webhook_secret
* @property null|string $confirmation_url where the user confirms it (third-party apps)
*/
class Subscription extends BaseResource
{
use HasBillable;
use HasConfirmationUrl;
/**
* Update a given subscription from the current user.
@@ -33,9 +34,7 @@ class Subscription extends BaseResource
* price: float,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* ends_at: null|string
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name (required when set)
@@ -45,8 +44,6 @@ class Subscription extends BaseResource
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws Throwable
*/
public function update(array $attributes): self
+1 -5
View File
@@ -22,9 +22,7 @@ class Subscriptions extends BaseCollection
* price: float,
* vat_rate: float,
* payload: null|array,
* ends_at: null|string,
* webhook_url: null|string,
* webhook_secret: null|string
* ends_at: null|string
* } $attributes The subscription data:
* - group: The group (optional)
* - name: The name
@@ -34,8 +32,6 @@ class Subscriptions extends BaseCollection
* - vat_rate: The VAT rate (required when set)
* - payload: The payload (optional)
* - ends_at: The end date (optional)
* - webhook_url: The webhook URL (optional)
* - webhook_secret: The webhook secret (optional)
* @throws Throwable
*/
public function create(array $attributes): Subscription
+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.');
}
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
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);
}
}
+11
View File
@@ -76,6 +76,17 @@ class AppSubscriptionsTest extends TestCase
$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']);
+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());
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Illuminate\Database\Eloquent\Model;
class CheckoutConfirmationTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
public function test_a_draft_order_links_to_its_confirmation_with_the_return_url(): void
{
$this->respond(200, ['data' => []]); // orders() loads the list first
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]);
$order = $this->owner()->orders()->create(['billing_address_id' => '01JADDR', 'items' => []]);
$this->assertSame('https://id.anikeen.com/checkout/orders/01JORD', $order->confirmationUrl());
$this->assertSame(
'https://id.anikeen.com/checkout/orders/01JORD?return_url=https%3A%2F%2Fshop.test%2Fdone%3Fcart%3D1',
$order->confirmationUrl('https://shop.test/done?cart=1'),
);
$this->assertSame('POST /api/v1/orders', $this->sent[1]['request']->getMethod() . ' ' . $this->sent[1]['request']->getUri()->getPath());
}
public function test_checked_out_bookings_have_no_confirmation_url(): void
{
$this->respond(200, ['data' => []]);
$this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'completed', 'confirmation_url' => null]]);
$this->assertNull($this->owner()->orders()->create([])->confirmationUrl('https://shop.test/done'));
}
public function test_checking_out_a_draft_as_a_third_party_app_points_to_the_confirmation(): void
{
$this->respond(200, ['data' => []]); // subscriptions() loads the list first
$this->respond(201, ['data' => ['id' => '01JSUB', 'active' => false, 'confirmation_url' => 'https://id.anikeen.com/checkout/subscriptions/01JSUB']]);
$this->respond(403, ['message' => 'The user has to confirm this on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.anikeen.com/checkout/subscriptions/01JSUB']);
$subscription = $this->owner()->subscriptions()->create(['name' => 'Pro', 'unit' => 'month', 'price' => 4.99]);
try {
$subscription->checkout();
$this->fail('Expected a ConfirmationRequiredException.');
} catch (ConfirmationRequiredException $e) {
$this->assertSame(403, $e->getCode());
$this->assertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\Webhook;
class WebhookTest extends TestCase
{
private string $secret = 'whsec_test';
private string $payload = '{"id":"01JEVT","type":"order.completed","created":1790000000,"data":{"object":{"id":"01JORD"}}}';
private function header(?int $timestamp = null, ?string $secret = null): string
{
$timestamp ??= time();
return "t={$timestamp},v1=" . hash_hmac('sha256', "{$timestamp}.{$this->payload}", $secret ?? $this->secret);
}
public function test_a_signed_event_is_returned(): void
{
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
$this->assertSame('order.completed', $event['type']);
$this->assertSame('01JORD', $event['data']['object']['id']);
}
public function test_a_wrong_secret_is_rejected(): void
{
$this->expectException(SignatureVerificationException::class);
Webhook::constructEvent($this->payload, $this->header(secret: 'whsec_other'), $this->secret);
}
public function test_a_changed_body_is_rejected(): void
{
$this->expectException(SignatureVerificationException::class);
Webhook::constructEvent(str_replace('01JORD', '01JXXX', $this->payload), $this->header(), $this->secret);
}
public function test_old_requests_are_rejected(): void
{
$this->expectException(SignatureVerificationException::class);
Webhook::constructEvent($this->payload, $this->header(time() - 600), $this->secret);
}
public function test_a_missing_header_is_rejected(): void
{
$this->expectException(SignatureVerificationException::class);
Webhook::constructEvent($this->payload, null, $this->secret);
}
}