Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions app/src/ai/onboarding.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
//! Onboarding-specific AI types and conversions.
//! Onboarding-specific AI types, conversions and credit helpers.

use ai::LLMId;
use onboarding::OnboardingAuthState;
use onboarding::slides::OnboardingModelInfo;
use onboarding::{CreditPackOption, OnboardingAuthState};
use warp_core::ui::icons::Icon;
use warpui::{AppContext, SingletonEntity};

use super::llms::{LLMInfo, LLMPreferences};
use crate::auth::AuthStateProvider;
use crate::pricing::{PricingInfoModel, onboarding_credit_pack_options};
use crate::workspaces::user_workspaces::UserWorkspaces;

impl From<&LLMInfo> for OnboardingModelInfo {
Expand Down Expand Up @@ -52,3 +53,20 @@ pub fn current_onboarding_auth_state(ctx: &AppContext) -> OnboardingAuthState {
OnboardingAuthState::FreeUser
}
}

/// The ad-hoc credit packs to offer during onboarding, priced for the current
/// viewer. Empty when the server hasn't sent pricing yet or the viewer's plan
/// can't buy packs at all, which hides the option.
pub fn onboarding_credit_packs(ctx: &AppContext) -> Vec<CreditPackOption> {
let workspaces = UserWorkspaces::as_ref(ctx);
let Some(policy) = workspaces.purchase_policy() else {
return Vec::new();
};
if !policy.allows_purchases() {
return Vec::new();
}
let Some(options) = PricingInfoModel::as_ref(ctx).addon_credits_options() else {
return Vec::new();
};
onboarding_credit_pack_options(options, policy.effective_premium_bps())
}
38 changes: 38 additions & 0 deletions app/src/pricing/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,42 @@
use onboarding::CreditPackOption;
use warp_graphql::billing::{
AddonCreditsOption, OveragesPricing, PlanPricing, PricingInfo, StripeSubscriptionPlan,
};
use warpui::{Entity, ModelContext, SingletonEntity};

/// Converts the server's add-on credit packs into the display options shown on
/// the onboarding offer slide.
///
/// `premium_bps` is the viewer's `PurchaseAddOnCreditsPolicy` surcharge (see
/// [`crate::workspaces::workspace::PurchaseAddOnCreditsPolicy::effective_premium_bps`]),
/// applied with the same integer math the server charges with, so the price we
/// show is the price billed. Savings are computed against the smallest pack's
/// per-credit list rate — the premium scales every pack equally, so it doesn't
/// change the relative volume discount.
pub fn onboarding_credit_pack_options(
options: &[AddonCreditsOption],
premium_bps: i32,
) -> Vec<CreditPackOption> {
let base_rate = options.first().map_or(0., |option| option.rate());
options
.iter()
.map(|option| {
let savings_percent = if base_rate > 0. {
(((base_rate - option.rate()) / base_rate) * 100.)
.round()
.max(0.) as u32
} else {
0
};
CreditPackOption {
credits: option.credits,
price_usd_cents: option.price_usd_cents_with_premium(premium_bps),
savings_percent,
}
})
.collect()
}

/// A global model for maintaining pricing information from the server.
#[derive(Debug)]
pub struct PricingInfoModel {
Expand Down Expand Up @@ -83,3 +117,7 @@ impl Entity for PricingInfoModel {
}

impl SingletonEntity for PricingInfoModel {}

#[cfg(test)]
#[path = "pricing_tests.rs"]
mod tests;
89 changes: 89 additions & 0 deletions app/src/pricing/pricing_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
use warp_graphql::billing::AddonCreditsOption;

use super::onboarding_credit_pack_options;

/// The production add-on credit packs (`GetAddonCreditsOptions` on the server).
fn production_packs() -> Vec<AddonCreditsOption> {
[
(400, 1_000),
(1_000, 2_000),
(3_000, 5_000),
(6_500, 10_000),
]
.into_iter()
.map(|(credits, price_usd_cents)| AddonCreditsOption {
credits,
price_usd_cents,
})
.collect()
}

#[test]
fn subscriber_packs_are_offered_at_list_price() {
let packs = onboarding_credit_pack_options(&production_packs(), 0);

let prices: Vec<_> = packs.iter().map(|pack| pack.price_label()).collect();
assert_eq!(prices, ["$10", "$20", "$50", "$100"]);
}

/// Free-plan buyers pay the `price_premium_bps` surcharge (2000 bps = +20%) on
/// top of the list price. Regression test for REV-1886: the onboarding offer
/// must show the premium-adjusted price the server actually charges, never the
/// list price.
#[test]
fn free_plan_packs_apply_the_twenty_percent_premium() {
let packs = onboarding_credit_pack_options(&production_packs(), 2_000);

let labels: Vec<_> = packs
.iter()
.map(|pack| (pack.credits_label(), pack.price_label()))
.collect();
assert_eq!(
labels,
[
("400".to_string(), "$12".to_string()),
("1,000".to_string(), "$24".to_string()),
("3,000".to_string(), "$60".to_string()),
("6,500".to_string(), "$120".to_string()),
]
);
}

/// Volume savings are relative to the smallest pack's per-credit rate, and are
/// unaffected by the premium (which scales every pack equally).
#[test]
fn volume_savings_are_relative_to_the_smallest_pack() {
for premium_bps in [0, 2_000] {
let packs = onboarding_credit_pack_options(&production_packs(), premium_bps);

let savings: Vec<_> = packs.iter().map(|pack| pack.savings_percent).collect();
assert_eq!(savings, [0, 20, 33, 38], "premium_bps = {premium_bps}");
}
}

#[test]
fn no_packs_produces_no_options() {
assert!(onboarding_credit_pack_options(&[], 2_000).is_empty());
}

/// A pack that is a worse per-credit deal than the smallest one must not
/// render a negative or wrapped-around "savings" badge.
#[test]
fn packs_worse_than_the_base_rate_show_no_savings() {
let packs = vec![
AddonCreditsOption {
credits: 400,
price_usd_cents: 1_000,
},
AddonCreditsOption {
credits: 400,
price_usd_cents: 1_500,
},
];

let savings: Vec<_> = onboarding_credit_pack_options(&packs, 0)
.iter()
.map(|pack| pack.savings_percent)
.collect();
assert_eq!(savings, [0, 0]);
}
119 changes: 115 additions & 4 deletions app/src/root_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ use crate::ai::AIRequestUsageModel;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::blocklist::SerializedBlockListItem;
use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent};
use crate::ai::onboarding::{build_onboarding_models, current_onboarding_auth_state};
use crate::ai::onboarding::{
build_onboarding_models, current_onboarding_auth_state, onboarding_credit_packs,
};
use crate::ai::request_usage_model::AIRequestUsageModelEvent;
use crate::app_state::{AppState, PaneUuid, WindowSnapshot};
use crate::appearance::Appearance;
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
Expand Down Expand Up @@ -71,6 +74,7 @@ use crate::linear::LinearIssueWork;
use crate::notebooks::manager::NotebookSource;
use crate::pane_group::{NewTerminalOptions, PanesLayout};
use crate::persistence::ModelEvent;
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::auth::UserAuthenticationError;
Expand Down Expand Up @@ -140,6 +144,47 @@ fn offer_variant_for_account_class(account_class: FtueAccountClass) -> Option<Of
}
}

/// Relays the outcome of an onboarding-initiated credit purchase back to the
/// onboarding view. On the checkout path the credits arrive asynchronously, so
/// this only opens the browser; completion is detected later from the server's
/// AI credit availability decision.
fn handle_onboarding_credit_purchase_event(
onboarding_view: &ViewHandle<AgentOnboardingView>,
event: &UserWorkspacesEvent,
ctx: &mut ViewContext<RootView>,
) {
if !onboarding_view
.as_ref(ctx)
.is_awaiting_purchased_credits(ctx)
{
return;
}
match event {
UserWorkspacesEvent::PurchaseAddonCreditsSuccess => {
onboarding_view.update(ctx, |onboarding_view, ctx| {
onboarding_view.on_credit_purchase_completed(ctx);
});
}
UserWorkspacesEvent::PurchaseAddonCreditsCheckoutRequired { checkout_url } => {
let checkout_url = checkout_url.clone();
onboarding_view.update(ctx, |onboarding_view, ctx| {
onboarding_view.on_credit_purchase_checkout_opened(ctx);
});
ctx.open_url(&checkout_url);
}
UserWorkspacesEvent::PurchaseAddonCreditsRejected(err) => {
safe_error!(
safe: ("Onboarding add-on credits purchase failed"),
full: ("Onboarding add-on credits purchase failed: {err}")
);
onboarding_view.update(ctx, |onboarding_view, ctx| {
onboarding_view.on_credit_purchase_failed(ctx);
});
}
_ => {}
}
}

#[derive(Debug, Clone)]
enum WindowState {
/// Quake mode window is open and visible on the screen.
Expand Down Expand Up @@ -1690,6 +1735,9 @@ enum AccountFirstCompletion {
PaidTeam,
FreeIcpSetupLater,
FreeStandardSetupLater,
/// The user bought a one-time credit pack on the offer slide instead of
/// subscribing. They stay on the free plan, so they remain free-standard.
FreeStandardCreditsPurchased,
UpgradeCompleted,
}

Expand All @@ -1700,6 +1748,9 @@ impl AccountFirstCompletion {
AccountFirstCompletion::PaidTeam => "paid_team",
AccountFirstCompletion::FreeIcpSetupLater => "free_icp_setup_later",
AccountFirstCompletion::FreeStandardSetupLater => "free_standard_setup_later",
AccountFirstCompletion::FreeStandardCreditsPurchased => {
"free_standard_credits_purchased"
}
AccountFirstCompletion::UpgradeCompleted => "upgrade_completed",
}
}
Expand All @@ -1711,7 +1762,10 @@ impl AccountFirstCompletion {
Some(FtueAccountClass::Paid)
}
AccountFirstCompletion::FreeIcpSetupLater => Some(FtueAccountClass::FreeIcp),
AccountFirstCompletion::FreeStandardSetupLater => Some(FtueAccountClass::FreeStandard),
AccountFirstCompletion::FreeStandardSetupLater
| AccountFirstCompletion::FreeStandardCreditsPurchased => {
Some(FtueAccountClass::FreeStandard)
}
}
}

