Skip to content

Commit a8941be

Browse files
warp-agent-staging[bot]oz-agentwarp-agent
authored
Free-plan ad-hoc credit purchases at a 10% premium (desktop UI) [single-commit mirror of #14453] (#14584)
> **Single-commit mirror of #14453.** This PR carries the entire net content of #14453 (head `jlloyd/free-adhoc-credit-purchases-desktop` @ `7462b207`) as exactly **one commit** squashed onto current `master`. No new authorship: the source PR owns the change and its behavioural verification. The only content delta vs. the source branch is master's concurrent edit to `app/src/ai/request_usage_model_tests.rs` (adds `MockTelemetryContextProvider` registration), which the squash-merge picked up automatically — see *Mirror fidelity* below. --- ## Description Desktop UI for free-plan ad-hoc credit purchases at a 10% premium (Pricing Transparency M5, pulled forward). Server side: warpdotdev/warp-server#13446. Plan: https://staging.warp.dev/drive/notebook/uxcmHeaDP7Dd2RwDKFL6kd **What**: Free-plan (premium-policy) users — including fresh users with **no team yet**, the primary persona — get the full add-on credits purchase experience: billing pages (v1 + v2), out-of-credits banner, prompt alert, at premium-adjusted prices, including auto-reload. Per copy review, premium pricing is presented as one final price with a single savings-framed upsell ("Save 10% on add-on credits by upgrading to a Build plan."); no surcharge language or +X% badges anywhere. Branch is rebased onto master `02b53fcd8` (post-#14393 window-scoped team APIs). **How**: - GraphQL crate: `PurchaseAddOnCreditsPolicy` gains `premiumEnabled`/`pricePremiumBps`; the `PurchaseAddonCreditsResult` union gains a dedicated `PurchaseAddonCreditsCheckoutOutput` member (warpdotdev/warp-server#13618); vendored schema updated to match the server SDL. - Gating is centralized on the policy fields (`enabled || premiumEnabled` to show purchase surfaces; premium treatment iff `!enabled && premiumEnabled`). There is no client-side flag: while the server rollout flag is off it masks the policy, so this UI stays dormant and free plans keep today's upgrade-only nudge. - Display prices use `price_usd_cents_with_premium` — byte-identical integer ceiling math to the server — everywhere prices surface: pack rows, banner dropdown, auto-reload labels/tooltips, post-purchase modal, and all monthly-spend-limit checks (`Workspace::get_auto_reload_price_cents` is premium-adjusted centrally). - Purchases without a saved payment method return the checkout union member: the initiating surface opens the browser (single-opener via each surface's loading flag), shows a pending state, and credits land via existing polling. The banner's auto-reload opt-in persists only after purchased credits are observed landing, so an abandoned checkout never silently enables auto-reload. - Teamless support: `UserWorkspaces::purchase_policy_for_team` resolves the governing policy with a strict three-leg chain — team's → current workspace's → **user-level** `billingMetadata` (a slim, dedicated selection carrying only the purchase policy; the teamless fallback exists because a fresh free user's only workspace is the REV-717 placeholder, which the client rightly filters out — no placeholder mining). The stored user-level policy refreshes on all three metadata apply paths so it can't go stale. `teamUid` is `Option` end-to-end (omitted → the server auto-creates the personal team), teamless users are permitted for their own purchase, and team-settings affordances (spend-limit row, auto-reload switch) stay hidden until the server-created team lands. - Paid-tier rendering is unchanged (0 bps degenerates every new code path to existing values). **Why**: Free users' only option today is subscribing; the 10% premium is the subscription incentive, framed in-product as savings for upgrading. See the PRODUCT/TECH specs in the server PR. ## Linked Issue No GitHub issue; part of the Pricing Transparency effort tracked in the plan/spec linked above. - [ ] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. - [ ] Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes). ## Testing Automated: - `crates/graphql` unit tests for the premium price math (exact cents at 1000 bps, round-up edges, negative-bps guard). - `workspace_tests` for policy gating (premium enables purchasing; standard plans 0 bps; both-set falls back to standard). - `request_usage_model_tests` for banner display-state gating (with explicit teamless assertions), premium auto-reload counting as AI remaining, and a reload blocked at the premium price but not list price. - `user_workspaces_tests` + `update_manager_tests`: regression coverage through the REAL conversion/apply paths — a `GqlUser` fixture whose only workspace is the placeholder resolves the user-level premium policy while `workspaces` stays empty; workspace and team legs each win over the user leg; the poll-path apply stores and clears the fallback; plus `Option` teamUid forwarding to a mocked `WorkspaceClient`. - `cargo check` / `cargo clippy --all-targets --tests -- -D warnings` / `./script/format` clean on touched crates; targeted `cargo nextest` 48/48. Manual (against a local warp-server on the server feature branch, rollout flag on): 1. Free user → Settings > Billing & Usage (both page variants): packs at $11/$22/$55/$110, the "Save 10% on add-on credits by upgrading to a Build plan." note with a working upgrade link, and the auto-reload switch present with its standard copy (no premium annotations). 1a. Fresh free user with NO team: same panel appears (minus spend-limit row and auto-reload switch); buying with no card opens browser checkout, and after the webhook grant the personal team appears and the team-settings rows materialize. 2. Buy with no saved card → browser opens Stripe Checkout, pending state shown, credits land via polling; second purchase charges synchronously. 3. Out-of-credits banner: premium prices in dropdown, checkout handoff state, auto-reload checkbox opt-in applied after credits land. 4. Regression: Build-plan account renders identically to master; flag off → free plan shows the old upgrade nudge. - [ ] I have manually tested my changes locally with `./script/run` (Manual QA pass on staging pending — tracked before marking ready for review.) ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode Conversation: https://staging.warp.dev/conversation/a516b043-08e8-46c3-9ae5-9b903e94458b CHANGELOG-NONE Co-Authored-By: Warp <agent@warp.dev> Co-Authored-By: Oz <oz-agent@warp.dev> > **Deploy order**: depends on warpdotdev/warp-server#13618 being deployed before this ships, since the client selects the new `PurchaseAddonCreditsCheckoutOutput` union member. --- ## Mirror fidelity (factory verification) - Exactly one commit on top of `origin/master`: `git rev-list --count origin/master..HEAD` → `1`. - Net change is identical in shape to the source PR: **22 files changed, +1599 / -211** (same as #14453). - Content proof: `git diff jlloyd/free-adhoc-credit-purchases-desktop..HEAD -- <the 22 PR files>` is a **20-line diff confined to `app/src/ai/request_usage_model_tests.rs`**, consisting solely of master's newer `MockTelemetryContextProvider` test-harness registration that landed after the source branch's merge-base. Every other one of the 22 files is byte-identical to the source branch. The remainder of a raw two-dot diff against the source branch is master advancement (21 commits, 127 files) inherent to re-basing the content onto current `master`. - No tests were added and no code was edited beyond that automatic merge resolution (mirror of already-authored work). - UI/computer-use proof is intentionally **not** attached: this PR authors no new user-facing behaviour — it reproduces #14453's content verbatim, and #14453 owns the behavioural/visual verification for the feature. - Source PR #14453, its branch, and its review conversation were left untouched. Its comment threads were deliberately **not** carried over (only the code and the description are mirrored). Originating thread: https://warpdotdev.slack.com/archives/D0BGF2WSJBB/p1785503011978259 Linear ticket: https://linear.app/warpdotdev/issue/REV-1879/single-commit-mirror-pr-of-warp14453-free-plan-ad-hoc-credit-purchases <!-- factory-agent: {"source":"factory-agent","task_id":"REV-1879","task_source":"linear","task_url":"https://linear.app/warpdotdev/issue/REV-1879/single-commit-mirror-pr-of-warp14453-free-plan-ad-hoc-credit-purchases","oz_run_id":"019fb84a-d9be-7344-8367-1ba3d235578c","repo":"warpdotdev/warp"} --> Co-authored-by: Oz <oz-agent@warp.dev> Co-authored-by: Warp <agent@warp.dev>
1 parent eda0085 commit a8941be

22 files changed

Lines changed: 1599 additions & 211 deletions

app/src/ai/blocklist/prompt/prompt_alert.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,15 +374,18 @@ impl View for PromptAlertView {
374374
self.primary_text(&state, &mut text_fragments);
375375

376376
let auth_state = AuthStateProvider::as_ref(app).get();
377-
let current_team = UserWorkspaces::as_ref(app).team_for_view_handle(&self.view_handle, app);
378-
let has_admin_permissions = auth_state
379-
.user_email()
380-
.zip(current_team)
381-
.is_some_and(|(email, team)| team.has_admin_permissions(&email));
382-
383-
let can_purchase_addon_credits = current_team
384-
.and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy)
385-
.is_some_and(|policy| policy.enabled);
377+
let workspaces = UserWorkspaces::as_ref(app);
378+
let current_team = workspaces.team_for_view_handle(&self.view_handle, app);
379+
// A teamless user can be considered the admin of their non-existent team.
380+
let has_admin_permissions = current_team.is_none_or(|team| {
381+
auth_state
382+
.user_email()
383+
.is_some_and(|email| team.has_admin_permissions(&email))
384+
});
385+
386+
let can_purchase_addon_credits = workspaces
387+
.purchase_policy_for_team(current_team)
388+
.is_some_and(|policy| policy.allows_purchases());
386389

387390
let suggest_buy_credits = can_purchase_addon_credits
388391
&& has_admin_permissions

app/src/ai/request_usage_model.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -603,15 +603,11 @@ impl AIRequestUsageModel {
603603
if self.buy_addon_credits_banner_dismissed {
604604
return BuyCreditsBannerDisplayState::Hidden;
605605
}
606-
let current_workspace = UserWorkspaces::as_ref(ctx).current_workspace();
607-
let policy_allows_purchasing = current_workspace
608-
.map(|w| {
609-
w.billing_metadata
610-
.tier
611-
.purchase_add_on_credits_policy
612-
.is_some_and(|p| p.enabled)
613-
})
614-
.unwrap_or(false);
606+
let user_workspaces = UserWorkspaces::as_ref(ctx);
607+
let current_workspace = user_workspaces.current_workspace();
608+
let policy_allows_purchasing = user_workspaces
609+
.purchase_policy()
610+
.is_some_and(|policy| policy.allows_purchases());
615611

616612
// TODO: we might want to suggest credits purchase if request_remain/bonus credits is below certain threshold
617613
// something to consider after launch

app/src/ai/request_usage_model_tests.rs

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,22 @@ fn set_addon_credits_pricing_info(app: &mut App) {
9393
});
9494
}
9595

