collab: Add endpoint for initiating a billing subscription (#15452)

This PR adds a new `POST /billing/subscriptions` endpoint that can be
used to initiate a billing subscription.

The endpoint will use the provided `github_user_id` to look up a user,
generate a Stripe Checkout session, and then return the URL.

The caller would then redirect the user to the URL to initiate the
checkout flow.

Here's an example of how to call it:

```sh
curl -X POST "http://localhost:8080/billing/subscriptions" \
     -H "Authorization: <ADMIN_TOKEN>" \
     -H "Content-Type: application/json" \
     -d '{"github_user_id": 12345}'
```

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2024-07-29 17:31:36 -04:00 committed by GitHub
parent 8bb34fd93e
commit e15d59c445
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 258 additions and 0 deletions

View file

@ -0,0 +1,88 @@
use std::str::FromStr;
use std::sync::Arc;
use anyhow::anyhow;
use axum::{extract, routing::post, Extension, Json, Router};
use collections::HashSet;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use stripe::{CheckoutSession, CreateCheckoutSession, CreateCheckoutSessionLineItems, CustomerId};
use crate::{AppState, Error, Result};
pub fn router() -> Router {
Router::new().route("/billing/subscriptions", post(create_billing_subscription))
}
#[derive(Debug, Deserialize)]
struct CreateBillingSubscriptionBody {
github_user_id: i32,
}
#[derive(Debug, Serialize)]
struct CreateBillingSubscriptionResponse {
checkout_session_url: String,
}
/// Initiates a Stripe Checkout session for creating a billing subscription.
async fn create_billing_subscription(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<CreateBillingSubscriptionBody>,
) -> Result<Json<CreateBillingSubscriptionResponse>> {
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.ok_or_else(|| anyhow!("user not found"))?;
let Some((stripe_client, stripe_price_id)) = app
.stripe_client
.clone()
.zip(app.config.stripe_price_id.clone())
else {
log::error!("failed to retrieve Stripe client or price ID");
Err(Error::Http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let existing_customer_id = {
let existing_subscriptions = app.db.get_billing_subscriptions(user.id).await?;
let distinct_customer_ids = existing_subscriptions
.iter()
.map(|subscription| subscription.stripe_customer_id.as_str())
.collect::<HashSet<_>>();
// Sanity: Make sure we can determine a single Stripe customer ID for the user.
if distinct_customer_ids.len() > 1 {
Err(anyhow!("user has multiple existing customer IDs"))?;
}
distinct_customer_ids
.into_iter()
.next()
.map(|id| CustomerId::from_str(id).map_err(|err| anyhow!(err)))
.transpose()
}?;
let checkout_session = {
let mut params = CreateCheckoutSession::new();
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.customer = existing_customer_id;
params.client_reference_id = Some(user.github_login.as_str());
params.line_items = Some(vec![CreateCheckoutSessionLineItems {
price: Some(stripe_price_id.to_string()),
quantity: Some(1),
..Default::default()
}]);
params.success_url = Some("https://zed.dev/billing/success");
CheckoutSession::create(&stripe_client, params).await?
};
Ok(Json(CreateBillingSubscriptionResponse {
checkout_session_url: checkout_session
.url
.ok_or_else(|| anyhow!("no checkout session URL"))?,
}))
}