collab: Add `GET /billing/subscriptions` endpoint (#15516)

Marshall Bowers created

This PR adds a new `GET /billing/subscriptions` endpoint to collab for
retrieving the subscriptions to display on the account settings page.

Release Notes:

- N/A

Change summary

crates/collab/src/api/billing.rs                    | 59 +++++++++++++-
crates/collab/src/db/tables/billing_subscription.rs | 19 ++++
2 files changed, 73 insertions(+), 5 deletions(-)

Detailed changes

crates/collab/src/api/billing.rs 🔗

@@ -3,7 +3,11 @@ use std::sync::Arc;
 use std::time::Duration;
 
 use anyhow::{anyhow, bail, Context};
-use axum::{extract, routing::post, Extension, Json, Router};
+use axum::{
+    extract::{self, Query},
+    routing::{get, post},
+    Extension, Json, Router,
+};
 use reqwest::StatusCode;
 use sea_orm::ActiveValue;
 use serde::{Deserialize, Serialize};
@@ -27,13 +31,60 @@ use crate::{AppState, Error, Result};
 
 pub fn router() -> Router {
     Router::new()
-        .route("/billing/subscriptions", post(create_billing_subscription))
+        .route(
+            "/billing/subscriptions",
+            get(list_billing_subscriptions).post(create_billing_subscription),
+        )
         .route(
             "/billing/subscriptions/manage",
             post(manage_billing_subscription),
         )
 }
 
+#[derive(Debug, Deserialize)]
+struct ListBillingSubscriptionsParams {
+    github_user_id: i32,
+}
+
+#[derive(Debug, Serialize)]
+struct BillingSubscriptionJson {
+    id: BillingSubscriptionId,
+    name: String,
+    status: StripeSubscriptionStatus,
+    /// Whether this subscription can be canceled.
+    is_cancelable: bool,
+}
+
+#[derive(Debug, Serialize)]
+struct ListBillingSubscriptionsResponse {
+    subscriptions: Vec<BillingSubscriptionJson>,
+}
+
+async fn list_billing_subscriptions(
+    Extension(app): Extension<Arc<AppState>>,
+    Query(params): Query<ListBillingSubscriptionsParams>,
+) -> Result<Json<ListBillingSubscriptionsResponse>> {
+    let user = app
+        .db
+        .get_user_by_github_user_id(params.github_user_id)
+        .await?
+        .ok_or_else(|| anyhow!("user not found"))?;
+
+    let subscriptions = app.db.get_billing_subscriptions(user.id).await?;
+
+    Ok(Json(ListBillingSubscriptionsResponse {
+        subscriptions: subscriptions
+            .into_iter()
+            .map(|subscription| BillingSubscriptionJson {
+                id: subscription.id,
+                name: "Zed Pro".to_string(),
+                status: subscription.stripe_subscription_status,
+                is_cancelable: subscription.stripe_subscription_status.is_cancelable(),
+            })
+            .collect(),
+    }))
+}
+
 #[derive(Debug, Deserialize)]
 struct CreateBillingSubscriptionBody {
     github_user_id: i32,
@@ -179,7 +230,7 @@ async fn manage_billing_subscription(
             after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
                 type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
                 redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
-                    return_url: "https://zed.dev/billing".into(),
+                    return_url: "https://zed.dev/settings".into(),
                 }),
                 ..Default::default()
             }),
@@ -195,7 +246,7 @@ async fn manage_billing_subscription(
 
     let mut params = CreateBillingPortalSession::new(customer_id);
     params.flow_data = Some(flow);
-    params.return_url = Some("https://zed.dev/billing");
+    params.return_url = Some("https://zed.dev/settings");
 
     let session = BillingPortalSession::create(&stripe_client, params).await?;
 

crates/collab/src/db/tables/billing_subscription.rs 🔗

@@ -1,5 +1,6 @@
 use crate::db::{BillingCustomerId, BillingSubscriptionId};
 use sea_orm::entity::prelude::*;
+use serde::Serialize;
 
 /// A billing subscription.
 #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@@ -34,8 +35,11 @@ impl ActiveModelBehavior for ActiveModel {}
 /// The status of a Stripe subscription.
 ///
 /// [Stripe docs](https://docs.stripe.com/api/subscriptions/object#subscription_object-status)
-#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
+#[derive(
+    Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash, Serialize,
+)]
 #[sea_orm(rs_type = "String", db_type = "String(None)")]
+#[serde(rename_all = "snake_case")]
 pub enum StripeSubscriptionStatus {
     #[default]
     #[sea_orm(string_value = "incomplete")]
@@ -55,3 +59,16 @@ pub enum StripeSubscriptionStatus {
     #[sea_orm(string_value = "paused")]
     Paused,
 }
+
+impl StripeSubscriptionStatus {
+    pub fn is_cancelable(&self) -> bool {
+        match self {
+            Self::Trialing | Self::Active | Self::PastDue => true,
+            Self::Incomplete
+            | Self::IncompleteExpired
+            | Self::Canceled
+            | Self::Unpaid
+            | Self::Paused => false,
+        }
+    }
+}