mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-01 13:19:34 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6e6684d00 | ||
|
|
68132e7026 | ||
|
|
5c09089822
|
||
|
|
526b65cb5c
|
||
|
|
f20a12e6da
|
||
|
|
0fce38f48a |
No files matched your search
@@ -157,6 +157,62 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
|
||||
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:
|
||||
|
||||
+56
@@ -157,6 +157,62 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
|
||||
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:
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user