feat(stripe): pass context obejct to stripe plugin callbacks (#2990)

* feat(stripe): pass context obejct to stripe plugin callbacks

* cleanup

* cleanup

* cleanup

* chore: lint

* fix: tests

---------

Co-authored-by: ping-maxwell <maxwell.multinite@gmail.com>
This commit is contained in:
Bereket Engida
2025-07-17 14:48:11 -07:00
committed by GitHub
parent 78e384e3f5
commit 8fa4c9ce7e
5 changed files with 201 additions and 151 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Fragment, useEffect, useId, useState } from "react"; import { useEffect, useId, useState } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import Link from "next/link"; import Link from "next/link";
import clsx from "clsx"; import clsx from "clsx";
@@ -207,131 +207,161 @@ function CodePreview() {
<MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}> <MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}>
<motion.div <motion.div
animate={{ height: height > 0 ? height : undefined }} animate={{ height: height > 0 ? height : undefined }}
className="from-stone-100 to-stone-200 dark:to-black/90 dark:via-stone-950/10 dark:from-stone-950/90 relative overflow-hidden rounded-sm bg-gradient-to-tr ring-1 ring-white/10 backdrop-blur-lg" className="relative overflow-hidden rounded-xl"
> >
<div ref={ref}> {/* Dynamic background based on theme */}
<div className="absolute -top-px left-0 right-0 h-px" /> <div
<div className="absolute -bottom-px left-11 right-20 h-px" /> className="absolute inset-0 rounded-xl"
<div className="pl-4 pt-4"> style={{
<TrafficLightsIcon className="stroke-slate-500/30 h-2.5 w-auto" /> background:
theme.resolvedTheme === "light"
? "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.6) 100%)"
: "linear-gradient(135deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.8) 100%)",
}}
/>
<div className="mt-4 flex space-x-2 text-xs"> {/* Glass layers - responsive to theme */}
{tabs.map((tab) => ( <div className="absolute inset-0 rounded-xl backdrop-blur-3xl bg-gradient-to-br dark:from-black/40 dark:via-black/20 dark:to-black/60 light:from-white/30 light:via-white/10 light:to-white/40" />
<button <div className="absolute inset-0 rounded-xl bg-gradient-to-tl dark:from-slate-800/20 dark:via-transparent dark:to-slate-700/15 light:from-blue-500/10 light:via-transparent light:to-purple-500/10" />
key={tab.name} <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-transparent dark:via-black/10 light:via-white/15 to-transparent" />
onClick={() => setCurrentTab(tab.name)}
className={clsx(
"relative isolate flex h-6 cursor-pointer items-center justify-center rounded-full px-2.5",
currentTab === tab.name
? "text-stone-300"
: "text-slate-500",
)}
>
{tab.name}
{tab.name === currentTab && (
<motion.div
layoutId="tab-code-preview"
className="bg-stone-800 absolute inset-0 -z-10 rounded-full"
/>
)}
</button>
))}
</div>
<div className="mt-6 flex flex-col items-start px-1 text-sm"> {/* Border and shadow effects - theme aware */}
<div className="absolute top-2 right-4"> <div className="absolute inset-0 rounded-xl border shadow-2xl dark:border-white/5 dark:shadow-black/50 light:border-black/10 light:shadow-black/20" />
<Button <div className="absolute inset-0 rounded-xl border dark:border-white/[0.02] light:border-black/[0.05]" />
variant="outline"
size="icon" {/* Inner glow - theme specific */}
className="absolute w-5 border-none bg-transparent h-5 top-2 right-0" <div className="absolute inset-0 rounded-xl shadow-inner dark:shadow-black/20 light:shadow-white/30" />
onClick={() => copyToClipboard(code)}
> {/* Liquid glass reflection effect - adaptive */}
{copyState ? ( <div className="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b rounded-t-xl backdrop-blur-sm dark:from-white/[0.03] light:from-black/[0.06] to-transparent" />
<Check className="h-3 w-3" /> <div className="absolute top-0 left-1/4 right-1/4 h-1/6 bg-gradient-to-b rounded-t-xl blur-sm dark:from-white/[0.05] light:from-black/[0.08] to-transparent" />
) : ( <div className="relative z-10 bg-gradient-to-br from-black/98 via-stone-950/95 to-black/98 dark:from-black/98 dark:via-stone-950/95 dark:to-black/98 light:from-white/98 light:via-stone-50/95 light:to-white/98 backdrop-blur-xl rounded-xl border border-white/[0.02] dark:border-white/[0.02] light:border-black/[0.08]">
<Copy className="h-3 w-3" /> <div ref={ref}>
)} <div className="absolute -top-px left-0 right-0 h-px bg-gradient-to-r from-transparent dark:via-white/[0.05] light:via-black/[0.15] to-transparent" />
<span className="sr-only">Copy code</span> <div className="absolute -bottom-px left-11 right-20 h-px bg-gradient-to-r from-transparent dark:via-white/[0.03] light:via-black/[0.10] to-transparent" />
</Button> <div className="pl-4 pt-4">
</div> <TrafficLightsIcon className="stroke-stone-500/30 h-2.5 w-auto" />
<motion.div
initial={{ opacity: 0 }} <div className="mt-4 flex space-x-2 text-xs">
animate={{ opacity: 1 }} {tabs.map((tab) => (
transition={{ duration: 0.5 }} <button
key={currentTab} key={tab.name}
className="relative flex items-start px-1 text-sm" onClick={() => setCurrentTab(tab.name)}
> className={clsx(
<div "relative isolate flex h-6 cursor-pointer items-center justify-center rounded-full px-2.5 transition-all duration-200",
aria-hidden="true" currentTab === tab.name
className="border-slate-300/5 text-slate-600 select-none border-r pr-4 font-mono" ? "dark:text-gray-100 light:text-gray-900"
> : "dark:text-slate-400 dark:hover:text-slate-300 light:text-slate-600 light:hover:text-slate-700",
{Array.from({ )}
length: code.split("\n").length,
}).map((_, index) => (
<Fragment key={index}>
{(index + 1).toString().padStart(2, "0")}
<br />
</Fragment>
))}
</div>
<Highlight
key={theme.resolvedTheme}
code={code}
language={"javascript"}
theme={{
...codeTheme,
plain: {
backgroundColor: "transparent",
},
}}
>
{({
className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre
className={clsx(className, "flex overflow-x-auto pb-6")}
style={style}
>
<code className="px-4">
{tokens.map((line, lineIndex) => (
<div key={lineIndex} {...getLineProps({ line })}>
{line.map((token, tokenIndex) => (
<span
key={tokenIndex}
{...getTokenProps({ token })}
/>
))}
</div>
))}
</code>
</pre>
)}
</Highlight>
</motion.div>
<motion.div layout className="self-end">
<Link
href="https://demo.better-auth.com"
target="_blank"
className="shadow-md border shadow-primary-foreground mb-4 ml-auto mr-4 mt-auto flex cursor-pointer items-center gap-2 px-3 py-1 transition-all ease-in-out hover:opacity-70"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
> >
<path {tab.name}
fill="currentColor" {tab.name === currentTab && (
d="M10 20H8V4h2v2h2v3h2v2h2v2h-2v2h-2v3h-2z" <motion.div
></path> layoutId="tab-code-preview"
</svg> className="absolute inset-0 -z-10 rounded-full backdrop-blur-sm border bg-gradient-to-r dark:from-black/60 dark:to-slate-900/80 dark:border-white/[0.05] light:from-white/60 light:to-slate-100/80 light:border-black/[0.15]"
<p className="text-sm">Demo</p> />
</Link> )}
</motion.div> </button>
))}
</div>
<div className="mt-6 flex flex-col items-start px-1 text-sm">
<div className="absolute top-2 right-4">
<Button
variant="outline"
size="icon"
className="absolute w-5 border-none backdrop-blur-sm h-5 top-2 right-0 transition-all duration-200 dark:bg-black/20 dark:hover:bg-black/40 dark:border-white/[0.05] light:bg-white/20 light:hover:bg-white/40 light:border-black/[0.15]"
onClick={() => copyToClipboard(code)}
>
{copyState ? (
<Check className="h-3 w-3 dark:text-gray-300 light:text-gray-700" />
) : (
<Copy className="h-3 w-3 dark:text-gray-300 light:text-gray-700" />
)}
<span className="sr-only">Copy code</span>
</Button>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
key={currentTab}
className="relative flex items-start px-1 text-sm"
>
<div
aria-hidden="true"
className="select-none border-r pr-4 font-mono dark:border-slate-300/5 dark:text-slate-600 light:border-slate-700/20 light:text-slate-500"
>
{Array.from({
length: code.split("\n").length,
}).map((_, index) => (
<div key={index}>
{(index + 1).toString().padStart(2, "0")}
</div>
))}
</div>
<Highlight
key={theme.resolvedTheme}
code={code}
language={"javascript"}
theme={{
...codeTheme,
plain: {
backgroundColor: "transparent",
},
}}
>
{({
className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre
className={clsx(
className,
"flex overflow-x-auto pb-6",
)}
style={style}
>
<code className="px-4">
{tokens.map((line, lineIndex) => (
<div key={lineIndex} {...getLineProps({ line })}>
{line.map((token, tokenIndex) => (
<span
key={tokenIndex}
{...getTokenProps({ token })}
/>
))}
</div>
))}
</code>
</pre>
)}
</Highlight>
</motion.div>
<motion.div layout className="self-end">
<Link
href="https://demo.better-auth.com"
target="_blank"
className="shadow-lg border backdrop-blur-sm mb-4 ml-auto mr-4 mt-auto flex cursor-pointer items-center gap-2 px-3 py-1 transition-all ease-in-out hover:opacity-90 rounded-md dark:border-white/[0.05] dark:bg-black/20 dark:hover:bg-black/40 light:border-black/[0.15] light:bg-white/20 light:hover:bg-white/40"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M10 20H8V4h2v2h2v3h2v2h2v2h-2v2h-2v3h-2z"
></path>
</svg>
<p className="text-sm">Demo</p>
</Link>
</motion.div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -74,12 +74,15 @@ export async function onCheckoutSessionCompleted(
], ],
}); });
} }
await options.subscription?.onSubscriptionComplete?.({ await options.subscription?.onSubscriptionComplete?.(
event, {
subscription: dbSubscription as Subscription, event,
stripeSubscription: subscription, subscription: dbSubscription as Subscription,
plan, stripeSubscription: subscription,
}); plan,
},
ctx,
);
return; return;
} }
} }
@@ -183,14 +186,14 @@ export async function onSubscriptionUpdated(
subscription.status === "trialing" && subscription.status === "trialing" &&
plan.freeTrial?.onTrialEnd plan.freeTrial?.onTrialEnd
) { ) {
await plan.freeTrial.onTrialEnd({ subscription }, ctx.request); await plan.freeTrial.onTrialEnd({ subscription }, ctx);
} }
if ( if (
subscriptionUpdated.status === "incomplete_expired" && subscriptionUpdated.status === "incomplete_expired" &&
subscription.status === "trialing" && subscription.status === "trialing" &&
plan.freeTrial?.onTrialExpired plan.freeTrial?.onTrialExpired
) { ) {
await plan.freeTrial.onTrialExpired(subscription, ctx.request); await plan.freeTrial.onTrialExpired(subscription, ctx);
} }
} }
} catch (error: any) { } catch (error: any) {

View File

@@ -91,12 +91,15 @@ export const stripe = <O extends StripeOptions>(options: O) => {
}); });
} }
const isAuthorized = ctx.body?.referenceId const isAuthorized = ctx.body?.referenceId
? await options.subscription?.authorizeReference?.({ ? await options.subscription?.authorizeReference?.(
user: session.user, {
session: session.session, user: session.user,
referenceId, session: session.session,
action, referenceId,
}) action,
},
ctx,
)
: true; : true;
if (!isAuthorized) { if (!isAuthorized) {
throw new APIError("UNAUTHORIZED", { throw new APIError("UNAUTHORIZED", {
@@ -387,6 +390,8 @@ export const stripe = <O extends StripeOptions>(options: O) => {
subscription, subscription,
}, },
ctx.request, ctx.request,
//@ts-expect-error
ctx,
); );
const freeTrail = plan.freeTrial const freeTrail = plan.freeTrial
@@ -1035,11 +1040,14 @@ export const stripe = <O extends StripeOptions>(options: O) => {
if (!customer) { if (!customer) {
logger.error("#BETTER_AUTH: Failed to create customer"); logger.error("#BETTER_AUTH: Failed to create customer");
} else { } else {
await options.onCustomerCreate?.({ await options.onCustomerCreate?.(
customer, {
stripeCustomer, customer,
user, stripeCustomer,
}); user,
},
ctx,
);
} }
} }
}, },

