Skip to content

Commit f6eda07

Browse files
committed
Allow plan switching during active trial
- Route trialing users to change_plan() instead of checkout flow - Skip proration for trialing subscriptions (no charges to prorate) - Return trial-aware success messages from change_plan/schedule_downgrade - Fix isReturningCustomer to exclude currently-trialing users - Show "Switch plan" CTA and "Your trial continues" badge for trialing users
1 parent 67ee846 commit f6eda07

File tree

4 files changed

+51
-8
lines changed

4 files changed

+51
-8
lines changed

backend/src/server/billing/handlers.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ async fn create_checkout_session(
125125
Ok(Json(ApiResponse::success(result)))
126126
} else {
127127
// Paid target — check trial eligibility and payment state
128+
let is_currently_trialing = org.base.plan_status.as_deref() == Some("trialing");
129+
130+
if is_currently_trialing {
131+
// Currently trialing — switch plan via subscription update (preserves trial)
132+
let result = billing_service
133+
.change_plan(organization_id, request.plan, auth.into_entity())
134+
.await?;
135+
return Ok(Json(ApiResponse::success(result)));
136+
}
137+
128138
let is_returning = org.base.trial_end_date.is_some()
129139
|| org.base.plan.as_ref().is_some_and(|p| !p.is_free());
130140
let is_trial_eligible = !is_returning && request.plan.config().trial_days > 0;

backend/src/server/billing/service.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,8 @@ impl BillingService {
17001700
SubscriptionStatus::Active | SubscriptionStatus::Trialing
17011701
)
17021702
}) {
1703+
let is_trialing = sub.status == SubscriptionStatus::Trialing;
1704+
17031705
UpdateSubscription::new(&sub.id)
17041706
.cancel_at_period_end(true)
17051707
.send(&self.stripe)
@@ -1708,10 +1710,15 @@ impl BillingService {
17081710
tracing::info!(
17091711
organization_id = %organization_id,
17101712
subscription_id = %sub.id,
1713+
is_trialing,
17111714
"Scheduled downgrade to Free at period end"
17121715
);
17131716

1714-
Ok("Your plan will change to Free at the end of your billing cycle.".to_string())
1717+
if is_trialing {
1718+
Ok("Your plan will change to Free when your trial ends.".to_string())
1719+
} else {
1720+
Ok("Your plan will change to Free at the end of your billing cycle.".to_string())
1721+
}
17151722
} else {
17161723
Err(anyhow!("No active subscription found"))
17171724
}
@@ -1805,6 +1812,12 @@ impl BillingService {
18051812
.first()
18061813
.ok_or_else(|| anyhow!("No subscription items found"))?;
18071814

1815+
let proration = if sub.status == SubscriptionStatus::Trialing {
1816+
UpdateSubscriptionProrationBehavior::None
1817+
} else {
1818+
UpdateSubscriptionProrationBehavior::AlwaysInvoice
1819+
};
1820+
18081821
UpdateSubscription::new(&sub.id)
18091822
.items(vec![UpdateSubscriptionItems {
18101823
id: Some(base_item.id.to_string()),
@@ -1816,18 +1829,28 @@ impl BillingService {
18161829
("plan".to_string(), serde_json::to_string(&target_plan)?),
18171830
("organization_id".to_string(), organization_id.to_string()),
18181831
])
1819-
.proration_behavior(UpdateSubscriptionProrationBehavior::AlwaysInvoice)
1832+
.proration_behavior(proration)
18201833
.cancel_at_period_end(false) // Clear any pending cancellation
18211834
.send(&self.stripe)
18221835
.await?;
18231836

1837+
let is_trialing = sub.status == SubscriptionStatus::Trialing;
1838+
18241839
tracing::info!(
18251840
organization_id = %organization_id,
18261841
target_plan = %target_plan.name(),
1842+
is_trialing,
18271843
"Plan changed via subscription update"
18281844
);
18291845

1830-
Ok(format!("Plan changed to {}", target_plan.name()))
1846+
if is_trialing {
1847+
Ok(format!(
1848+
"Plan changed to {}. Your trial continues.",
1849+
target_plan.name()
1850+
))
1851+
} else {
1852+
Ok(format!("Plan changed to {}", target_plan.name()))
1853+
}
18311854
} else {
18321855
Err(anyhow!("No active subscription found to modify"))
18331856
}

ui/src/lib/features/billing/BillingPlanForm.svelte

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
forceCommercial?: boolean;
4949
/** If true, user is a returning customer and should not see trial offers */
5050
isReturningCustomer?: boolean;
51+
/** If true, user is currently on an active trial */
52+
isCurrentlyTrialing?: boolean;
5153
}
5254
5355
// eslint-disable-next-line svelte/no-unused-props
@@ -63,7 +65,8 @@
6365
showHosting = false,
6466
recommendedPlan = null,
6567
forceCommercial = false,
66-
isReturningCustomer = false
68+
isReturningCustomer = false,
69+
isCurrentlyTrialing = false
6770
}: Props = $props();
6871
6972
let loadingPlanType = $state<string | null>(null);
@@ -452,9 +455,9 @@
452455
billed yearly
453456
</div>
454457
<div
455-
class={`text-xs font-medium text-success ${hasTrial(plan) && !hasCustomPrice(plan) ? 'opacity-100' : 'opacity-0'}`}
458+
class={`text-xs font-medium text-success ${(hasTrial(plan) || (isCurrentlyTrialing && plan.trial_days > 0)) && !hasCustomPrice(plan) ? 'opacity-100' : 'opacity-0'}`}
456459
>
457-
{plan.trial_days}-day free trial
460+
{isCurrentlyTrialing ? 'Your trial continues' : `${plan.trial_days}-day free trial`}
458461
</div>
459462
</div>
460463

@@ -485,6 +488,8 @@
485488
>
486489
{#if loadingPlanType === plan.type}
487490
<Loader2 class="mx-auto h-4 w-4 animate-spin" />
491+
{:else if isCurrentlyTrialing}
492+
Switch plan
488493
{:else}
489494
{trial ? `Start ${plan.trial_days}-day free trial` : 'Get Started'}
490495
{/if}

ui/src/lib/features/billing/BillingPlanModal.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,16 @@
7272
const organizationQuery = useOrganizationQuery();
7373
let organization = $derived(organizationQuery.data);
7474
75+
let isCurrentlyTrialing = $derived(organization?.plan_status === 'trialing');
76+
7577
// Only show trial offers to orgs that have never had a non-Free paid plan and never trialed.
7678
// trial_end_date is set by Stripe webhook only for subscriptions with trial periods
7779
// (Free plan has trial_days=0, so it never sets trial_end_date).
80+
// Trialing users are NOT returning — they should see trial-aware UI instead.
7881
let isReturningCustomer = $derived(
79-
(organization?.plan != null && organization.plan.type !== 'Free') ||
80-
!!organization?.trial_end_date
82+
!isCurrentlyTrialing &&
83+
((organization?.plan != null && organization.plan.type !== 'Free') ||
84+
!!organization?.trial_end_date)
8185
);
8286
8387
// Mutations
@@ -184,6 +188,7 @@
184188
{initialPlanFilter}
185189
{recommendedPlan}
186190
{isReturningCustomer}
191+
{isCurrentlyTrialing}
187192
/>
188193
</div>
189194

0 commit comments

Comments
 (0)