diff --git a/README.md b/README.md index 9da79b2..18cc381 100644 --- a/README.md +++ b/README.md @@ -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 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 "; 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) 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 public function appOrders(): AppOrders public function appCheckoutSessions(): AppCheckoutSessions +public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object public function createOrderForUser(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 checkoutSessions(): CheckoutSessions public function appOrders(): AppOrders +public function appBillingPortalUrl(array $attributes = []): string public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` diff --git a/README.stub b/README.stub index 6158682..f812265 100644 --- a/README.stub +++ b/README.stub @@ -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 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 "; 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) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, diff --git a/src/Id/Concerns/ManagesSubscriptions.php b/src/Id/Concerns/ManagesSubscriptions.php index 33c78b4..21198db 100644 --- a/src/Id/Concerns/ManagesSubscriptions.php +++ b/src/Id/Concerns/ManagesSubscriptions.php @@ -2,6 +2,8 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\AnikeenId; +use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Resources\AppSubscriptions; @@ -65,4 +67,19 @@ trait ManagesSubscriptions { 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; + } } diff --git a/src/Id/Concerns/ManagesUserBilling.php b/src/Id/Concerns/ManagesUserBilling.php index d87b4ff..4020ae5 100644 --- a/src/Id/Concerns/ManagesUserBilling.php +++ b/src/Id/Concerns/ManagesUserBilling.php @@ -2,9 +2,11 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Resources\AppCheckoutSessions; use Anikeen\Id\Resources\AppOrders; use Anikeen\Id\Result; +use Anikeen\Id\Support\AppRequest; use Throwable; /** @@ -29,6 +31,35 @@ trait ManagesUserBilling 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 * away: with the given `transaction` (a payment captured elsewhere) or by charging the user's diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php index 8704144..e2cbb31 100644 --- a/tests/Id/EndpointsTest.php +++ b/tests/Id/EndpointsTest.php @@ -4,6 +4,7 @@ namespace Anikeen\Id\Tests; use Anikeen\Id\AnikeenId; use Anikeen\Id\Billable; +use Anikeen\Id\Exceptions\ApiException; 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)); } + 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 { $this->respond(200, ['data' => ['gross_total' => 11.9]]);