collab: Add endpoint for migrating users to new billing (#29769)

This PR adds a new `POST /billing/subscriptions/migrate` endpoint for
migrating users to the new billing system.

When called with a GitHub user ID this endpoint will:

1. Find the active billing subscription for this user (if they have one)
2. Cancel the subscription and send a final invoice
3. Ensure the user is in the `new-billing` and `assistant2` feature
flags

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2025-05-01 21:47:09 -04:00 committed by GitHub
parent d25da9728b
commit 1ffedf4a08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -54,6 +54,10 @@ pub fn router() -> Router {
"/billing/subscriptions/manage",
post(manage_billing_subscription),
)
.route(
"/billing/subscriptions/migrate",
post(migrate_to_new_billing),
)
.route("/billing/monthly_spend", get(get_monthly_spend))
.route("/billing/usage", get(get_current_usage))
}
@ -610,6 +614,85 @@ async fn manage_billing_subscription(
}))
}
#[derive(Debug, Deserialize)]
struct MigrateToNewBillingBody {
github_user_id: i32,
}
#[derive(Debug, Serialize)]
struct MigrateToNewBillingResponse {
/// The ID of the subscription that was canceled.
canceled_subscription_id: String,
}
async fn migrate_to_new_billing(
Extension(app): Extension<Arc<AppState>>,
extract::Json(body): extract::Json<MigrateToNewBillingBody>,
) -> Result<Json<MigrateToNewBillingResponse>> {
let Some(stripe_client) = app.stripe_client.clone() else {
log::error!("failed to retrieve Stripe client");
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let user = app
.db
.get_user_by_github_user_id(body.github_user_id)
.await?
.ok_or_else(|| anyhow!("user not found"))?;
let old_billing_subscriptions_by_user = app
.db
.get_active_billing_subscriptions(HashSet::from_iter([user.id]))
.await?;
let Some((_billing_customer, billing_subscription)) =
old_billing_subscriptions_by_user.get(&user.id)
else {
return Err(Error::http(
StatusCode::NOT_FOUND,
"No active billing subscriptions to migrate".into(),
));
};
let stripe_subscription_id = billing_subscription
.stripe_subscription_id
.parse::<stripe::SubscriptionId>()
.context("failed to parse Stripe subscription ID from database")?;
Subscription::cancel(
&stripe_client,
&stripe_subscription_id,
stripe::CancelSubscription {
invoice_now: Some(true),
..Default::default()
},
)
.await?;
let feature_flags = app.db.list_feature_flags().await?;
for feature_flag in ["new-billing", "assistant2"] {
let already_in_feature_flag = feature_flags.iter().any(|flag| flag.flag == feature_flag);
if already_in_feature_flag {
continue;
}
let feature_flag = feature_flags
.iter()
.find(|flag| flag.flag == feature_flag)
.context("failed to find feature flag: {feature_flag:?}")?;
app.db.add_user_flag(user.id, feature_flag.id).await?;
}
Ok(Json(MigrateToNewBillingResponse {
canceled_subscription_id: stripe_subscription_id.to_string(),
}))
}
/// The amount of time we wait in between each poll of Stripe events.
///
/// This value should strike a balance between: