Skip to content
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]);
}
158 changes: 153 additions & 5 deletions app/src/root_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use cfg_if::cfg_if;
use itertools::Itertools;
use lazy_static::lazy_static;
use onboarding::{
AgentOnboardingEvent, AgentOnboardingView, OfferVariant, OnboardingEvent, OnboardingIntention,
SelectedSettings,
AgentOnboardingEvent, AgentOnboardingView, CreditPackOption, OfferVariant, OnboardingEvent,
OnboardingIntention, SelectedSettings,
};
use parking_lot::Mutex;
use pathfinder_geometry::rect::RectF;
Expand Down Expand Up @@ -42,6 +42,7 @@ 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::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 +72,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, onboarding_credit_pack_options};
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 +142,76 @@ fn offer_variant_for_account_class(account_class: FtueAccountClass) -> Option<Of
}
}

/// 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.
fn current_credit_pack_options(ctx: &AppContext) -> Vec<CreditPackOption> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we find different places for functions like these? I don't think the root_view should be managing this.

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())
}

/// The credits a purchase lands in: the non-expired bonus grants scoped to the
/// user and to their current workspace. Purchased add-on credits are granted as
/// bonus credits, so this is the balance that says whether the user has
/// purchasable AI credits — unlike "has any AI remaining", which is also true
/// for base plan requests, BYOK credentials, overages and auto-reload, and so
/// would advance a brand-new free user who cancelled checkout.
fn purchased_credit_balance(ctx: &AppContext) -> i32 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned you still aren't doing the thing of moving to the next step right, e.g. here, what's this for? AFAIK, before our changes here, we did a thing where if the app got focus, then we'd check to see if the user subbed to a plan, and if they did, we'd progress through the onboarding flow. This was a fail-safe in case the user didn't use the intent link from the checkout confirmation page. We don't need to be checking "did they buy credits" - the only thing we need to check is "can they make AI requests", via the user { aiCreditAvailability } part of the graph. If we weren't checking that before, then we can check it now.

The point is - I care way less about how they have access to credits in onboarding. It doesn't need to be a one-to-one "they def went thru the addon credit checkout flow and thus they can move on". All we care about is preventing people who don't have credits from getting into the app without realizing, oh shit, I can't use AI unless I pay.

let usage = AIRequestUsageModel::as_ref(ctx);
usage.total_user_interactive_bonus_credits_remaining()
+ usage.total_current_workspace_bonus_credits_remaining(ctx)
}

/// 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 from a later usage
/// refresh.
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 +1762,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 +1775,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 +1789,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 +1802,7 @@ impl AccountFirstCompletion {
AccountFirstCompletion::PaidTeam
| AccountFirstCompletion::FreeIcpSetupLater
| AccountFirstCompletion::FreeStandardSetupLater
| AccountFirstCompletion::FreeStandardCreditsPurchased
| AccountFirstCompletion::UpgradeCompleted
)
}
Expand Down Expand Up @@ -2124,7 +2206,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 +2215,24 @@ impl RootView {
FeatureFlag::AgentView.is_enabled(),
auth_state,
ctx,
)
);
view.set_credit_pack_options(current_credit_pack_options(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 = current_credit_pack_options(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 +2266,37 @@ 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 = current_credit_pack_options(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) can change with
// the user's teams, which moves the displayed prices.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you mean "the user's workspace"?

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 granted credits show up on a usage refresh.
let onboarding_view_for_usage = onboarding_view.clone();
ctx.subscribe_to_model(
&AIRequestUsageModel::handle(ctx),
move |_, _usage, event, ctx| {
if !matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
return;
}
// The view completes the purchase only if this balance is
// non-zero, so a brand-new user who cancels checkout stays on
// the slide.
let credits_now = purchased_credit_balance(ctx);
onboarding_view_for_usage.update(ctx, |onboarding_view, ctx| {
onboarding_view.on_purchased_credit_balance_observed(credits_now, ctx);
Comment on lines +2294 to +2299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this seems too specific. A user with some number of base credits (because they are an ICP) should not see the slide we're working on in the first place, is that correct? If so, then the fact is that they have 0 base credits, and the server now saying "you can make AI requests" is generic enough of an answer that we can trust it without needing to say specifically did you purchase addon credits?.

});
},
);
Expand Down Expand Up @@ -2849,6 +2974,23 @@ impl RootView {
self.complete_account_first(AccountFirstCompletion::FreeStandardSetupLater, ctx)
}
},
AgentOnboardingEvent::PurchaseCreditsRequested { credits } => {
// `team_uid` is intentionally omitted: the server resolves the
// buyer's personal team and creates one when a brand-new free
// user doesn't have one yet.
let credits = *credits;
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
user_workspaces.purchase_addon_credits(None, credits, ctx);
});
Comment on lines +2978 to +2984

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda feel like, to be totally correct, we should be passing in the current team for the view we're in. In 99% of cases, in onboarding, there will be no team for the user. But we have things like team discovery or domain capture, which could land a user on a team during sign up. In that case, I wouldn't want part of the code here assuming that the user isn't on a team. If they aren't, then great, our lookup of "what team they're on" will return None and this will work as you intended. If they are, then the server at least won't blow up, confused that the caller passed in None but they are on one or more teams.

}
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 All @@ -2858,6 +3000,12 @@ impl RootView {
TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| {
drop(manager.refresh_workspace_metadata(ctx));
});
// Returning from browser checkout is the usual way an
// onboarding credit purchase completes, so refresh usage too:
// the granted credits are what allow onboarding to advance.
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
drop(model.refresh_request_usage(ctx));
});
Comment on lines +3003 to +3008

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not even be necessary, now that I just landed a change which adds the AI availability to the workspace metadata query.

}
}
}
Expand Down
Loading
Loading