mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-05 15:19:31 +00:00
Document first_period on subscription.renewed and login-free billing portal links
- subscription.renewed is now sent for the first paid period too; data.period.first_period tells it apart (README, EventType, test payload). - Billing portal links need no login, are single use and valid for about 15 minutes: create them on click and redirect right away (README, createBillingPortalLinkForUser(), appBillingPortalUrl()).
This commit is contained in:
6 files changed
+71
-33
No files matched your search
@@ -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
|
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
|
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked
|
||||||
first that was booked on the user's monthly order, with `$event['data']['period']`:
|
on the user's monthly order, **the first one included**, with `$event['data']['period']`:
|
||||||
`{subscription_id, period_start, period_end, periods, unit, order_id, order_item_id, items,
|
`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id,
|
||||||
currency_iso, net_amount}` (`net_amount` after a discount). Hourly subscriptions renew every hour,
|
items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the
|
||||||
so this event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
|
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
|
`order.*` events list the order's `items` and `invoice` like
|
||||||
[app orders](#app-orders-trusted-apps-no-user-token).
|
[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)
|
### 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
|
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
|
your app bills): create a link to the user's billing pages on Anikeen ID with the app's token
|
||||||
app's token (scope `subscriptions:client` or `orders:client`):
|
(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works
|
||||||
|
**without a login**:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::post('teams/{team}/billing', function (Team $team) {
|
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}
|
// {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
|
The link is **single use** and valid for about 15 minutes: create it when the user clicks and
|
||||||
store it. It doesn't sign anyone in: the user logs in as usual and then sees only what bills your
|
redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a
|
||||||
app's subscriptions (of the `group`), with "Back to *app*" and "Show everything".
|
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_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,
|
payment) and back to `return_url`. For the signed-in user with their own token,
|
||||||
`$user->billingPortalUrl()` stays available.
|
`$user->billingPortalUrl()` stays available.
|
||||||
|
|||||||
+26
-10
@@ -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
|
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
|
`subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked
|
||||||
first that was booked on the user's monthly order, with `$event['data']['period']`:
|
on the user's monthly order, **the first one included**, with `$event['data']['period']`:
|
||||||
`{subscription_id, period_start, period_end, periods, unit, order_id, order_item_id, items,
|
`{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id,
|
||||||
currency_iso, net_amount}` (`net_amount` after a discount). Hourly subscriptions renew every hour,
|
items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the
|
||||||
so this event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it.
|
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
|
`order.*` events list the order's `items` and `invoice` like
|
||||||
[app orders](#app-orders-trusted-apps-no-user-token).
|
[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)
|
### 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
|
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
|
your app bills): create a link to the user's billing pages on Anikeen ID with the app's token
|
||||||
app's token (scope `subscriptions:client` or `orders:client`):
|
(scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works
|
||||||
|
**without a login**:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
Route::post('teams/{team}/billing', function (Team $team) {
|
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}
|
// {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
|
The link is **single use** and valid for about 15 minutes: create it when the user clicks and
|
||||||
store it. It doesn't sign anyone in: the user logs in as usual and then sees only what bills your
|
redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a
|
||||||
app's subscriptions (of the `group`), with "Back to *app*" and "Show everything".
|
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_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,
|
payment) and back to `return_url`. For the signed-in user with their own token,
|
||||||
`$user->billingPortalUrl()` stays available.
|
`$user->billingPortalUrl()` stays available.
|
||||||
|
|||||||
@@ -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
|
* A link to the user's billing pages on Anikeen ID that needs no login, created with the app's
|
||||||
* token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a "Manage
|
* own token (trusted apps only, scope subscriptions:client or orders:client), e.g. for a
|
||||||
* billing" button; see AnikeenId::createBillingPortalLinkForUser() for the attributes
|
* "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.
|
* (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
|
* @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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"
|
* A link to a user's billing pages on Anikeen ID that needs no login, like Stripe's customer
|
||||||
* button of an app that bills another user's account (a team owner) without that user's
|
* portal, e.g. for a "Manage billing" button of an app that bills another user's account (a
|
||||||
* token (trusted apps, scope subscriptions:client or orders:client). The link is signed and
|
* team owner) without that user's token (trusted apps, scope subscriptions:client or
|
||||||
* valid for about 15 minutes: create it when the user clicks, don't store it. It doesn't sign
|
* orders:client). The link is single use and valid for about 15 minutes: create it when the
|
||||||
* anyone in; the pages then show only what bills the app's subscriptions (of the group) and
|
* user clicks and redirect right away, don't store it. It opens a portal session for that user
|
||||||
* lead back to return_url.
|
* 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 string $userId The user's Anikeen ID.
|
||||||
* @param array{
|
* @param array{
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ 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`
|
* A period of a subscription was booked on its collective order, the first one included;
|
||||||
* describes it. Opt-in: only endpoints that select this event get it (not through `*`).
|
* `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';
|
public const SUBSCRIPTION_RENEWED = 'subscription.renewed';
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,14 @@ class WebhookTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_RENEWED, 'created' => time(), 'data' => [
|
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_RENEWED, 'created' => time(), 'data' => [
|
||||||
'object' => ['id' => '01JSUB', 'status' => 'active'],
|
'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);
|
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
|
||||||
|
|
||||||
$this->assertSame('subscription.renewed', $event['type']);
|
$this->assertSame('subscription.renewed', $event['type']);
|
||||||
$this->assertSame('01JORD', $event['data']['period']['order_id']);
|
$this->assertSame('01JORD', $event['data']['period']['order_id']);
|
||||||
|
$this->assertTrue($event['data']['period']['first_period']);
|
||||||
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
|
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user