From b5273623bfbe48392b28499b968c2c477a03087d Mon Sep 17 00:00:00 2001 From: dogus <147812199+rhitune2@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:09:32 +0300 Subject: [PATCH] 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 Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> --- .changeset/kind-rice-poke.md | 5 ++ docs/content/docs/plugins/stripe.mdx | 27 ++++++++++- packages/stripe/src/index.ts | 70 +++++++++++++++++++++++++++- packages/stripe/src/stripe.test.ts | 36 ++++++++++++++ packages/stripe/src/types.ts | 3 +- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 .changeset/kind-rice-poke.md diff --git a/.changeset/kind-rice-poke.md b/.changeset/kind-rice-poke.md new file mode 100644 index 0000000000..e2d7143259 --- /dev/null +++ b/.changeset/kind-rice-poke.md @@ -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 diff --git a/docs/content/docs/plugins/stripe.mdx b/docs/content/docs/plugins/stripe.mdx index 48720580a0..8be9620fcc 100644 --- a/docs/content/docs/plugins/stripe.mdx +++ b/docs/content/docs/plugins/stripe.mdx @@ -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: + + +```ts +type createBillingPortal = { + /** + * Reference id of the subscription to upgrade. + */ + referenceId?: string = "123" + /** + * Return URL to redirect back after successful subscription. + */ + returnUrl?: string +} +``` + + +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. \ No newline at end of file diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 54a0017f6e..283ffb35e5 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -69,7 +69,8 @@ export const stripe = (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 = (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({ + 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", diff --git a/packages/stripe/src/stripe.test.ts b/packages/stripe/src/stripe.test.ts index 785bf0ba11..2af96b4d32 100644 --- a/packages/stripe/src/stripe.test.ts +++ b/packages/stripe/src/stripe.test.ts @@ -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", + }); + }); }); diff --git a/packages/stripe/src/types.ts b/packages/stripe/src/types.ts index 2130ae79b3..11df6eee5a 100644 --- a/packages/stripe/src/types.ts +++ b/packages/stripe/src/types.ts @@ -274,7 +274,8 @@ export interface StripeOptions { | "upgrade-subscription" | "list-subscription" | "cancel-subscription" - | "restore-subscription"; + | "restore-subscription" + | "billing-portal"; }, ctx: GenericEndpointContext, ) => Promise;