-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Add ad-hoc AI credit pack option to onboarding "Choose how to start" #14605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
094b625
eedb0f3
a4831a7
6a1313b
01840c7
7c5f2cf
e1a586b
7cec0e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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}; | ||
|
|
@@ -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; | ||
|
|
@@ -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> { | ||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. | ||
|
|
@@ -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, | ||
| } | ||
|
|
||
|
|
@@ -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", | ||
| } | ||
| } | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1721,6 +1802,7 @@ impl AccountFirstCompletion { | |
| AccountFirstCompletion::PaidTeam | ||
| | AccountFirstCompletion::FreeIcpSetupLater | ||
| | AccountFirstCompletion::FreeStandardSetupLater | ||
| | AccountFirstCompletion::FreeStandardCreditsPurchased | ||
| | AccountFirstCompletion::UpgradeCompleted | ||
| ) | ||
| } | ||
|
|
@@ -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, | ||
|
|
@@ -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), | ||
|
|
@@ -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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?. |
||
| }); | ||
| }, | ||
| ); | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| 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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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_viewshould be managing this.