Compare commits

..
Author SHA1 Message Date
envoyr 9a6f40ad99 Merge pull request #2 from anikeen-com/app-subscriptions
Bring anikeen/id up to date: app subscriptions, fixed and missing endpoints, Laravel 11-13
2026-10-25 17:25:22 +01:00
6 changed files with 0 additions and 208 deletions

No files matched your search

-16
View File
@@ -142,20 +142,6 @@ 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 ### 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: 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:
@@ -376,7 +362,6 @@ public function shippingPriceList(): Result
### ManagesUserBilling ### ManagesUserBilling
```php ```php
public function appOrders(): AppOrders
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
``` ```
@@ -479,7 +464,6 @@ public function getBillable(): Billable
```php ```php
public function subscriptions(): Subscriptions public function subscriptions(): Subscriptions
public function appSubscriptions(): AppSubscriptions public function appSubscriptions(): AppSubscriptions
public function appOrders(): AppOrders
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
-14
View File
@@ -142,20 +142,6 @@ 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 ### 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: 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:
-10
View File
@@ -2,7 +2,6 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\AppSubscriptions;
use Anikeen\Id\Resources\Subscriptions; use Anikeen\Id\Resources\Subscriptions;
use Throwable; use Throwable;
@@ -36,13 +35,4 @@ trait ManagesSubscriptions
{ {
return new AppSubscriptions($this); 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);
}
}␍ }␍
-9
View File
@@ -2,7 +2,6 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Throwable; use Throwable;
@@ -11,14 +10,6 @@ use Throwable;
*/ */
trait ManagesUserBilling 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 * 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 * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
-88
View File
@@ -1,88 +0,0 @@
<?php
namespace Anikeen\Id\Resources;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Support\AppRequest;
use Illuminate\Database\Eloquent\Model;
/**
* Orders that bill the app's subscriptions, read with the app's own token (trusted apps,
* scope orders:client) - of one user ($user->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<object>
*
* @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<object>
*
* @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();
}
}
-71
View File
@@ -1,71 +0,0 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->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);
}
}