From f20a12e6dae630902272586cbdf0cdaf9ff42eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sun, 1 Nov 2026 11:01:29 +0100 Subject: [PATCH] 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 --- README.md | 26 ++++++++ README.stub | 26 ++++++++ src/Id/Concerns/HasConfirmationUrl.php | 27 ++++++++ .../ConfirmationRequiredException.php | 17 +++++ src/Id/Resources/BaseResource.php | 9 +++ src/Id/Resources/Order.php | 3 + src/Id/Resources/Subscription.php | 3 + tests/Id/CheckoutConfirmationTest.php | 63 +++++++++++++++++++ 8 files changed, 174 insertions(+) create mode 100644 src/Id/Concerns/HasConfirmationUrl.php create mode 100644 src/Id/Exceptions/ConfirmationRequiredException.php create mode 100644 tests/Id/CheckoutConfirmationTest.php diff --git a/README.md b/README.md index d941bb1..e8a1c6a 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,32 @@ 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. + ### 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: diff --git a/README.stub b/README.stub index dc76d3c..9223695 100644 --- a/README.stub +++ b/README.stub @@ -157,6 +157,32 @@ 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. + ### 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: diff --git a/src/Id/Concerns/HasConfirmationUrl.php b/src/Id/Concerns/HasConfirmationUrl.php new file mode 100644 index 0000000..024d962 --- /dev/null +++ b/src/Id/Concerns/HasConfirmationUrl.php @@ -0,0 +1,27 @@ +confirmation_url ?? null; + + if (!$url || !$returnUrl) { + return $url ?: null; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query(['return_url' => $returnUrl]); + } +} diff --git a/src/Id/Exceptions/ConfirmationRequiredException.php b/src/Id/Exceptions/ConfirmationRequiredException.php new file mode 100644 index 0000000..646150a --- /dev/null +++ b/src/Id/Exceptions/ConfirmationRequiredException.php @@ -0,0 +1,17 @@ +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()); } diff --git a/src/Id/Resources/Order.php b/src/Id/Resources/Order.php index b71f975..308a022 100644 --- a/src/Id/Resources/Order.php +++ b/src/Id/Resources/Order.php @@ -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. diff --git a/src/Id/Resources/Subscription.php b/src/Id/Resources/Subscription.php index 06e4c75..5e47469 100644 --- a/src/Id/Resources/Subscription.php +++ b/src/Id/Resources/Subscription.php @@ -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; @@ -17,10 +18,12 @@ use Throwable; * @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. diff --git a/tests/Id/CheckoutConfirmationTest.php b/tests/Id/CheckoutConfirmationTest.php new file mode 100644 index 0000000..92d385d --- /dev/null +++ b/tests/Id/CheckoutConfirmationTest.php @@ -0,0 +1,63 @@ +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); + } + } +}