mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 22:59:41 +00:00
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:
6 files changed
+188
-3
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user