Read order lines and invoices of app orders

Document the items (subscription, period, net amount, discount), invoice
and metadata of orders read with the app's token, add
AppOrders::invoicePdf() for the invoice PDF and the opt-in
subscription.renewed event type with its period data.
This commit is contained in:
Maurice Preuß committed 2026-11-04 21:25:56 +01:00
1 parent db3c50af72
commit bd56f114da
6 files changed
+188 -3

No files matched your search

+31
View File
@@ -469,6 +469,14 @@ arrive more than once or out of order: use `$event['id']` to skip duplicates, an
object through the API when the current state matters. The admin panel can send a object through the API when the current state matters. The admin panel can send a
`webhook.test` event to check your endpoint. `webhook.test` event to check your endpoint.
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent for every period after the
first that was booked on the user's monthly order, with `$event['data']['period']`:
`{subscription_id, period_start, period_end, periods, unit, order_id, order_item_id, items,
currency_iso, net_amount}` (`net_amount` after a discount). Hourly subscriptions renew every hour,
so this event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
`order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token).
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -630,6 +638,29 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
Orders also list their lines and their invoice, e.g. to show a team's invoices or to attribute
amounts (commissions) per subscription:
```php
$order = app(AnikeenId::class)->appOrders()->find($orderId);
foreach ($order->items as $item) {
$item->subscription_id; // null for lines that bill no subscription
$item->period_start; $item->period_end; // ISO 8601, the end is exclusive; null without a period
$item->net_amount; // units × price, rounded to cents
$item->discount; // {label, percent_off, amount_off} on a discount line, else null
}
$order->invoice; // null until paid, then {id, number, status, …, pdf_url}
$pdf = app(AnikeenId::class)->appOrders()->invoicePdf($orderId); // the invoice PDF (binary)
```
The net amount actually billed for a subscription is the sum of `net_amount` of its lines
(discount lines are negative). `invoicePdf()` throws `ApiException` with 404 `invoice_not_found`
before the order is paid and 409 `invoice_not_ready` while the PDF is still being rendered
(usually seconds after the payment; try again later). A collective order can also bill
subscriptions of other apps of the same user and group.
### Checkout sessions (the user confirms several subscriptions at once) ### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
+31
View File
@@ -469,6 +469,14 @@ arrive more than once or out of order: use `$event['id']` to skip duplicates, an
object through the API when the current state matters. The admin panel can send a object through the API when the current state matters. The admin panel can send a
`webhook.test` event to check your endpoint. `webhook.test` event to check your endpoint.
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent for every period after the
first that was booked on the user's monthly order, with `$event['data']['period']`:
`{subscription_id, period_start, period_end, periods, unit, order_id, order_item_id, items,
currency_iso, net_amount}` (`net_amount` after a discount). Hourly subscriptions renew every hour,
so this event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
`order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token).
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -630,6 +638,29 @@ Each order has `status` (`processing` = current month, still collecting; `pendin
paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null =
no more automatic retries) and `last_payment_error`. no more automatic retries) and `last_payment_error`.
Orders also list their lines and their invoice, e.g. to show a team's invoices or to attribute
amounts (commissions) per subscription:
```php
$order = app(AnikeenId::class)->appOrders()->find($orderId);
foreach ($order->items as $item) {
$item->subscription_id; // null for lines that bill no subscription
$item->period_start; $item->period_end; // ISO 8601, the end is exclusive; null without a period
$item->net_amount; // units × price, rounded to cents
$item->discount; // {label, percent_off, amount_off} on a discount line, else null
}
$order->invoice; // null until paid, then {id, number, status, …, pdf_url}
$pdf = app(AnikeenId::class)->appOrders()->invoicePdf($orderId); // the invoice PDF (binary)
```
The net amount actually billed for a subscription is the sum of `net_amount` of its lines
(discount lines are negative). `invoicePdf()` throws `ApiException` with 404 `invoice_not_found`
before the order is paid and 409 `invoice_not_ready` while the PDF is still being rendered
(usually seconds after the payment; try again later). A collective order can also bill
subscriptions of other apps of the same user and group.
### Checkout sessions (the user confirms several subscriptions at once) ### Checkout sessions (the user confirms several subscriptions at once)
Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID,
+30 -3
View File
@@ -13,9 +13,20 @@ use Illuminate\Database\Eloquent\Model;
* scope orders:client) - of one user ($user->appOrders()) or of all users * scope orders:client) - of one user ($user->appOrders()) or of all users
* (app(AnikeenId::class)->appOrders()). Nothing is requested until a method is called. * (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), * Each order is an object with id, user_id, group, metadata, status
* is_collective, net_total, vat_total, gross_total, currency_iso, created_at, paid_at, * (processing|pending|completed|cancelled), is_collective, net_total, vat_total, gross_total,
* payment_attempts, next_payment_attempt_at and last_payment_error ({code, message} or null). * currency_iso, created_at, paid_at, payment_attempts, next_payment_attempt_at,
* last_payment_error ({code, message} or null), and
*
* - items: all lines of the order, each {id, name, description, type, image_url, units, unit,
* price, vat_rate, subscription_id, meter, monthly_cap, discount, period_start, period_end,
* net_amount, vat_amount, gross_amount}. period_end is exclusive (the start of the next
* period); both are null for lines without a period. discount ({label, percent_off,
* amount_off}) marks the negative line of a subscription's discount, else null. The net amount
* a subscription was billed is the sum of net_amount of its lines.
* - invoice: null until the order is paid, then {id, number, type, status (pending while the PDF
* is being rendered, then completed), service_period_from, service_period_to, created_at,
* pdf_url}; see invoicePdf().
*/ */
class AppOrders class AppOrders
{ {
@@ -81,6 +92,22 @@ class AppOrders
return !$this->billable || $order->user_id === $this->userId() ? $order : null; return !$this->billable || $order->user_id === $this->userId() ? $order : null;
} }
/**
* The PDF of an order's invoice (binary string, with the embedded e-invoice), the same as its
* `invoice.pdf_url` with the app's token.
*
* @throws ApiException 404 "order_not_found", 404 "invoice_not_found" (not paid yet),
* 409 "invoice_not_ready" (still being rendered, try again later)
*/
public function invoicePdf(string $orderId): string
{
if ($this->billable && !$this->find($orderId)) {
throw new ApiException('Order not found.', 404, 'order_not_found');
}
return (string) AppRequest::send('GET', sprintf('v1/client/orders/%s/invoice', $orderId))->response()?->getBody();
}
private function userId(): string private function userId(): string
{ {
return (new AppSubscriptions($this->billable))->userId(); return (new AppSubscriptions($this->billable))->userId();
+6
View File
@@ -33,6 +33,12 @@ final class EventType
*/ */
public const SUBSCRIPTION_UPDATED = 'subscription.updated'; public const SUBSCRIPTION_UPDATED = 'subscription.updated';
/**
* A new period of a running subscription was booked on its collective order; `data.period`
* describes it. Opt-in: only endpoints that select this event get it (not through `*`).
*/
public const SUBSCRIPTION_RENEWED = 'subscription.renewed';
/** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */ /** The user confirmed a checkout session: all of its subscriptions are running (sent after their subscription.activated). */
public const CHECKOUT_COMPLETED = 'checkout.completed'; public const CHECKOUT_COMPLETED = 'checkout.completed';
+63
View File
@@ -4,6 +4,8 @@ namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable; use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class AppOrdersTest extends TestCase class AppOrdersTest extends TestCase
@@ -68,4 +70,65 @@ class AppOrdersTest extends TestCase
$this->assertNull($orders->find('o1')); $this->assertNull($orders->find('o1'));
$this->assertSame('o1', $orders->find('o1')->id); $this->assertSame('o1', $orders->find('o1')->id);
} }
public function test_orders_list_their_lines_and_invoice(): void
{
$this->respond(200, ['data' => [
'id' => 'o1',
'user_id' => '01JUSER',
'group' => 'team-1',
'metadata' => ['topup' => '7'],
'items' => [
['id' => 'i1', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => 10, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => 10, 'discount' => null],
['id' => 'i2', 'subscription_id' => '01JSUB', 'units' => 1, 'price' => -2, 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'net_amount' => -2, 'discount' => ['label' => 'SUMMER20', 'percent_off' => 20, 'amount_off' => null]],
],
'invoice' => ['id' => 'inv1', 'number' => 'RE-2026-0001', 'status' => 'completed', 'pdf_url' => 'https://id.test/api/v1/client/orders/o1/invoice'],
]]);
$order = $this->owner()->appOrders()->find('o1');
$this->assertSame('7', $order->metadata->topup);
$this->assertSame(8, array_sum(array_map(fn ($item) => $item->net_amount, array_filter($order->items, fn ($item) => $item->subscription_id === '01JSUB'))));
$this->assertSame('SUMMER20', $order->items[1]->discount->label);
$this->assertSame('RE-2026-0001', $order->invoice->number);
}
public function test_the_invoice_pdf_is_downloaded_with_the_app_token(): void
{
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => '01JUSER']]);
$this->http->append(new Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7 invoice'));
$this->assertSame('%PDF-1.7 invoice', app(AnikeenId::class)->appOrders()->invoicePdf('o1'));
$this->assertSame('%PDF-1.7 invoice', $this->owner()->appOrders()->invoicePdf('o1'));
$calls = array_map(fn ($entry) => $entry['request']->getMethod() . ' ' . $entry['request']->getUri()->getPath() . ' ' . $entry['request']->getHeaderLine('Authorization'), $this->sent);
$this->assertSame([
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
'GET /api/v1/client/orders/o1 Bearer app-token',
'GET /api/v1/client/orders/o1/invoice Bearer app-token',
], $calls);
}
public function test_an_invoice_that_is_not_ready_is_a_conflict(): void
{
$this->respond(409, ['message' => 'The invoice document is still being created.', 'error' => 'invoice_not_ready']);
$this->respond(200, ['data' => ['id' => 'o1', 'user_id' => 'someone-else']]);
try {
app(AnikeenId::class)->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isConflict());
$this->assertSame('invoice_not_ready', $e->error);
}
try {
$this->owner()->appOrders()->invoicePdf('o1');
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('order_not_found', $e->error);
}
}
} }
+27
View File
@@ -3,6 +3,7 @@
namespace Anikeen\Id\Tests; namespace Anikeen\Id\Tests;
use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Exceptions\SignatureVerificationException;
use Anikeen\Id\Webhooks\EventType;
use Anikeen\Id\Webhooks\Webhook; use Anikeen\Id\Webhooks\Webhook;
class WebhookTest extends TestCase class WebhookTest extends TestCase
@@ -53,4 +54,30 @@ class WebhookTest extends TestCase
Webhook::constructEvent($this->payload, null, $this->secret); Webhook::constructEvent($this->payload, null, $this->secret);
} }
public function test_a_renewal_carries_the_booked_period(): void
{
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_RENEWED, 'created' => time(), 'data' => [
'object' => ['id' => '01JSUB', 'status' => 'active'],
'period' => ['object' => 'subscription_period', 'subscription_id' => '01JSUB', 'period_start' => '2026-11-01T00:00:00+00:00', 'period_end' => '2026-12-01T00:00:00+00:00', 'periods' => 1, 'unit' => 'month', 'order_id' => '01JORD', 'order_item_id' => '01JITEM', 'items' => [], 'currency_iso' => 'EUR', 'net_amount' => 8],
]]);
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
$this->assertSame('subscription.renewed', $event['type']);
$this->assertSame('01JORD', $event['data']['period']['order_id']);
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void
{
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [
'object' => ['id' => '01JSUB', 'price' => 14.99],
'previous_attributes' => ['price' => 9.99],
]]);
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
$this->assertSame(9.99, $event['data']['previous_attributes']['price']);
}
} }