fix(stripe): prevent duplicate trials when switching plans (#3622)

* fix(stripe): tiral subscription should use update flow

* add changelog

* chore: changeset
This commit is contained in:
Bereket Engida
2025-07-25 20:53:03 -07:00
committed by GitHub
parent ac6baba2a0
commit c2fb1aa316
8 changed files with 73 additions and 77 deletions

View File

@@ -0,0 +1,5 @@
---
"@better-auth/stripe": patch
---
Fix duplicate trials when switching plans

View File

@@ -19,7 +19,7 @@ function Component(props: {
currentPlan?: string; currentPlan?: string;
isTrial?: boolean; isTrial?: boolean;
}) { }) {
const [selectedPlan, setSelectedPlan] = useState("starter"); const [selectedPlan, setSelectedPlan] = useState("plus");
const id = useId(); const id = useId();
return ( return (
<Dialog> <Dialog>
@@ -72,35 +72,35 @@ function Component(props: {
> >
<div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-accent"> <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-accent">
<RadioGroupItem <RadioGroupItem
value="starter" value="plus"
id={`${id}-1`} id={`${id}-1`}
aria-describedby={`${id}-1-description`} aria-describedby={`${id}-1-description`}
className="order-1 after:absolute after:inset-0" className="order-1 after:absolute after:inset-0"
/> />
<div className="grid grow gap-1"> <div className="grid grow gap-1">
<Label htmlFor={`${id}-1`}>Starter</Label> <Label htmlFor={`${id}-1`}>Plus</Label>
<p <p
id={`${id}-1-description`} id={`${id}-1-description`}
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
> >
$50/month $20/month
</p> </p>
</div> </div>
</div> </div>
<div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-accent"> <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-accent">
<RadioGroupItem <RadioGroupItem
value="professional" value="pro"
id={`${id}-2`} id={`${id}-2`}
aria-describedby={`${id}-2-description`} aria-describedby={`${id}-2-description`}
className="order-1 after:absolute after:inset-0" className="order-1 after:absolute after:inset-0"
/> />
<div className="grid grow gap-1"> <div className="grid grow gap-1">
<Label htmlFor={`${id}-2`}>Professional</Label> <Label htmlFor={`${id}-2`}>Pro</Label>
<p <p
id={`${id}-2-description`} id={`${id}-2-description`}
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
> >
$99/month $200/month
</p> </p>
</div> </div>
</div> </div>
@@ -158,11 +158,11 @@ function Component(props: {
? props.isTrial ? props.isTrial
? "Upgrade" ? "Upgrade"
: "Current Plan" : "Current Plan"
: selectedPlan === "starter" : selectedPlan === "plus"
? !props.currentPlan ? !props.currentPlan
? "Upgrade" ? "Upgrade"
: "Downgrade" : "Downgrade"
: selectedPlan === "professional" : selectedPlan === "pro"
? "Upgrade" ? "Upgrade"
: "Contact us"} : "Contact us"}
</Button> </Button>

View File

@@ -142,11 +142,11 @@ export default function UserCard(props: {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<SubscriptionTierLabel <SubscriptionTierLabel
tier={subscription?.plan?.toLowerCase() as "starter"} tier={subscription?.plan?.toLowerCase() as "plus"}
/> />
</div> </div>
<Component <Component
currentPlan={subscription?.plan?.toLowerCase() as "starter"} currentPlan={subscription?.plan?.toLowerCase() as "plus"}
isTrial={subscription?.status === "trialing"} isTrial={subscription?.status === "trialing"}
/> />
</div> </div>

View File

@@ -2,9 +2,9 @@ import { Pricing } from "@/components/blocks/pricing";
const demoPlans = [ const demoPlans = [
{ {
name: "STARTER", name: "Plus",
price: "50", price: "20",
yearlyPrice: "40", yearlyPrice: "16",
period: "per month", period: "per month",
features: [ features: [
"Up to 10 projects", "Up to 10 projects",
@@ -18,9 +18,9 @@ const demoPlans = [
isPopular: false, isPopular: false,
}, },
{ {
name: "PROFESSIONAL", name: "Pro",
price: "99", price: "50",
yearlyPrice: "79", yearlyPrice: "40",
period: "per month", period: "per month",
features: [ features: [
"Unlimited projects", "Unlimited projects",
@@ -34,24 +34,6 @@ const demoPlans = [
href: "/sign-up", href: "/sign-up",
isPopular: true, isPopular: true,
}, },
{
name: "ENTERPRISE",
price: "299",
yearlyPrice: "239",
period: "per month",
features: [
"Everything in Professional",
"Custom solutions",
"Dedicated account manager",
"1-hour support response time",
"SSO Authentication",
"Advanced security",
],
description: "For large organizations with specific needs",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
},
]; ];
export default function Page() { export default function Page() {

View File

@@ -8,9 +8,8 @@ const tierVariants = cva(
variants: { variants: {
variant: { variant: {
free: "bg-gray-500 text-white ring-gray-400 hover:bg-gray-600", free: "bg-gray-500 text-white ring-gray-400 hover:bg-gray-600",
starter: "bg-lime-700/40 text-white ring-lime-200/40 hover:bg-lime-600", plus: "bg-lime-700/40 text-white ring-lime-200/40 hover:bg-lime-600",
professional: "bg-purple-800/80 ring-purple-400 hover:bg-purple-700", pro: "bg-purple-800/80 ring-purple-400 hover:bg-purple-700",
enterprise: "bg-amber-500 text-black ring-amber-400 hover:bg-amber-600",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -22,7 +21,7 @@ const tierVariants = cva(
export interface SubscriptionTierLabelProps export interface SubscriptionTierLabelProps
extends React.HTMLAttributes<HTMLSpanElement>, extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof tierVariants> { VariantProps<typeof tierVariants> {
tier?: "free" | "starter" | "professional" | "enterprise"; tier?: "free" | "plus" | "pro";
} }
export const SubscriptionTierLabel: React.FC<SubscriptionTierLabelProps> = ({ export const SubscriptionTierLabel: React.FC<SubscriptionTierLabelProps> = ({

View File

@@ -39,15 +39,14 @@ if (!dialect) {
throw new Error("No dialect found"); throw new Error("No dialect found");
} }
const PROFESSION_PRICE_ID = { const PRO_PRICE_ID = {
default: "price_1QxWZ5LUjnrYIrml5Dnwnl0X", default: "price_1RoxnRHmTADgihIt4y8c0lVE",
annual: "price_1QxWZTLUjnrYIrmlyJYpwyhz", annual: "price_1RoxnoHmTADgihItzFvVP8KT",
}; };
const STARTER_PRICE_ID = { const PLUS_PRICE_ID = {
default: "price_1QxWWtLUjnrYIrmleljPKszG", default: "price_1RoxnJHmTADgihIthZTLmrPn",
annual: "price_1QxWYqLUjnrYIrmlonqPThVF", annual: "price_1Roxo5HmTADgihItEbJu5llL",
}; };
export const auth = betterAuth({ export const auth = betterAuth({
appName: "Better Auth Demo", appName: "Better Auth Demo",
database: { database: {
@@ -174,22 +173,23 @@ export const auth = betterAuth({
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
subscription: { subscription: {
enabled: true, enabled: true,
allowReTrialsForDifferentPlans: true,
plans: [ plans: [
{ {
name: "Starter", name: "Plus",
priceId: STARTER_PRICE_ID.default, priceId: PLUS_PRICE_ID.default,
annualDiscountPriceId: STARTER_PRICE_ID.annual, annualDiscountPriceId: PLUS_PRICE_ID.annual,
freeTrial: { freeTrial: {
days: 7, days: 7,
}, },
}, },
{ {
name: "Professional", name: "Pro",
priceId: PROFESSION_PRICE_ID.default, priceId: PRO_PRICE_ID.default,
annualDiscountPriceId: PROFESSION_PRICE_ID.annual, annualDiscountPriceId: PRO_PRICE_ID.annual,
}, freeTrial: {
{ days: 7,
name: "Enterprise", },
}, },
], ],
}, },

View File

@@ -319,23 +319,20 @@ export const stripe = <O extends StripeOptions>(options: O) => {
} }
} }
const activeSubscription = customerId const activeSubscriptions = await client.subscriptions
? await client.subscriptions .list({
.list({ customer: customerId,
customer: customerId, })
status: "active", .then((res) =>
}) res.data.filter(
.then((res) => (sub) => sub.status === "active" || sub.status === "trialing",
res.data.find( ),
(subscription) => );
subscription.id === const activeSubscription = activeSubscriptions.find((sub) =>
subscriptionToUpdate?.stripeSubscriptionId || subscriptionToUpdate?.stripeSubscriptionId
ctx.body.subscriptionId, ? sub.id === subscriptionToUpdate?.stripeSubscriptionId
), : true,
) );
.catch((e) => null)
: null;
const subscriptions = subscriptionToUpdate const subscriptions = subscriptionToUpdate
? [subscriptionToUpdate] ? [subscriptionToUpdate]
: await ctx.context.adapter.findMany<Subscription>({ : await ctx.context.adapter.findMany<Subscription>({
@@ -370,6 +367,12 @@ export const stripe = <O extends StripeOptions>(options: O) => {
return_url: getUrl(ctx, ctx.body.returnUrl || "/"), return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
flow_data: { flow_data: {
type: "subscription_update_confirm", type: "subscription_update_confirm",
after_completion: {
type: "redirect",
redirect: {
return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
},
},
subscription_update_confirm: { subscription_update_confirm: {
subscription: activeSubscription.id, subscription: activeSubscription.id,
items: [ items: [
@@ -426,11 +429,13 @@ export const stripe = <O extends StripeOptions>(options: O) => {
ctx, ctx,
); );
const freeTrial = plan.freeTrial const alreadyHasTrial = subscription.status === "trialing";
? { const freeTrial =
trial_period_days: plan.freeTrial.days, !alreadyHasTrial && plan.freeTrial
} ? {
: undefined; trial_period_days: plan.freeTrial.days,
}
: undefined;
let priceIdToUse: string | undefined = undefined; let priceIdToUse: string | undefined = undefined;
if (ctx.body.annual) { if (ctx.body.annual) {

View File

@@ -318,6 +318,11 @@ export interface StripeOptions {
enabled: boolean; enabled: boolean;
}; };
}; };
/**
* A callback to run after a stripe event is received
* @param event - Stripe Event
* @returns
*/
onEvent?: (event: Stripe.Event) => Promise<void>; onEvent?: (event: Stripe.Event) => Promise<void>;
/** /**
* Schema for the stripe plugin * Schema for the stripe plugin