diff --git a/README.md b/README.md index 01ae3e2..3b3a7d0 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,20 @@ try { } ``` +### App orders (trusted apps, no user token) + +Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): + +```php +$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first +app(AnikeenId::class)->appOrders()->pending(); // of all users +$team->owner->appOrders()->find($orderId); // null if unknown or not this user's +``` + +Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not +paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = +no more automatic retries) and `last_payment_error`. + ### 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: @@ -362,6 +376,7 @@ public function shippingPriceList(): Result ### ManagesUserBilling ```php +public function appOrders(): AppOrders public function createOrderForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result ``` @@ -464,6 +479,7 @@ public function getBillable(): Billable ```php public function subscriptions(): Subscriptions public function appSubscriptions(): AppSubscriptions +public function appOrders(): AppOrders public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` diff --git a/README.stub b/README.stub index bafe028..a1f5d9d 100644 --- a/README.stub +++ b/README.stub @@ -142,6 +142,20 @@ try { } ``` +### App orders (trusted apps, no user token) + +Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): + +```php +$team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first +app(AnikeenId::class)->appOrders()->pending(); // of all users +$team->owner->appOrders()->find($orderId); // null if unknown or not this user's +``` + +Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not +paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = +no more automatic retries) and `last_payment_error`. + ### 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/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php index d9cb6dd..446c89b 100644 --- a/src/Id/Concerns/ManagesSubscriptions.php +++ b/src/Id/Concerns/ManagesSubscriptions.php @@ -2,6 +2,7 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\Subscriptions; use Throwable; @@ -35,4 +36,13 @@ trait ManagesSubscriptions { return new AppSubscriptions($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. + */ + public function appOrders(): AppOrders + { + 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 44472b9..28e50a0 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\AppOrders; use Anikeen\Id\Result; use Throwable; @@ -10,6 +11,14 @@ use Throwable; */ trait ManagesUserBilling { + /** + * Orders of all users that bill the app's subscriptions (e.g. ->pending() for unpaid ones). + */ + public function appOrders(): AppOrders + { + return new AppOrders(); + } + /** * 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/AppOrders.php b/src/Id/Resources/AppOrders.php new file mode 100644 index 0000000..0457e2c --- /dev/null +++ b/src/Id/Resources/AppOrders.php @@ -0,0 +1,88 @@ +appOrders()) or of all users + * (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called. + * + * Each order is an object with id, user_id, group, status (processing|pending|completed|cancelled), + * is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at, + * payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null). + */ +class AppOrders +{ + public function __construct(protected Billable|Model|null $billable = null) + { + } + + /** + * All matching orders, oldest first. + * + * @param array{status?: string, group?: string} $filters + * @return list + * + * @throws ApiException + */ + public function all(array $filters = []): array + { + $orders = []; + $page = 1; + + do { + $result = AppRequest::send('GET', 'v1/client/orders', null, [ + ...$filters, + ...($this->billable ? ['user_id' => $this->userId()] : []), + 'per_page' => 100, + 'page' => $page, + ]); + + array_push($orders, ...$result->data); + $lastPage = (int) ($result->meta->last_page ?? $page); + } while ($page++ < $lastPage); + + return $orders; + } + + /** + * Closed orders that aren't paid (yet), oldest first. + * + * @return list + * + * @throws ApiException + */ + public function pending(array $filters = []): array + { + return $this->all([...$filters, 'status' => 'pending']); + } + + /** + * @throws ApiException + */ + public function find(string $id): ?object + { + try { + $order = AppRequest::send('GET', sprintf('v1/client/orders/%s', $id))->data; + } catch (ApiException $e) { + if ($e->isNotFound()) { + return null; + } + + throw $e; + } + + return !$this->billable || $order->user_id === $this->userId() ? $order : null; + } + + private function userId(): string + { + return (new AppSubscriptions($this->billable))->userId(); + } +} diff --git a/tests/Id/AppOrdersTest.php b/tests/Id/AppOrdersTest.php new file mode 100644 index 0000000..9bcb0a0 --- /dev/null +++ b/tests/Id/AppOrdersTest.php @@ -0,0 +1,71 @@ +anikeen_id = '01JUSER'; + + return $owner; + } + + private function page(array $orders, int $page = 1, int $last = 1): array + { + return ['data' => $orders, 'meta' => ['current_page' => $page, 'last_page' => $last, 'total' => count($orders)], 'links' => ['next' => null]]; + } + + private function requestQuery(int $index): array + { + parse_str($this->sent[$index]['request']->getUri()->getQuery(), $query); + + return $query; + } + + public function test_pending_orders_of_a_user(): void + { + $this->respond(200, $this->page([['id' => 'o1', 'user_id' => '01JUSER', 'status' => 'pending', 'gross_total' => 11.9, 'next_payment_attempt_at' => null, 'payment_attempts' => 4]])); + + $orders = $this->owner()->appOrders()->pending(); + + $this->assertSame('o1', $orders[0]->id); + $this->assertSame(4, $orders[0]->payment_attempts); + $request = $this->sent[0]['request']; + $this->assertSame('GET /api/v1/client/orders Bearer app-token', $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization')); + $this->assertSame(['status' => 'pending', 'user_id' => '01JUSER', 'per_page' => '100', 'page' => '1'], $this->requestQuery(0)); + } + + public function test_pending_orders_of_all_users_follow_pages(): void + { + $this->respond(200, $this->page([['id' => 'o1', 'user_id' => 'a']], 1, 2)); + $this->respond(200, $this->page([['id' => 'o2', 'user_id' => 'b']], 2, 2)); + + $orders = app(AnikeenId::class)->appOrders()->pending(['group' => 'team-1']); + + $this->assertSame(['o1', 'o2'], array_map(fn ($o) => $o->id, $orders)); + $this->assertSame(['group' => 'team-1', 'status' => 'pending', 'per_page' => '100', 'page' => '2'], $this->requestQuery(1)); + } + + public function test_find_returns_null_for_unknown_and_other_users_orders(): void + { + $this->respond(404, ['message' => 'Order not found.', 'error' => 'order_not_found']); + $this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]); + $this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]); + + $orders = $this->owner()->appOrders(); + + $this->assertNull($orders->find('unknown')); + $this->assertNull($orders->find('o1')); + $this->assertSame('o1', $orders->find('o1')->id); + } +}