mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 20:37:44 +00:00
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:
5
.changeset/hungry-meals-float.md
Normal file
5
.changeset/hungry-meals-float.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@better-auth/stripe": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix duplicate trials when switching plans
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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> = ({
|
||||||
|
|||||||
@@ -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",
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user