View File

@@ -561,6 +561,10 @@ describe("stripe", async () => {
stripeSubscription: expect.any(Object), stripeSubscription: expect.any(Object),
plan: expect.any(Object), plan: expect.any(Object),
}), }),
expect.objectContaining({
context: expect.any(Object),
_flag: expect.any(String),
}),
); );
const updateEvent = { const updateEvent = {

View File

@@ -1,4 +1,9 @@
import type { InferOptionSchema, Session, User } from "better-auth"; import type {
GenericEndpointContext,
InferOptionSchema,
Session,
User,
} from "better-auth";
import type Stripe from "stripe"; import type Stripe from "stripe";
import type { subscriptions, user } from "./schema"; import type { subscriptions, user } from "./schema";
@@ -70,7 +75,7 @@ export type StripePlan = {
data: { data: {
subscription: Subscription; subscription: Subscription;
}, },
request?: Request, ctx: GenericEndpointContext,
) => Promise<void>; ) => Promise<void>;
/** /**
* A function that will be called when the trial * A function that will be called when the trial
@@ -80,7 +85,7 @@ export type StripePlan = {
*/ */
onTrialExpired?: ( onTrialExpired?: (
subscription: Subscription, subscription: Subscription,
request?: Request, ctx: GenericEndpointContext,
) => Promise<void>; ) => Promise<void>;
}; };
}; };
@@ -186,7 +191,7 @@ export interface StripeOptions {
stripeCustomer: Stripe.Customer; stripeCustomer: Stripe.Customer;
user: User; user: User;
}, },
request?: Request, ctx: GenericEndpointContext,
) => Promise<void>; ) => Promise<void>;
/** /**
* A custom function to get the customer create * A custom function to get the customer create
@@ -199,7 +204,7 @@ export interface StripeOptions {
user: User; user: User;
session: Session; session: Session;
}, },
request?: Request, ctx: GenericEndpointContext,
) => Promise<{}>; ) => Promise<{}>;
/** /**
* Subscriptions * Subscriptions
@@ -233,7 +238,7 @@ export interface StripeOptions {
subscription: Subscription; subscription: Subscription;
plan: StripePlan; plan: StripePlan;
}, },
request?: Request, ctx: GenericEndpointContext,
) => Promise<void>; ) => Promise<void>;
/** /**
* A callback to run after a user is about to cancel their subscription * A callback to run after a user is about to cancel their subscription
@@ -258,7 +263,7 @@ export interface StripeOptions {
* and belongs to the user * and belongs to the user
* *
* @param data - data containing user, session and referenceId * @param data - data containing user, session and referenceId
* @param request - Request Object * @param ctx - the context object
* @returns * @returns
*/ */
authorizeReference?: ( authorizeReference?: (
@@ -272,7 +277,7 @@ export interface StripeOptions {
| "cancel-subscription" | "cancel-subscription"
| "restore-subscription"; | "restore-subscription";
}, },
request?: Request, ctx: GenericEndpointContext,
) => Promise<boolean>; ) => Promise<boolean>;
/** /**
* A callback to run after a user has deleted their subscription * A callback to run after a user has deleted their subscription
@@ -287,7 +292,7 @@ export interface StripeOptions {
* parameters for session create params * parameters for session create params
* *
* @param data - data containing user, session and plan * @param data - data containing user, session and plan
* @param request - Request Object * @param ctx - the context object
*/ */
getCheckoutSessionParams?: ( getCheckoutSessionParams?: (
data: { data: {
@@ -296,7 +301,7 @@ export interface StripeOptions {
plan: StripePlan; plan: StripePlan;
subscription: Subscription; subscription: Subscription;
}, },
request?: Request, ctx: GenericEndpointContext,
) => ) =>
| Promise<{ | Promise<{
params?: Stripe.Checkout.SessionCreateParams; params?: Stripe.Checkout.SessionCreateParams;