feat(stripe): create billing portal session (#3625)

* feat(stripe) : add billing portal session endpoint

* create billing portal session

* update docs

* chore: changeset

* chore: fix stripe

* chore: remove comment

* chore: update docs

---------

Co-authored-by: Bereket Engida <Bekacru@gmail.com>
Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
This commit is contained in:
dogus
2025-08-07 02:09:32 +03:00
committed by GitHub
parent 9c4a7b5d96
commit b5273623bf
5 changed files with 138 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@better-auth/stripe": patch
---
A new API endpoint to create Stripe billing portal sessions, allowing users to manage subscriptions, payment methods, and billing history

View File

@@ -384,6 +384,31 @@ This will reactivate a subscription that was previously set to cancel at the end
> **Note:** This only works for subscriptions that are still active but marked to cancel at the end of the period. It cannot restore subscriptions that have already ended.
#### Creating Billing Portal Sessions
To create a [Stripe billing portal session](https://docs.stripe.com/api/customer_portal/sessions/create) where customers can manage their subscriptions, update payment methods, and view billing history:
<APIMethod
path="/subscription/billing-portal"
method="POST"
requireSession
>
```ts
type createBillingPortal = {
/**
* Reference id of the subscription to upgrade.
*/
referenceId?: string = "123"
/**
* Return URL to redirect back after successful subscription.
*/
returnUrl?: string
}
```
</APIMethod>
This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history.
### Reference System
By default, subscriptions are associated with the user ID. However, you can use a custom reference ID to associate subscriptions with other entities, such as organizations:
@@ -822,4 +847,4 @@ For local development, you can use the Stripe CLI to forward webhooks to your lo
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook
```
This will provide you with a webhook signing secret that you can use in your local environment.
This will provide you with a webhook signing secret that you can use in your local environment.

View File

@@ -69,7 +69,8 @@ export const stripe = <O extends StripeOptions>(options: O) => {
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
| "restore-subscription",
| "restore-subscription"
| "billing-portal",
) =>
createAuthMiddleware(async (ctx) => {
const session = ctx.context.session;
@@ -1033,6 +1034,73 @@ export const stripe = <O extends StripeOptions>(options: O) => {
throw ctx.redirect(getUrl(ctx, callbackURL));
},
),
createBillingPortal: createAuthEndpoint(
"/subscription/billing-portal",
{
method: "POST",
body: z.object({
referenceId: z.string().optional(),
returnUrl: z.string().default("/"),
}),
use: [
sessionMiddleware,
originCheck((ctx) => ctx.body.returnUrl),
referenceMiddleware("billing-portal"),
],
},
async (ctx) => {
const { user } = ctx.context.session;
const referenceId = ctx.body.referenceId || user.id;
let customerId = user.stripeCustomerId;
if (!customerId) {
const subscription = await ctx.context.adapter
.findMany<Subscription>({
model: "subscription",
where: [
{
field: "referenceId",
value: referenceId,
},
],
})
.then((subs) =>
subs.find(
(sub) => sub.status === "active" || sub.status === "trialing",
),
);
customerId = subscription?.stripeCustomerId;
}
if (!customerId) {
throw new APIError("BAD_REQUEST", {
message: "No Stripe customer found for this user",
});
}
try {
const { url } = await client.billingPortal.sessions.create({
customer: customerId,
return_url: getUrl(ctx, ctx.body.returnUrl),
});
return ctx.json({
url,
redirect: true,
});
} catch (error: any) {
ctx.context.logger.error(
"Error creating billing portal session",
error,
);
throw new APIError("BAD_REQUEST", {
message: error.message,
});
}
},
),
} as const;
return {
id: "stripe",

View File

@@ -844,4 +844,40 @@ describe("stripe", async () => {
expect(mockStripe.customers.create).toHaveBeenCalledTimes(1);
});
it("should create billing portal session", async () => {
await authClient.signUp.email(
{
...testUser,
email: "billing-portal@email.com",
},
{
throw: true,
},
);
const headers = new Headers();
await authClient.signIn.email(
{
...testUser,
email: "billing-portal@email.com",
},
{
throw: true,
onSuccess: setCookieToHeader(headers),
},
);
const billingPortalRes = await authClient.subscription.billingPortal({
returnUrl: "/dashboard",
fetchOptions: {
headers,
},
});
expect(billingPortalRes.data?.url).toBe("https://billing.stripe.com/mock");
expect(billingPortalRes.data?.redirect).toBe(true);
expect(mockStripe.billingPortal.sessions.create).toHaveBeenCalledWith({
customer: expect.any(String),
return_url: "http://localhost:3000/dashboard",
});
});
});

View File

@@ -274,7 +274,8 @@ export interface StripeOptions {
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
| "restore-subscription";
| "restore-subscription"
| "billing-portal";
},
ctx: GenericEndpointContext,
) => Promise<boolean>;