Add overdue invoices check (#31290)

Ben Brandt and Marshall Bowers created

- Rename current_user_account_too_young to account_too_young for
consistency
- Add has_overdue_invoices field to track billing status
- Block edit predictions when user has overdue invoices
- Add overdue invoice warning to inline completion menu

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

crates/agent/src/agent_panel.rs                                 |  2 
crates/client/src/user.rs                                       | 12 
crates/collab/src/llm/token.rs                                  |  6 
crates/collab/src/rpc.rs                                        |  4 
crates/inline_completion_button/src/inline_completion_button.rs | 42 ++
crates/proto/proto/app.proto                                    |  1 
crates/zeta/src/zeta.rs                                         |  5 
7 files changed, 65 insertions(+), 7 deletions(-)

Detailed changes

crates/agent/src/agent_panel.rs 🔗

@@ -1973,7 +1973,7 @@ impl AgentPanel {
             return None;
         }
 
-        if self.user_store.read(cx).current_user_account_too_young() {
+        if self.user_store.read(cx).account_too_young() {
             Some(self.render_young_account_upsell(cx).into_any_element())
         } else {
             Some(self.render_trial_upsell(cx).into_any_element())

crates/client/src/user.rs 🔗

@@ -109,6 +109,7 @@ pub struct UserStore {
     edit_predictions_usage_limit: Option<proto::UsageLimit>,
     is_usage_based_billing_enabled: Option<bool>,
     account_too_young: Option<bool>,
+    has_overdue_invoices: Option<bool>,
     current_user: watch::Receiver<Option<Arc<User>>>,
     accepted_tos_at: Option<Option<DateTime<Utc>>>,
     contacts: Vec<Arc<Contact>>,
@@ -176,6 +177,7 @@ impl UserStore {
             edit_predictions_usage_limit: None,
             is_usage_based_billing_enabled: None,
             account_too_young: None,
+            has_overdue_invoices: None,
             accepted_tos_at: None,
             contacts: Default::default(),
             incoming_contact_requests: Default::default(),
@@ -350,6 +352,7 @@ impl UserStore {
                 .and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0));
             this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled;
             this.account_too_young = message.payload.account_too_young;
+            this.has_overdue_invoices = message.payload.has_overdue_invoices;
 
             if let Some(usage) = message.payload.usage {
                 this.model_request_usage_amount = Some(usage.model_requests_usage_amount);
@@ -755,11 +758,16 @@ impl UserStore {
         self.current_user.clone()
     }
 
-    /// Check if the current user's account is too new to use the service
-    pub fn current_user_account_too_young(&self) -> bool {
+    /// Returns whether the user's account is too new to use the service.
+    pub fn account_too_young(&self) -> bool {
         self.account_too_young.unwrap_or(false)
     }
 
+    /// Returns whether the current user has overdue invoices and usage should be blocked.
+    pub fn has_overdue_invoices(&self) -> bool {
+        self.has_overdue_invoices.unwrap_or(false)
+    }
+
     pub fn current_user_has_accepted_terms(&self) -> Option<bool> {
         self.accepted_tos_at
             .map(|accepted_tos_at| accepted_tos_at.is_some())

crates/collab/src/llm/token.rs 🔗

@@ -1,5 +1,5 @@
 use crate::db::billing_subscription::SubscriptionKind;
-use crate::db::{billing_subscription, user};
+use crate::db::{billing_customer, billing_subscription, user};
 use crate::llm::AGENT_EXTENDED_TRIAL_FEATURE_FLAG;
 use crate::{Config, db::billing_preference};
 use anyhow::{Context as _, Result};
@@ -32,6 +32,8 @@ pub struct LlmTokenClaims {
     pub enable_model_request_overages: bool,
     pub model_request_overages_spend_limit_in_cents: u32,
     pub can_use_web_search_tool: bool,
+    #[serde(default)]
+    pub has_overdue_invoices: bool,
 }
 
 const LLM_TOKEN_LIFETIME: Duration = Duration::from_secs(60 * 60);
@@ -40,6 +42,7 @@ impl LlmTokenClaims {
     pub fn create(
         user: &user::Model,
         is_staff: bool,
+        billing_customer: billing_customer::Model,
         billing_preferences: Option<billing_preference::Model>,
         feature_flags: &Vec<String>,
         subscription: billing_subscription::Model,
@@ -99,6 +102,7 @@ impl LlmTokenClaims {
                 .map_or(0, |preferences| {
                     preferences.model_request_overages_spend_limit_in_cents as u32
                 }),
+            has_overdue_invoices: billing_customer.has_overdue_invoices,
         };
 
         Ok(jsonwebtoken::encode(

crates/collab/src/rpc.rs 🔗

@@ -2748,6 +2748,7 @@ async fn make_update_user_plan_message(
     Ok(proto::UpdateUserPlan {
         plan: plan.into(),
         trial_started_at: billing_customer
+            .as_ref()
             .and_then(|billing_customer| billing_customer.trial_started_at)
             .map(|trial_started_at| trial_started_at.and_utc().timestamp() as u64),
         is_usage_based_billing_enabled: if is_staff {
@@ -2762,6 +2763,8 @@ async fn make_update_user_plan_message(
             }
         }),
         account_too_young: Some(account_too_young),
+        has_overdue_invoices: billing_customer
+            .map(|billing_customer| billing_customer.has_overdue_invoices),
         usage: usage.map(|usage| {
             let plan = match plan {
                 proto::Plan::Free => zed_llm_client::Plan::ZedFree,
@@ -4077,6 +4080,7 @@ async fn get_llm_api_token(
     let token = LlmTokenClaims::create(
         &user,
         session.is_staff(),
+        billing_customer,
         billing_preferences,
         &flags,
         billing_subscription,

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -745,7 +745,7 @@ impl InlineCompletionButton {
                         })
                     })
                     .separator();
-            } else if self.user_store.read(cx).current_user_account_too_young() {
+            } else if self.user_store.read(cx).account_too_young() {
                 menu = menu
                     .custom_entry(
                         |_window, _cx| {
@@ -785,6 +785,46 @@ impl InlineCompletionButton {
                         },
                     )
                     .separator();
+            } else if self.user_store.read(cx).has_overdue_invoices() {
+                menu = menu
+                    .custom_entry(
+                        |_window, _cx| {
+                            h_flex()
+                                .gap_1()
+                                .child(
+                                    Icon::new(IconName::Warning)
+                                        .size(IconSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .child(
+                                    Label::new("You have an outstanding invoice")
+                                        .size(LabelSize::Small)
+                                        .color(Color::Warning),
+                                )
+                                .into_any_element()
+                        },
+                        |window, cx| {
+                            window.dispatch_action(
+                                Box::new(OpenZedUrl {
+                                    url: zed_urls::account_url(cx),
+                                }),
+                                cx,
+                            );
+                        },
+                    )
+                    .entry(
+                        "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
+                        None,
+                        |window, cx| {
+                            window.dispatch_action(
+                                Box::new(OpenZedUrl {
+                                    url: zed_urls::account_url(cx),
+                                }),
+                                cx,
+                            );
+                        },
+                    )
+                    .separator();
             }
 
             self.build_language_settings_menu(menu, window, cx).when(

crates/proto/proto/app.proto 🔗

@@ -28,6 +28,7 @@ message UpdateUserPlan {
     optional SubscriptionUsage usage = 4;
     optional SubscriptionPeriod subscription_period = 5;
     optional bool account_too_young = 6;
+    optional bool has_overdue_invoices = 7;
 }
 
 message SubscriptionPeriod {

crates/zeta/src/zeta.rs 🔗

@@ -1578,8 +1578,9 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider
             .zeta
             .read(cx)
             .user_store
-            .read(cx)
-            .current_user_account_too_young()
+            .read_with(cx, |user_store, _| {
+                user_store.account_too_young() || user_store.has_overdue_invoices()
+            })
         {
             return;
         }