Merge pull request #5515 from Dokploy/fix/trial-billing-interval-switch

fix(billing): allow switching billing interval during trial
This commit is contained in:
Mauricio Siu
2026-09-23 15:09:24 -06:00
committed by GitHub
3 changed files with 64 additions and 21 deletions
@@ -111,14 +111,20 @@ export const ShowBilling = () => {
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
const planName = tier === "startup" ? "Startup" : "Hobby";
const handleSwitchTrialPlan = async (
tier: "hobby" | "startup",
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: false,
isAnnual: isAnnualPlan,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
@@ -133,7 +139,7 @@ export const ShowBilling = () => {
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on the ${planName} plan`,
success: `Your trial is now on ${planName}`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
@@ -336,6 +342,11 @@ export const ShowBilling = () => {
<Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"}
</Badge>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial
@@ -356,23 +367,45 @@ export const ShowBilling = () => {
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan && (
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
billingStatus.plan &&
billingStatus.plan !== "legacy" && (
<>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
billingStatus.isAnnual,
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan as "hobby" | "startup",
!billingStatus.isAnnual,
billingStatus.isAnnual
? "monthly billing"
: "annual billing",
)
}
>
{billingStatus.isAnnual
? "Switch to monthly"
: "Switch to annual"}
</Button>
</>
)}
<Button
size="sm"
@@ -388,6 +388,9 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: isTrialing ? "none" : "create_prorations",
...(isTrialing && subscription.trial_end
? { trial_end: subscription.trial_end }
: {}),
});
return { ok: true };
+7
View File
@@ -92,6 +92,7 @@ export interface BillingStatus {
hasUsedTrial: boolean;
hasActiveAccess: boolean;
hasPaymentMethod: boolean;
isAnnual: boolean;
}
export const getBillingStatus = async (
@@ -106,6 +107,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: true,
hasPaymentMethod: true,
isAnnual: false,
};
}
@@ -119,6 +121,7 @@ export const getBillingStatus = async (
hasUsedTrial: false,
hasActiveAccess: false,
hasPaymentMethod: false,
isAnnual: false,
};
}
@@ -146,6 +149,9 @@ export const getBillingStatus = async (
const hasPaymentMethod =
!!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method;
const isAnnual =
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
?.interval === "year";
const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000)
: null;
@@ -164,5 +170,6 @@ export const getBillingStatus = async (
hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub,
isAnnual,
};
};