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