diff --git a/README.md b/README.md index cc8f690..97d029f 100644 --- a/README.md +++ b/README.md @@ -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 `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 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 = 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) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, diff --git a/README.stub b/README.stub index df8e484..e101a24 100644 --- a/README.stub +++ b/README.stub @@ -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 `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 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 = 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) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, diff --git a/src/Id/Resources/AppOrders.php b/src/Id/Resources/AppOrders.php index 0457e2c..9d3b1ae 100644 --- a/src/Id/Resources/AppOrders.php +++ b/src/Id/Resources/AppOrders.php @@ -13,9 +13,20 @@ use Illuminate\Database\Eloquent\Model; * 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). + * Each order is an object with id, user_id, group, metadata, 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, + * 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 { @@ -81,6 +92,22 @@ class AppOrders 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 { return (new AppSubscriptions($this->billable))->userId(); diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index d394cb9..e5a05fd 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -33,6 +33,12 @@ final class EventType */ 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). */ public const CHECKOUT_COMPLETED = 'checkout.completed'; diff --git a/tests/Id/AppOrdersTest.php b/tests/Id/AppOrdersTest.php index 9bcb0a0..e88e600 100644 --- a/tests/Id/AppOrdersTest.php +++ b/tests/Id/AppOrdersTest.php @@ -4,6 +4,8 @@ namespace Anikeen\Id\Tests; use Anikeen\Id\AnikeenId; use Anikeen\Id\Billable; +use Anikeen\Id\Exceptions\ApiException; +use GuzzleHttp\Psr7\Response; use Illuminate\Database\Eloquent\Model; class AppOrdersTest extends TestCase @@ -68,4 +70,65 @@ class AppOrdersTest extends TestCase $this->assertNull($orders->find('o1')); $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); + } + } } diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php index ab0bb88..851f82d 100644 --- a/tests/Id/WebhookTest.php +++ b/tests/Id/WebhookTest.php @@ -3,6 +3,7 @@ namespace Anikeen\Id\Tests; use Anikeen\Id\Exceptions\SignatureVerificationException; +use Anikeen\Id\Webhooks\EventType; use Anikeen\Id\Webhooks\Webhook; class WebhookTest extends TestCase @@ -53,4 +54,30 @@ class WebhookTest extends TestCase 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']); + } }