diff --git a/README.md b/README.md index 18cc381..f2a4803 100644 --- a/README.md +++ b/README.md @@ -469,11 +469,22 @@ 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. +`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked +on the user's monthly order, **the first one included**, with `$event['data']['period']`: +`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id, +items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the +subscription's first paid period (the checkout, sent after `subscription.activated`, or the first +period after a free trial) and `false` for every later one (renewals, resuming an expired +subscription, a unit change applied right away). Hourly subscriptions renew every hour, so this +event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it. + +```php +if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) { + $period = $event['data']['period']; + // e.g. a commission per paid period; the first one may count differently + Commission::record($period['subscription_id'], $period['net_amount'], first: $period['first_period']); +} +``` `order.*` events list the order's `items` and `invoice` like [app orders](#app-orders-trusted-apps-no-user-token). @@ -691,8 +702,9 @@ subscriptions of other apps of the same user and group. ### Billing portal links (trusted apps) A "Manage billing" button for a user whose token your app doesn't have (e.g. the owner of a team -your app bills): create a short-lived link to the user's billing pages on Anikeen ID with the -app's token (scope `subscriptions:client` or `orders:client`): +your app bills): create a link to the user's billing pages on Anikeen ID with the app's token +(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works +**without a login**: ```php Route::post('teams/{team}/billing', function (Team $team) { @@ -708,9 +720,13 @@ app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payme // {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at} ``` -The link is signed and valid for about 15 minutes, so create it when the user clicks and don't -store it. It doesn't sign anyone in: the user logs in as usual and then sees only what bills your -app's subscriptions (of the `group`), with "Back to *app*" and "Show everything". +The link is **single use** and valid for about 15 minutes: create it when the user clicks and +redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a +reload, gets a 403). Opening it starts a portal session for that user that needs no login and +signs nobody in: only the billing pages of the `flow`, only what bills your app's subscriptions +(of the `group`), with "Back to *app*". The user's account settings, other apps and OAuth consent +stay behind the normal login; signed in as another account, the portal still shows only the +link's user. The session ends after an hour without activity or on "Back to *app*". `payment_method_update` leads straight to replacing the payment method (e.g. after a failed payment) and back to `return_url`. For the signed-in user with their own token, `$user->billingPortalUrl()` stays available. diff --git a/README.stub b/README.stub index f812265..5de6ebb 100644 --- a/README.stub +++ b/README.stub @@ -469,11 +469,22 @@ 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. +`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked +on the user's monthly order, **the first one included**, with `$event['data']['period']`: +`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id, +items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the +subscription's first paid period (the checkout, sent after `subscription.activated`, or the first +period after a free trial) and `false` for every later one (renewals, resuming an expired +subscription, a unit change applied right away). Hourly subscriptions renew every hour, so this +event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it. + +```php +if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) { + $period = $event['data']['period']; + // e.g. a commission per paid period; the first one may count differently + Commission::record($period['subscription_id'], $period['net_amount'], first: $period['first_period']); +} +``` `order.*` events list the order's `items` and `invoice` like [app orders](#app-orders-trusted-apps-no-user-token). @@ -691,8 +702,9 @@ subscriptions of other apps of the same user and group. ### Billing portal links (trusted apps) A "Manage billing" button for a user whose token your app doesn't have (e.g. the owner of a team -your app bills): create a short-lived link to the user's billing pages on Anikeen ID with the -app's token (scope `subscriptions:client` or `orders:client`): +your app bills): create a link to the user's billing pages on Anikeen ID with the app's token +(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works +**without a login**: ```php Route::post('teams/{team}/billing', function (Team $team) { @@ -708,9 +720,13 @@ app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payme // {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at} ``` -The link is signed and valid for about 15 minutes, so create it when the user clicks and don't -store it. It doesn't sign anyone in: the user logs in as usual and then sees only what bills your -app's subscriptions (of the `group`), with "Back to *app*" and "Show everything". +The link is **single use** and valid for about 15 minutes: create it when the user clicks and +redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a +reload, gets a 403). Opening it starts a portal session for that user that needs no login and +signs nobody in: only the billing pages of the `flow`, only what bills your app's subscriptions +(of the `group`), with "Back to *app*". The user's account settings, other apps and OAuth consent +stay behind the normal login; signed in as another account, the portal still shows only the +link's user. The session ends after an hour without activity or on "Back to *app*". `payment_method_update` leads straight to replacing the payment method (e.g. after a failed payment) and back to `return_url`. For the signed-in user with their own token, `$user->billingPortalUrl()` stays available. diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php index 21198db..6a6b302 100644 --- a/src/Id/Concerns/ManagesSubscriptions.php +++ b/src/Id/Concerns/ManagesSubscriptions.php @@ -69,10 +69,11 @@ trait ManagesSubscriptions } /** - * A short-lived link to the user's billing pages on Anikeen ID, created with the app's own - * token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a "Manage - * billing" button; see AnikeenId::createBillingPortalLinkForUser() for the attributes - * (return_url, group, flow). Create it when the user clicks, it expires after about 15 minutes. + * A link to the user's billing pages on Anikeen ID that needs no login, created with the app's + * own token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a + * "Manage billing" button; see AnikeenId::createBillingPortalLinkForUser() for the attributes + * (return_url, group, flow). Single use and valid for about 15 minutes: create it when the + * user clicks and redirect right away. * * @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes * diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php index 4020ae5..8d9986e 100644 --- a/src/Id/Concerns/ManagesUserBilling.php +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -32,12 +32,14 @@ trait ManagesUserBilling } /** - * A short-lived link to a user's billing pages on Anikeen ID, e.g. for a "Manage billing" - * button of an app that bills another user's account (a team owner) without that user's - * token (trusted apps, scope subscriptions:client or orders:client). The link is signed and - * valid for about 15 minutes: create it when the user clicks, don't store it. It doesn't sign - * anyone in; the pages then show only what bills the app's subscriptions (of the group) and - * lead back to return_url. + * A link to a user's billing pages on Anikeen ID that needs no login, like Stripe's customer + * portal, e.g. for a "Manage billing" button of an app that bills another user's account (a + * team owner) without that user's token (trusted apps, scope subscriptions:client or + * orders:client). The link is single use and valid for about 15 minutes: create it when the + * user clicks and redirect right away, don't store it. It opens a portal session for that user + * only (no login, nobody signed in) with only the flow's billing pages and only what bills the + * app's subscriptions (of the group), leading back to return_url; it ends after an hour + * without activity. * * @param string $userId The user's Anikeen ID. * @param array{ diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index e5a05fd..dd9a83a 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -34,8 +34,10 @@ 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 `*`). + * A period of a subscription was booked on its collective order, the first one included; + * `data.period` describes it, `data.period.first_period` is true for the subscription's first + * paid period (checkout or the end of a free trial). Opt-in: only endpoints that select this + * event get it (not through `*`). */ public const SUBSCRIPTION_RENEWED = 'subscription.renewed'; diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php index 851f82d..d9e75b0 100644 --- a/tests/Id/WebhookTest.php +++ b/tests/Id/WebhookTest.php @@ -59,13 +59,14 @@ class WebhookTest extends TestCase { $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], + '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, 'first_period' => true, '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->assertTrue($event['data']['period']['first_period']); $this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all()); }