diff --git a/README.md b/README.md index 97d029f..9da79b2 100644 --- a/README.md +++ b/README.md @@ -587,6 +587,33 @@ try { } ``` +### Confirmable orders (trusted apps) + +Trusted apps can charge one-off orders right away (`'checkout' => true`). With `'confirm' => true` +the user confirms and pays the order on Anikeen ID instead, like for a third-party app – e.g. a +top-up the user starts in your app: Anikeen ID shows the order with VAT, the billing address and +the payment method (one can be added there) and takes the payment. + +```php +$order = app(AnikeenId::class)->createOrderForUser($team->owner->anikeen_id, [ + 'group' => $team->id, + 'confirm' => true, + 'return_url' => route('billing.topup.done'), // same origin as one of your redirect URIs + 'metadata' => ['topup' => $topup->id], // returned as is, also in the order webhooks + 'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']], +])->data; + +return redirect()->away($order->confirmation_url); +// later: https://console.test/billing/topup/done?status=completed&order=01K… +``` + +`confirm` needs `items` and can't be combined with `checkout` or a `transaction` (422). The draft +expires after 24 hours (`expires_at`): it is cancelled, `confirmation_url` becomes null and +`checkout()` answers 409 `confirmation_expired`. Follow the order through its webhooks: +`order.created`, then `order.completed` once paid (`order.payment_failed` for a declined charge) +or `order.cancelled` when the user declines or it expires. `return_url` and `metadata` work for +every order, also with the user's token (`$user->orders()->create([...])`). + ### Changing running subscriptions (trusted apps) `update()` changes a subscription before its checkout (afterwards only the description). diff --git a/README.stub b/README.stub index e101a24..6158682 100644 --- a/README.stub +++ b/README.stub @@ -587,6 +587,33 @@ try { } ``` +### Confirmable orders (trusted apps) + +Trusted apps can charge one-off orders right away (`'checkout' => true`). With `'confirm' => true` +the user confirms and pays the order on Anikeen ID instead, like for a third-party app – e.g. a +top-up the user starts in your app: Anikeen ID shows the order with VAT, the billing address and +the payment method (one can be added there) and takes the payment. + +```php +$order = app(AnikeenId::class)->createOrderForUser($team->owner->anikeen_id, [ + 'group' => $team->id, + 'confirm' => true, + 'return_url' => route('billing.topup.done'), // same origin as one of your redirect URIs + 'metadata' => ['topup' => $topup->id], // returned as is, also in the order webhooks + 'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']], +])->data; + +return redirect()->away($order->confirmation_url); +// later: https://console.test/billing/topup/done?status=completed&order=01K… +``` + +`confirm` needs `items` and can't be combined with `checkout` or a `transaction` (422). The draft +expires after 24 hours (`expires_at`): it is cancelled, `confirmation_url` becomes null and +`checkout()` answers 409 `confirmation_expired`. Follow the order through its webhooks: +`order.created`, then `order.completed` once paid (`order.payment_failed` for a declined charge) +or `order.cancelled` when the user declines or it expires. `return_url` and `metadata` work for +every order, also with the user's token (`$user->orders()->create([...])`). + ### Changing running subscriptions (trusted apps) `update()` changes a subscription before its checkout (afterwards only the description). diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php index f5d91ad..d87b4ff 100644 --- a/src/Id/Concerns/ManagesUserBilling.php +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -34,6 +34,12 @@ trait ManagesUserBilling * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's * default payment method (402 when declined; the order stays pending and is retried). * + * With `confirm`, the user confirms and pays it on Anikeen ID instead (e.g. a top-up): the + * order stays a draft with `confirmation_url` (send the user there) and is cancelled if not + * confirmed within 24 hours (`expires_at`; afterwards checking it out answers 409 + * "confirmation_expired"). Webhooks: order.created, order.completed once paid, or + * order.cancelled when the user declines or it expires. + * * @param string $userId The user's Anikeen ID. * @param array{ * group?: string, @@ -43,11 +49,18 @@ trait ManagesUserBilling * shipping_address?: array, * items: array, * checkout?: bool, + * confirm?: bool, + * return_url?: null|string, + * metadata?: null|array, * transaction_id?: string, * transaction?: array, * invoice_date?: string, * currency_iso?: string - * } $attributes + * } $attributes confirm: needs items, can't be combined with checkout or a transaction. + * return_url: where the user returns after confirming, with `status` and + * `order` (same origin as one of the app's redirect URIs, 422 otherwise). + * metadata: the app's own data (max. 50 keys), returned as is in the API and + * the order webhooks. * @throws Throwable */ public function createOrderForUser(string $userId, array $attributes): Result diff --git a/src/Id/Resources/Order.php b/src/Id/Resources/Order.php index 8ecd5f3..9602a7f 100644 --- a/src/Id/Resources/Order.php +++ b/src/Id/Resources/Order.php @@ -8,7 +8,11 @@ use Throwable; /** * @property string $id - * @property null|string $confirmation_url where the user confirms it (third-party apps) + * @property null|string $confirmation_url where the user confirms it (third-party apps, or `confirm`) + * @property null|string $group + * @property null|object $metadata the app's own data, returned as is + * @property null|string $return_url where the user returns after confirming + * @property null|string $expires_at when a draft created with `confirm` is cancelled unless confirmed */ class Order extends BaseResource { @@ -53,10 +57,14 @@ class Order extends BaseResource * country_iso: string, * phone_number: null|string, * email: null|string - * } + * }, + * group?: null|string, + * metadata?: null|array * } $attributes The order data: * - billing_address: Billing address (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) + * - group: The app's group (optional) + * - metadata: The app's own data (max. 50 keys), only while the order is a draft (optional) * @throws Throwable */ public function update(array $attributes = []): self @@ -69,6 +77,8 @@ class Order extends BaseResource /** * Checkout given order from the current user. * + * A draft created with `confirm` can't be checked out once it expired (409 "confirmation_expired"). + * * @throws Throwable */ public function checkout(): self diff --git a/src/Id/Resources/Orders.php b/src/Id/Resources/Orders.php index e56a21f..6c6b62d 100644 --- a/src/Id/Resources/Orders.php +++ b/src/Id/Resources/Orders.php @@ -48,6 +48,10 @@ class Orders extends BaseCollection * phone_number: null|string, * email: null|string * }, + * group?: null|string, + * confirm?: bool, + * return_url?: null|string, + * metadata?: null|array, * items: arrayassertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl); } } + + public function test_a_trusted_app_lets_the_user_confirm_its_order(): void + { + $this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'group' => 'team-1', 'metadata' => ['topup' => '7'], 'expires_at' => '2026-11-05T10:00:00+00:00', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]); + + $result = app(AnikeenId::class)->createOrderForUser('01JUSER', [ + 'group' => 'team-1', + 'confirm' => true, + 'return_url' => 'https://console.test/billing/topup', + 'metadata' => ['topup' => '7'], + 'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']], + ]); + + $this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sent[0]['request']->getMethod() . ' ' . $this->sent[0]['request']->getUri()->getPath() . ' ' . $this->sent[0]['request']->getHeaderLine('Authorization')); + $this->assertTrue($this->sentJson(0)['confirm']); + $this->assertSame('https://console.test/billing/topup', $this->sentJson(0)['return_url']); + $this->assertSame(['topup' => '7'], $this->sentJson(0)['metadata']); + $this->assertSame('01JUSER', $this->sentJson(0)['user_id']); + $this->assertSame('https://id.anikeen.com/checkout/orders/01JORD', $result->data->confirmation_url); + $this->assertSame('7', $result->data->metadata->topup); + } + + public function test_an_expired_confirmation_is_a_conflict(): void + { + $this->respond(200, ['data' => []]); + $this->respond(201, ['data' => ['id' => '01JORD', 'status' => 'draft', 'confirmation_url' => 'https://id.anikeen.com/checkout/orders/01JORD']]); + $this->respond(409, ['message' => 'The order was not confirmed in time.', 'error' => 'confirmation_expired']); + + $order = $this->owner()->orders()->create(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []]); + + $this->assertSame(['confirm' => true, 'return_url' => 'https://shop.test/done', 'metadata' => ['cart' => 1], 'items' => []], $this->sentJson(1)); + + try { + $order->checkout(); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertTrue($e->isConflict()); + $this->assertSame('confirmation_expired', $e->error); + } + } }