mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-01 05:09:35 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fce38f48a | ||
|
|
c4a42b4ddc
|
||
|
|
a1707e9189 | ||
|
|
820f1b37cc
|
No files matched your search
@@ -111,6 +111,7 @@ $subscription = $subscriptions->create([
|
||||
]);
|
||||
|
||||
$subscription->checkout(); // bill the first period
|
||||
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
|
||||
$subscription->pause(); // expires when the paid period ends
|
||||
$subscription->resume(); // expired subscriptions start a new period
|
||||
$subscription->revoke(refund: false); // cancel
|
||||
@@ -142,6 +143,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 +377,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 +480,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
|
||||
```
|
||||
|
||||
+15
@@ -111,6 +111,7 @@ $subscription = $subscriptions->create([
|
||||
]);
|
||||
|
||||
$subscription->checkout(); // bill the first period
|
||||
$subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then
|
||||
$subscription->pause(); // expires when the paid period ends
|
||||
$subscription->resume(); // expired subscriptions start a new period
|
||||
$subscription->revoke(refund: false); // cancel
|
||||
@@ -142,6 +143,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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}␍
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use DateTimeInterface;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
@@ -58,11 +59,18 @@ class AppSubscription implements JsonSerializable
|
||||
/**
|
||||
* Activate the subscription and bill its first period.
|
||||
*
|
||||
* @throws ApiException 409 "already_active", "billing_address_missing", …
|
||||
* $startsAt (in the past) books a service that is already running, e.g. a domain registered
|
||||
* earlier: the first period runs from $startsAt, is billed now and renews when it ends.
|
||||
*
|
||||
* @throws ApiException 409 "already_active", "billing_address_missing", "period_already_ended", …
|
||||
*/
|
||||
public function checkout(): self
|
||||
public function checkout(DateTimeInterface|string|null $startsAt = null): self
|
||||
{
|
||||
return $this->send('PUT', '/checkout');
|
||||
if ($startsAt instanceof DateTimeInterface) {
|
||||
$startsAt = $startsAt->format(DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
return $this->send('PUT', '/checkout', $startsAt === null ? null : ['starts_at' => $startsAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,17 @@ class AppSubscriptionsTest extends TestCase
|
||||
$this->assertSame(['refund' => true], $this->sentJson(4));
|
||||
}
|
||||
|
||||
public function test_a_running_service_is_checked_out_from_its_start(): void
|
||||
{
|
||||
$this->respond(201, $this->subscription());
|
||||
$this->respond(200, $this->subscription(['active' => true]));
|
||||
|
||||
$subscription = $this->owner()->appSubscriptions()->create(['name' => '.de TLD', 'unit' => 'year', 'price' => '5.19']);
|
||||
$subscription->checkout(new \DateTimeImmutable('2026-05-19 11:33:58', new \DateTimeZone('UTC')));
|
||||
|
||||
$this->assertSame(['starts_at' => '2026-05-19T11:33:58+00:00'], $this->sentJson(1));
|
||||
}
|
||||
|
||||
public function test_errors_carry_status_and_error_code(): void
|
||||
{
|
||||
$this->respond(409, ['message' => 'Product subscription has already been cancelled.', 'error' => 'already_cancelled']);
|
||||
|
||||
Reference in new issue
Block a user