diff --git a/README.md b/README.md index cb7524a..4d9dc93 100644 --- a/README.md +++ b/README.md @@ -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 = 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) Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), @@ -222,12 +279,13 @@ 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 +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 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\EventType; use Anikeen\Id\Webhooks\Webhook; Route::post('webhooks/anikeen', function (Request $request) { @@ -238,8 +296,9 @@ Route::post('webhooks/anikeen', function (Request $request) { } match ($event['type']) { - 'order.completed' => Shop::fulfil($event['data']['object']['id']), - 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + EventType::ORDER_COMPLETED => Shop::fulfil($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, }; @@ -471,6 +530,7 @@ public function shippingPriceList(): Result ```php public function appOrders(): AppOrders +public function appCheckoutSessions(): AppCheckoutSessions public function createOrderForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result ``` @@ -573,6 +633,8 @@ public function getBillable(): Billable ```php public function subscriptions(): Subscriptions public function appSubscriptions(): AppSubscriptions +public function appCheckoutSessions(): AppCheckoutSessions +public function checkoutSessions(): CheckoutSessions public function appOrders(): AppOrders public function setBillable(Billable|Model $billable): self public function getBillable(): Billable diff --git a/README.stub b/README.stub index 6fdf071..5522a5f 100644 --- a/README.stub +++ b/README.stub @@ -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 = 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) 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 -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 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\EventType; use Anikeen\Id\Webhooks\Webhook; Route::post('webhooks/anikeen', function (Request $request) { @@ -201,8 +259,9 @@ Route::post('webhooks/anikeen', function (Request $request) { } match ($event['type']) { - 'order.completed' => Shop::fulfil($event['data']['object']['id']), - 'subscription.cancelled' => Panel::suspend($event['data']['object']['id']), + EventType::ORDER_COMPLETED => Shop::fulfil($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, }; diff --git a/src/Id/Concerns/IsCheckoutSession.php b/src/Id/Concerns/IsCheckoutSession.php new file mode 100644 index 0000000..05fb49c --- /dev/null +++ b/src/Id/Concerns/IsCheckoutSession.php @@ -0,0 +1,49 @@ +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 + */ + public function subscriptionIds(): array + { + return array_values((array) ($this->subscription_ids ?? [])); + } +} diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php index 446c89b..33c78b4 100644 --- a/src/Id/Concerns/ManagesSubscriptions.php +++ b/src/Id/Concerns/ManagesSubscriptions.php @@ -2,8 +2,10 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppSubscriptions; +use Anikeen\Id\Resources\CheckoutSessions; use Anikeen\Id\Resources\Subscriptions; use Throwable; @@ -37,6 +39,24 @@ trait ManagesSubscriptions return new AppSubscriptions($this); } + /** + * Checkout sessions for the user, created with the app's own token (trusted apps only, scope + * subscriptions:client): the user confirms several subscriptions at once on Anikeen ID. + */ + public function appCheckoutSessions(): AppCheckoutSessions + { + return new AppCheckoutSessions($this); + } + + /** + * Checkout sessions with the user's own access token (any app): the user confirms several + * subscriptions at once on Anikeen ID. + */ + public function checkoutSessions(): CheckoutSessions + { + return new CheckoutSessions($this); + } + /** * The user's orders that bill the app's subscriptions, read with the app's own token * (trusted apps only, scope orders:client), e.g. ->pending() for unpaid ones. @@ -45,4 +65,4 @@ trait ManagesSubscriptions { return new AppOrders($this); } -} \ No newline at end of file +} diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php index e675647..f5d91ad 100644 --- a/src/Id/Concerns/ManagesUserBilling.php +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -2,6 +2,7 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Result; use Throwable; @@ -19,6 +20,15 @@ trait ManagesUserBilling return new AppOrders(); } + /** + * Checkout sessions of the app for any user, e.g. to look one up from a checkout.* webhook + * (create() then needs a user_id). + */ + public function appCheckoutSessions(): AppCheckoutSessions + { + return new AppCheckoutSessions(); + } + /** * Create an order for a user (VAT from the billing address). With `checkout`, it is paid right * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's diff --git a/src/Id/Resources/AppCheckoutSession.php b/src/Id/Resources/AppCheckoutSession.php new file mode 100644 index 0000000..380e721 --- /dev/null +++ b/src/Id/Resources/AppCheckoutSession.php @@ -0,0 +1,97 @@ + $subscription_ids + * @property list $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 + */ + 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; + } +} diff --git a/src/Id/Resources/AppCheckoutSessions.php b/src/Id/Resources/AppCheckoutSessions.php new file mode 100644 index 0000000..f4661f0 --- /dev/null +++ b/src/Id/Resources/AppCheckoutSessions.php @@ -0,0 +1,79 @@ +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, + * 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; + } +} diff --git a/src/Id/Resources/AppSubscription.php b/src/Id/Resources/AppSubscription.php index dba35fe..03f3377 100644 --- a/src/Id/Resources/AppSubscription.php +++ b/src/Id/Resources/AppSubscription.php @@ -29,6 +29,7 @@ use JsonSerializable; * @property int $failed_renewal_retries * @property ?array $payload * @property ?string $idempotency_key + * @property ?string $checkout_session_id the checkout session the user confirms it in, if any */ class AppSubscription implements JsonSerializable { diff --git a/src/Id/Resources/CheckoutSession.php b/src/Id/Resources/CheckoutSession.php new file mode 100644 index 0000000..5fa0450 --- /dev/null +++ b/src/Id/Resources/CheckoutSession.php @@ -0,0 +1,57 @@ + $subscription_ids + * @property list $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); + } +} diff --git a/src/Id/Resources/CheckoutSessions.php b/src/Id/Resources/CheckoutSessions.php new file mode 100644 index 0000000..b77c08a --- /dev/null +++ b/src/Id/Resources/CheckoutSessions.php @@ -0,0 +1,58 @@ +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; + } + } +} diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php new file mode 100644 index 0000000..e4b8b44 --- /dev/null +++ b/src/Id/Webhooks/EventType.php @@ -0,0 +1,46 @@ + …, … }`. + */ +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 + */ + public static function all(): array + { + return array_values((new \ReflectionClass(self::class))->getConstants()); + } + + private function __construct() + { + } +} diff --git a/tests/Id/CheckoutSessionsTest.php b/tests/Id/CheckoutSessionsTest.php new file mode 100644 index 0000000..06635f8 --- /dev/null +++ b/tests/Id/CheckoutSessionsTest.php @@ -0,0 +1,163 @@ +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()); + } +}