96+
fn standard_purchase_policy() -> PurchaseAddOnCreditsPolicy {
97+
PurchaseAddOnCreditsPolicy {
98+
enabled: true,
99+
premium_enabled: false,
100+
price_premium_bps: 0,
101+
}
102+
}
103+
104+
fn premium_purchase_policy() -> PurchaseAddOnCreditsPolicy {
105+
PurchaseAddOnCreditsPolicy {
106+
enabled: false,
107+
premium_enabled: true,
108+
price_premium_bps: 1000,
109+
}
110+
}
111+
96112
fn enable_auto_reload(workspace: &mut Workspace) {
97113
workspace
98114
.settings
@@ -239,7 +255,7 @@ fn test_buy_credits_banner_shows_with_only_ambient_bonus_credits() {
239255
workspace
240256
.billing_metadata
241257
.tier
242-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
258+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
243259

244260
add_user_workspaces_with_workspace(&mut app, workspace);
245261
let request_usage_model = add_request_usage_model(&mut app);
@@ -266,14 +282,75 @@ fn test_buy_credits_banner_shows_with_only_ambient_bonus_credits() {
266282
});
267283
}
268284

285+
#[test]
286+
fn test_buy_credits_banner_shows_for_premium_enabled_plan_out_of_credits() {
287+
App::test((), |mut app| async move {
288+
// The test workspace has no teams: this covers the teamless fresh
289+
// free user, whose purchase policy lives on the workspace billing
290+
// metadata until their first purchase creates a team server-side.
291+
let (_uid, mut workspace) = create_test_workspace();
292+
workspace
293+
.billing_metadata
294+
.tier
295+
.purchase_add_on_credits_policy = Some(premium_purchase_policy());
296+
297+
add_user_workspaces_with_workspace(&mut app, workspace);
298+
let request_usage_model = add_request_usage_model(&mut app);
299+
300+
request_usage_model.update(&mut app, |model, ctx| {
301+
assert!(
302+
!UserWorkspaces::as_ref(ctx).has_teams(),
303+
"this test covers the teamless case"
304+
);
305+
model.request_limit_info = RequestLimitInfo::new_for_test(10, 10);
306+
model.bonus_grants.clear();
307+
308+
assert_eq!(
309+
model.compute_buy_addon_credits_banner_display_state(ctx),
310+
BuyCreditsBannerDisplayState::OutOfCredits,
311+
);
312+
});
313+
});
314+
}
315+
316+
#[test]
317+
fn test_buy_credits_banner_hidden_when_policy_fully_disabled() {
318+
App::test((), |mut app| async move {
319+
// Also a teamless workspace: without premiumEnabled the purchase
320+
// surfaces must stay hidden for fresh free users.
321+
let (_uid, mut workspace) = create_test_workspace();
322+
workspace
323+
.billing_metadata
324+
.tier
325+
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy {
326+
enabled: false,
327+
premium_enabled: false,
328+
price_premium_bps: 0,
329+
});
330+
331+
add_user_workspaces_with_workspace(&mut app, workspace);
332+
let request_usage_model = add_request_usage_model(&mut app);
333+
334+
request_usage_model.update(&mut app, |model, ctx| {
335+
model.request_limit_info = RequestLimitInfo::new_for_test(10, 10);
336+
model.bonus_grants.clear();
337+
338+
assert_eq!(
339+
model.compute_buy_addon_credits_banner_display_state(ctx),
340+
BuyCreditsBannerDisplayState::Hidden,
341+
);
342+
});
343+
});
344+
}
345+
269346
#[test]
270347
fn test_buy_credits_banner_hidden_with_non_ambient_bonus_credits() {
271348
App::test((), |mut app| async move {
272349
let (_uid, mut workspace) = create_test_workspace();
273350
workspace
274351
.billing_metadata
275352
.tier
276-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
353+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
277354

278355
add_user_workspaces_with_workspace(&mut app, workspace);
279356
let request_usage_model = add_request_usage_model(&mut app);
@@ -307,7 +384,7 @@ fn test_buy_credits_banner_shows_when_non_ambient_bonus_credits_are_depleted() {
307384
workspace
308385
.billing_metadata
309386
.tier
310-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
387+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
311388

312389
add_user_workspaces_with_workspace(&mut app, workspace);
313390
let request_usage_model = add_request_usage_model(&mut app);
@@ -568,7 +645,7 @@ fn test_has_any_ai_remaining_true_with_self_serve_auto_reload() {
568645
workspace
569646
.billing_metadata
570647
.tier
571-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
648+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
572649
enable_auto_reload(&mut workspace);
573650

574651
add_user_workspaces_with_workspace(&mut app, workspace);
@@ -587,6 +664,65 @@ fn test_has_any_ai_remaining_true_with_self_serve_auto_reload() {
587664
});
588665
}
589666

667+
#[test]
668+
fn test_has_any_ai_remaining_true_with_premium_auto_reload() {
669+
App::test((), |mut app| async move {
670+
let (_uid, mut workspace) = create_test_workspace();
671+
workspace
672+
.billing_metadata
673+
.tier
674+
.purchase_add_on_credits_policy = Some(premium_purchase_policy());
675+
enable_auto_reload(&mut workspace);
676+
677+
add_user_workspaces_with_workspace(&mut app, workspace);
678+
let request_usage_model = add_request_usage_model(&mut app);
679+
set_addon_credits_pricing_info(&mut app);
680+
681+
request_usage_model.update(&mut app, |model, ctx| {
682+
model.request_limit_info = RequestLimitInfo::new_for_test(10, 10);
683+
model.bonus_grants.clear();
684+
685+
assert!(
686+
model.has_any_ai_remaining(ctx),
687+
"expected has_any_ai_remaining to be true when premium-plan auto-reload is enabled",
688+
);
689+
});
690+
});
691+
}
692+
693+
#[test]
694+
fn test_has_any_ai_remaining_false_when_premium_auto_reload_would_exceed_limit() {
695+
App::test((), |mut app| async move {
696+
let (_uid, mut workspace) = create_test_workspace();
697+
workspace
698+
.billing_metadata
699+
.tier
700+
.purchase_add_on_credits_policy = Some(premium_purchase_policy());
701+
enable_auto_reload(&mut workspace);
702+
// The reload's list price is $10.00 but the premium price is $11.00;
703+
// a $10.50 monthly limit only blocks the reload when the premium
704+
// surcharge is included in the check.
705+
workspace
706+
.settings
707+
.addon_credits_settings
708+
.max_monthly_spend_cents = Some(1050);
709+
710+
add_user_workspaces_with_workspace(&mut app, workspace);
711+
let request_usage_model = add_request_usage_model(&mut app);
712+
set_addon_credits_pricing_info(&mut app);
713+
714+
request_usage_model.update(&mut app, |model, ctx| {
715+
model.request_limit_info = RequestLimitInfo::new_for_test(10, 10);
716+
model.bonus_grants.clear();
717+
718+
assert!(
719+
!model.has_any_ai_remaining(ctx),
720+
"expected has_any_ai_remaining to be false when the premium-priced reload would exceed the monthly spend limit",
721+
);
722+
});
723+
});
724+
}
725+
590726
#[test]
591727
fn test_has_any_ai_remaining_true_with_self_serve_auto_reload_and_billing_v2_disabled() {
592728
App::test((), |mut app| async move {
@@ -596,7 +732,7 @@ fn test_has_any_ai_remaining_true_with_self_serve_auto_reload_and_billing_v2_dis
596732
workspace
597733
.billing_metadata
598734
.tier
599-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
735+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
600736
enable_auto_reload(&mut workspace);
601737

602738
add_user_workspaces_with_workspace(&mut app, workspace);
@@ -622,7 +758,7 @@ fn test_has_any_ai_remaining_false_with_add_on_credits_policy_when_purchase_woul
622758
workspace
623759
.billing_metadata
624760
.tier
625-
.purchase_add_on_credits_policy = Some(PurchaseAddOnCreditsPolicy { enabled: true });
761+
.purchase_add_on_credits_policy = Some(standard_purchase_policy());
626762
enable_auto_reload(&mut workspace);
627763
workspace
628764
.settings

app/src/server/server_api/workspace.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ use crate::server::ids::ServerId;
2828
use crate::workspaces::user_workspaces::WorkspacesMetadataResponse;
2929
use crate::workspaces::workspace::AiOverages;
3030

31+
/// Outcome of a successful `purchaseAddonCredits` mutation. Mirrors the
32+
/// server's `PurchaseAddonCreditsResult` union members one-to-one.
33+
pub enum PurchaseAddonCreditsOutcome {
34+
/// The saved payment method was charged synchronously and credits were
35+
/// granted immediately. Carries refreshed workspace metadata.
36+
Completed(Box<WorkspacesMetadataResponse>),
37+
/// There was no saved payment method to charge. The user must complete
38+
/// the purchase in the browser at `checkout_url`; credits are granted
39+
/// via webhook shortly after checkout completes.
40+
CheckoutRequired { checkout_url: String },
41+
}
42+
3143
#[cfg_attr(test, automock)]
3244
#[cfg_attr(not(target_family = "wasm"), async_trait)]
3345
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
@@ -45,9 +57,9 @@ pub trait WorkspaceClient: 'static + Send + Sync {
4557

4658
async fn purchase_addon_credits(
4759
&self,
48-
team_uid: ServerId,
60+
team_uid: Option<ServerId>,
4961
credits: i32,
50-
) -> Result<WorkspacesMetadataResponse>;
62+
) -> Result<PurchaseAddonCreditsOutcome>;
5163

5264
async fn update_addon_credits_settings(
5365
&self,
@@ -151,12 +163,12 @@ impl WorkspaceClient for ServerApi {
151163

152164
async fn purchase_addon_credits(
153165
&self,
154-
team_uid: ServerId,
166+
team_uid: Option<ServerId>,
155167
credits: i32,
156-
) -> Result<WorkspacesMetadataResponse> {
168+
) -> Result<PurchaseAddonCreditsOutcome> {
157169
let variables = PurchaseAddonCreditsVariables {
158170
input: PurchaseAddonCreditsInput {
159-
team_uid: team_uid.into(),
171+
team_uid: team_uid.map(Into::into),
160172
credits,
161173
},
162174
request_context: get_request_context(),
@@ -170,7 +182,12 @@ impl WorkspaceClient for ServerApi {
170182
PurchaseAddonCreditsResult::PurchaseAddonCreditsOutput(_) => {
171183
TeamClient::workspaces_metadata(self)
172184
.await
173-
.map(|w| w.metadata)
185+
.map(|w| PurchaseAddonCreditsOutcome::Completed(Box::new(w.metadata)))
186+
}
187+
PurchaseAddonCreditsResult::PurchaseAddonCreditsCheckoutOutput(output) => {
188+
Ok(PurchaseAddonCreditsOutcome::CheckoutRequired {
189+
checkout_url: output.checkout_url,
190+
})
174191
}
175192
PurchaseAddonCreditsResult::UserFacingError(error) => match error.error {
176193
UserFacingErrorInterface::BudgetExceededError(budget_error) => {

0 commit comments

Comments
 (0)