Expand All @@ -1721,6 +1775,7 @@ impl AccountFirstCompletion {
AccountFirstCompletion::PaidTeam
| AccountFirstCompletion::FreeIcpSetupLater
| AccountFirstCompletion::FreeStandardSetupLater
| AccountFirstCompletion::FreeStandardCreditsPurchased
| AccountFirstCompletion::UpgradeCompleted
)
}
Expand Down Expand Up @@ -2124,7 +2179,7 @@ impl RootView {

let auth_state = current_onboarding_auth_state(ctx);

AgentOnboardingView::new(
let mut view = AgentOnboardingView::new(
themes.clone(),
false, // Always use unskippable onboarding.
models,
Expand All @@ -2133,9 +2188,24 @@ impl RootView {
FeatureFlag::AgentView.is_enabled(),
auth_state,
ctx,
)
);
view.set_credit_pack_options(onboarding_credit_packs(ctx), ctx);
view
});

// Keep the offer slide's credit packs in sync with server pricing.
let onboarding_view_for_pricing = onboarding_view.clone();
ctx.subscribe_to_model(
&PricingInfoModel::handle(ctx),
move |_, _pricing, event, ctx| {
let PricingInfoModelEvent::PricingInfoUpdated = event;
let options = onboarding_credit_packs(ctx);
onboarding_view_for_pricing.update(ctx, |onboarding_view, ctx| {
onboarding_view.set_credit_pack_options(options, ctx);
});
},
);

let onboarding_view_clone = onboarding_view.clone();
ctx.subscribe_to_model(
&LLMPreferences::handle(ctx),
Expand Down Expand Up @@ -2169,9 +2239,35 @@ impl RootView {
.set_workspace_enforces_autonomy(workspace_enforces_autonomy, ctx);
});
}
handle_onboarding_credit_purchase_event(
&onboarding_view_for_workspaces,
event,
ctx,
);
let auth_state = current_onboarding_auth_state(ctx);
let credit_pack_options = onboarding_credit_packs(ctx);
onboarding_view_for_workspaces.update(ctx, |onboarding_view, ctx| {
onboarding_view.set_auth_state(auth_state, ctx);
// The purchase policy (and so the premium) comes from the
// user's workspace, so a metadata refresh can move the
// displayed prices.
onboarding_view.set_credit_pack_options(credit_pack_options, ctx);
});
},
);

// Browser checkout doesn't report back to the app, so the purchase is
// only complete once the user can actually make an AI request.
let onboarding_view_for_usage = onboarding_view.clone();
ctx.subscribe_to_model(
&AIRequestUsageModel::handle(ctx),
move |_, _usage, event, ctx| {
if !matches!(event, AIRequestUsageModelEvent::CreditAvailabilityUpdated) {
return;
}
let available = AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx);
onboarding_view_for_usage.update(ctx, |onboarding_view, ctx| {
onboarding_view.on_ai_credit_availability_observed(available, ctx);
});
},
);
Expand Down Expand Up @@ -2849,6 +2945,21 @@ impl RootView {
self.complete_account_first(AccountFirstCompletion::FreeStandardSetupLater, ctx)
}
},
AgentOnboardingEvent::PurchaseCreditsRequested { credits } => {
let credits = *credits;
let team_uid = UserWorkspaces::as_ref(ctx).team_uid_for_window(ctx.window_id());
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.purchase_addon_credits(team_uid, credits, ctx);
});
}
AgentOnboardingEvent::OfferCreditsPurchased { variant } => match variant {
// Only the free-standard offer surfaces credit packs.
OfferVariant::ChooseHowToStart => self.complete_account_first(
AccountFirstCompletion::FreeStandardCreditsPurchased,
ctx,
),
OfferVariant::HeadStart => {}
},
AgentOnboardingEvent::AppBecameActive => {
// fetch the models / workspace metadata when the user tabs/intents back
// into the app during onboarding after potentially upgrading
Expand Down
8 changes: 8 additions & 0 deletions app/src/root_view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ fn account_first_completion_metadata_matches_terminal_outcomes() {
Some(FtueAccountClass::FreeStandard),
true,
),
(
AccountFirstCompletion::FreeStandardCreditsPurchased,
"free_standard_credits_purchased",
// Buying an ad-hoc credit pack does not put the user on a plan, so
// they stay free-standard.
Some(FtueAccountClass::FreeStandard),
true,
),
(
AccountFirstCompletion::UpgradeCompleted,
"upgrade_completed",
Expand Down
Loading