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;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user