Create billing portal links for users as a trusted app

AnikeenId::createBillingPortalLinkForUser() and $user->appBillingPortalUrl()
create a short-lived link to a user's billing pages with the app's token
(return_url, group, flow), e.g. for a Manage billing button of an app
that bills a team owner's account.
This commit is contained in:
Maurice Preuß committed 2026-11-04 21:27:26 +01:00
1 parent e45a871ca7
commit 35ca9ddd2b
5 files changed
+134

No files matched your search

+29
View File
@@ -688,6 +688,33 @@ before the order is paid and 409 `invoice_not_ready` while the PDF is still bein
(usually seconds after the payment; try again later). A collective order can also bill (usually seconds after the payment; try again later). A collective order can also bill
subscriptions of other apps of the same user and group. 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`):
```php
Route::post('teams/{team}/billing', function (Team $team) {
return redirect()->away($team->owner->appBillingPortalUrl([
'return_url' => route('teams.show', $team), // "Back to <app>"; same origin as one of your redirect URIs
'group' => $team->id, // only this group's subscriptions, orders and invoices
'flow' => 'invoices', // overview (default), payment_method, payment_method_update,
// invoices, subscriptions or orders
]));
});
app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payment_method_update']);
// {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".
`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.
### 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,
@@ -941,6 +968,7 @@ public function shippingPriceList(): Result
```php ```php
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appCheckoutSessions(): AppCheckoutSessions public function appCheckoutSessions(): AppCheckoutSessions
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
public function createOrderForUser(string $userId, array $attributes): Result public function createOrderForUser(string $userId, array $attributes): Result
public function createTransactionForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result
``` ```
@@ -1046,6 +1074,7 @@ public function appSubscriptions(): AppSubscriptions
public function appCheckoutSessions(): AppCheckoutSessions public function appCheckoutSessions(): AppCheckoutSessions
public function checkoutSessions(): CheckoutSessions public function checkoutSessions(): CheckoutSessions
public function appOrders(): AppOrders public function appOrders(): AppOrders
public function appBillingPortalUrl(array $attributes = []): string
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
+27
View File
@@ -688,6 +688,33 @@ before the order is paid and 409 `invoice_not_ready` while the PDF is still bein
(usually seconds after the payment; try again later). A collective order can also bill (usually seconds after the payment; try again later). A collective order can also bill
subscriptions of other apps of the same user and group. 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`):
```php
Route::post('teams/{team}/billing', function (Team $team) {
return redirect()->away($team->owner->appBillingPortalUrl([
'return_url' => route('teams.show', $team), // "Back to <app>"; same origin as one of your redirect URIs
'group' => $team->id, // only this group's subscriptions, orders and invoices
'flow' => 'invoices', // overview (default), payment_method, payment_method_update,
// invoices, subscriptions or orders
]));
});
app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payment_method_update']);
// {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".
`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.
### 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,
+17
View File
@@ -2,6 +2,8 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Resources\AppSubscriptions; use Anikeen\Id\Resources\AppSubscriptions;
@@ -65,4 +67,19 @@ trait ManagesSubscriptions
{ {
return new AppOrders($this); return new AppOrders($this);
} }
/**
* 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.
*
* @param array{return_url?: null|string, group?: null|string, flow?: null|string} $attributes
*
* @throws ApiException
*/
public function appBillingPortalUrl(array $attributes = []): string
{
return app(AnikeenId::class)->createBillingPortalLinkForUser($this->appSubscriptions()->userId(), $attributes)->url;
}
} }
+31
View File
@@ -2,9 +2,11 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppCheckoutSessions;
use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppOrders;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
/** /**
@@ -29,6 +31,35 @@ trait ManagesUserBilling
return new AppCheckoutSessions(); return new AppCheckoutSessions();
} }
/**
* 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.
*
* @param string $userId The user's Anikeen ID.
* @param array{
* return_url?: null|string,
* group?: null|string,
* flow?: null|string
* } $attributes return_url: "Back to the app" (same origin as one of the app's redirect URIs,
* 422 otherwise).
* group: only this group's subscriptions and the orders, invoices and
* transactions billing them.
* flow: overview (default), payment_method, payment_method_update (straight to
* replacing the payment method, e.g. after a failed payment), invoices,
* subscriptions or orders.
* @return object {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at}
*
* @throws ApiException 403 untrusted app or missing scope, 404 unknown user, 422 validation
*/
public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object
{
return AppRequest::send('POST', sprintf('v1/client/users/%s/billing-portal', $userId), $attributes)->data;
}
/** /**
* Create an order for a user (VAT from the billing address). With `checkout`, it is paid right * Create an order for a user (VAT from the billing address). With `checkout`, it is paid right
* away: with the given `transaction` (a payment captured elsewhere) or by charging the user's * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's
+30
View File
@@ -4,6 +4,7 @@ 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 Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -199,6 +200,35 @@ class EndpointsTest extends TestCase
$this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1)); $this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1));
} }
public function test_billing_portal_links_for_users_use_the_app_token(): void
{
$link = ['object' => 'billing_portal_link', 'url' => 'https://id.test/billing/portal/01JUSER?signature=x', 'user_id' => '01JUSER', 'flow' => 'invoices', 'group' => 'team-1', 'return_url' => 'https://console.test/billing', 'expires_at' => '2026-11-04T10:15:00+00:00'];
$this->respond(201, ['data' => $link]);
$this->respond(201, ['data' => $link]);
$created = app(AnikeenId::class)->createBillingPortalLinkForUser('01JUSER', ['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices']);
$url = $this->owner()->appBillingPortalUrl(['flow' => 'payment_method_update']);
$this->assertSame('billing_portal_link', $created->object);
$this->assertSame($link['url'], $url);
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(0));
$this->assertSame(['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices'], $this->sentJson(0));
$this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(1));
$this->assertSame(['flow' => 'payment_method_update'], $this->sentJson(1));
}
public function test_a_refused_billing_portal_link_is_an_api_exception(): void
{
$this->respond(422, ['message' => 'The return URL has to point to one of the app\'s redirect URIs.', 'errors' => ['return_url' => ['…']]]);
try {
$this->owner()->appBillingPortalUrl(['return_url' => 'https://elsewhere.test']);
$this->fail('Expected an ApiException.');
} catch (ApiException $e) {
$this->assertSame(422, $e->status);
}
}
public function test_order_preview_maps_the_country_into_the_billing_address(): void public function test_order_preview_maps_the_country_into_the_billing_address(): void
{ {
$this->respond(200, ['data' => ['gross_total' => 11.9]]); $this->respond(200, ['data' => ['gross_total' => 11.9]]);