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;
isTrial?: boolean;
}) {
const [selectedPlan, setSelectedPlan] = useState("starter");
const [selectedPlan, setSelectedPlan] = useState("plus");
const id = useId();
return (
<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">
<RadioGroupItem
value="starter"
value="plus"
id={`${id}-1`}
aria-describedby={`${id}-1-description`}
className="order-1 after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label htmlFor={`${id}-1`}>Starter</Label>
<Label htmlFor={`${id}-1`}>Plus</Label>
<p
id={`${id}-1-description`}
className="text-xs text-muted-foreground"
>
$50/month
$20/month
</p>
</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">
<RadioGroupItem
value="professional"
value="pro"
id={`${id}-2`}
aria-describedby={`${id}-2-description`}
className="order-1 after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label htmlFor={`${id}-2`}>Professional</Label>
<Label htmlFor={`${id}-2`}>Pro</Label>
<p
id={`${id}-2-description`}
className="text-xs text-muted-foreground"
>
$99/month
$200/month
</p>
</div>
</div>
@@ -158,11 +158,11 @@ function Component(props: {
? props.isTrial
? "Upgrade"
: "Current Plan"
: selectedPlan === "starter"
: selectedPlan === "plus"
? !props.currentPlan
? "Upgrade"
: "Downgrade"
: selectedPlan === "professional"
: selectedPlan === "pro"
? "Upgrade"
: "Contact us"}
</Button>

View File

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

View File

@@ -2,9 +2,9 @@ import { Pricing } from "@/components/blocks/pricing";
const demoPlans = [
{
name: "STARTER",
price: "50",
yearlyPrice: "40",
name: "Plus",
price: "20",
yearlyPrice: "16",
period: "per month",
features: [
"Up to 10 projects",
@@ -18,9 +18,9 @@ const demoPlans = [
isPopular: false,
},
{
name: "PROFESSIONAL",
price: "99",
yearlyPrice: "79",
name: "Pro",
price: "50",
yearlyPrice: "40",
period: "per month",
features: [
"Unlimited projects",
@@ -34,24 +34,6 @@ const demoPlans = [
href: "/sign-up",
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() {

View File

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

View File

@@ -39,15 +39,14 @@ if (!dialect) {
throw new Error("No dialect found");
}
const PROFESSION_PRICE_ID = {
default: "price_1QxWZ5LUjnrYIrml5Dnwnl0X",
annual: "price_1QxWZTLUjnrYIrmlyJYpwyhz",
const PRO_PRICE_ID = {
default: "price_1RoxnRHmTADgihIt4y8c0lVE",
annual: "price_1RoxnoHmTADgihItzFvVP8KT",
};
const STARTER_PRICE_ID = {
default: "price_1QxWWtLUjnrYIrmleljPKszG",
annual: "price_1QxWYqLUjnrYIrmlonqPThVF",
const PLUS_PRICE_ID = {
default: "price_1RoxnJHmTADgihIthZTLmrPn",
annual: "price_1Roxo5HmTADgihItEbJu5llL",
};
export const auth = betterAuth({
appName: "Better Auth Demo",
database: {
@@ -174,22 +173,23 @@ export const auth = betterAuth({
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
subscription: {
enabled: true,
allowReTrialsForDifferentPlans: true,
plans: [
{
name: "Starter",
priceId: STARTER_PRICE_ID.default,
annualDiscountPriceId: STARTER_PRICE_ID.annual,
name: "Plus",
priceId: PLUS_PRICE_ID.default,
annualDiscountPriceId: PLUS_PRICE_ID.annual,
freeTrial: {
days: 7,
},
},
{
name: "Professional",
priceId: PROFESSION_PRICE_ID.default,
annualDiscountPriceId: PROFESSION_PRICE_ID.annual,
name: "Pro",
priceId: PRO_PRICE_ID.default,
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
? await client.subscriptions
const activeSubscriptions = await client.subscriptions
.list({
customer: customerId,
status: "active",
})
.then((res) =>
res.data.find(
(subscription) =>
subscription.id ===
subscriptionToUpdate?.stripeSubscriptionId ||
ctx.body.subscriptionId,
res.data.filter(
(sub) => sub.status === "active" || sub.status === "trialing",
),
)
.catch((e) => null)
: null;
);
const activeSubscription = activeSubscriptions.find((sub) =>
subscriptionToUpdate?.stripeSubscriptionId
? sub.id === subscriptionToUpdate?.stripeSubscriptionId
: true,
);
const subscriptions = subscriptionToUpdate
? [subscriptionToUpdate]
: 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 || "/"),
flow_data: {
type: "subscription_update_confirm",
after_completion: {
type: "redirect",
redirect: {
return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
},
},
subscription_update_confirm: {
subscription: activeSubscription.id,
items: [
@@ -426,7 +429,9 @@ export const stripe = <O extends StripeOptions>(options: O) => {
ctx,
);
const freeTrial = plan.freeTrial
const alreadyHasTrial = subscription.status === "trialing";
const freeTrial =
!alreadyHasTrial && plan.freeTrial
? {
trial_period_days: plan.freeTrial.days,
}

View File

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