mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
Let users confirm orders of trusted apps
confirm, return_url and metadata on createOrderForUser() and orders created with the user's token: the order stays a draft with a confirmation_url and expires after 24 hours (409 confirmation_expired on checkout). metadata can be changed on a draft with Order::update().
This commit is contained in:
6 files changed
+130
-3
No files matched your search
@@ -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)
|
### Changing running subscriptions (trusted apps)
|
||||||
|
|
||||||
`update()` changes a subscription before its checkout (afterwards only the description).
|
`update()` changes a subscription before its checkout (afterwards only the description).
|
||||||
|
|||||||
+27
@@ -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)
|
### Changing running subscriptions (trusted apps)
|
||||||
|
|
||||||
`update()` changes a subscription before its checkout (afterwards only the description).
|
`update()` changes a subscription before its checkout (afterwards only the description).
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ trait ManagesUserBilling
|
|||||||
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
|
* 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).
|
* 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 string $userId The user's Anikeen ID.
|
||||||
* @param array{
|
* @param array{
|
||||||
* group?: string,
|
* group?: string,
|
||||||
@@ -43,11 +49,18 @@ trait ManagesUserBilling
|
|||||||
* shipping_address?: array,
|
* shipping_address?: array,
|
||||||
* items: array<array{type: string, name: string, description?: string, image_url?: 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,
|
* checkout?: bool,
|
||||||
|
* confirm?: bool,
|
||||||
|
* return_url?: null|string,
|
||||||
|
* metadata?: null|array,
|
||||||
* transaction_id?: string,
|
* transaction_id?: string,
|
||||||
* transaction?: array,
|
* transaction?: array,
|
||||||
* invoice_date?: string,
|
* invoice_date?: string,
|
||||||
* currency_iso?: 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
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function createOrderForUser(string $userId, array $attributes): Result
|
public function createOrderForUser(string $userId, array $attributes): Result
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use Throwable;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $id
|
* @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
|
class Order extends BaseResource
|
||||||
{
|
{
|
||||||
@@ -53,10 +57,14 @@ class Order extends BaseResource
|
|||||||
* country_iso: string,
|
* country_iso: string,
|
||||||
* phone_number: null|string,
|
* phone_number: null|string,
|
||||||
* email: null|string
|
* email: null|string
|
||||||
* }
|
* },
|
||||||
|
* group?: null|string,
|
||||||
|
* metadata?: null|array
|
||||||
* } $attributes The order data:
|
* } $attributes The order data:
|
||||||
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
* - 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)
|
* - 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
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function update(array $attributes = []): self
|
public function update(array $attributes = []): self
|
||||||
@@ -69,6 +77,8 @@ class Order extends BaseResource
|
|||||||
/**
|
/**
|
||||||
* Checkout given order from the current user.
|
* 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
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function checkout(): self
|
public function checkout(): self
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class Orders extends BaseCollection
|
|||||||
* phone_number: null|string,
|
* phone_number: null|string,
|
||||||
* email: null|string
|
* email: null|string
|
||||||
* },
|
* },
|
||||||
|
* group?: null|string,
|
||||||
|
* confirm?: bool,
|
||||||
|
* return_url?: null|string,
|
||||||
|
* metadata?: null|array,
|
||||||
* items: array<array{
|
* items: array<array{
|
||||||
* type: string,
|
* type: string,
|
||||||
* name: string,
|
* name: string,
|
||||||
@@ -61,6 +65,10 @@ class Orders extends BaseCollection
|
|||||||
* } $attributes The order data:
|
* } $attributes The order data:
|
||||||
* - billing_address: Billing address (ISO 3166-1 alpha-2 country code)
|
* - 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)
|
* - 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, e.g. a team (optional)
|
||||||
|
* - confirm: Trusted apps: leave confirming and paying to the user (confirmation_url, expires after 24 hours; optional)
|
||||||
|
* - return_url: Where the user returns after confirming (same origin as one of the app's redirect URIs; optional)
|
||||||
|
* - metadata: The app's own data (max. 50 keys), returned as is in the API and webhooks (optional)
|
||||||
* - items: Array of order items:
|
* - items: Array of order items:
|
||||||
* - type: digital, physical or physical-reduced (VAT category)
|
* - type: digital, physical or physical-reduced (VAT category)
|
||||||
* - price/gross_price: net or gross unit price, one of both
|
* - price/gross_price: net or gross unit price, one of both
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace Anikeen\Id\Tests;
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
use Anikeen\Id\Billable;
|
use Anikeen\Id\Billable;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
|
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
@@ -60,4 +62,44 @@ class CheckoutConfirmationTest extends TestCase
|
|||||||
$this->assertSame('https://id.anikeen.com/checkout/subscriptions/01JSUB', $e->confirmationUrl);
|
$this->assertSame('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user