collab: Add separate billing_customers table (#15457)

This PR adds a new `billing_customers` table to hold the billing
customers.

Previously we were storing both the `stripe_customer_id` and
`stripe_subscription_id` in the `billable_subscriptions` table. However,
this creates problems when we need to correlate subscription events back
to the subscription record, as we don't know the user that the Stripe
event corresponds to.

By moving the `stripe_customer_id` to a separate table we can create the
Stripe customer earlier in the flow—before we create the Stripe Checkout
session—and associate that customer with a user. This way when we
receive events down the line we can use the Stripe customer ID to
correlate it back to the user.

We're doing some destructive actions to the `billing_subscriptions`
table, but this is fine, as we haven't started using them yet.

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2024-07-29 22:48:21 -04:00 committed by GitHub
parent 66121fa0e8
commit 28c14cdee4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 183 additions and 52 deletions

View file

@ -3,7 +3,6 @@ use std::sync::Arc;
use anyhow::{anyhow, Context};
use axum::{extract, routing::post, Extension, Json, Router};
use collections::HashSet;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use stripe::{
@ -11,7 +10,7 @@ use stripe::{
CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
CreateBillingPortalSessionFlowDataType, CreateCheckoutSession, CreateCheckoutSessionLineItems,
CustomerId,
CreateCustomer, Customer, CustomerId,
};
use crate::db::BillingSubscriptionId;
@ -59,28 +58,27 @@ async fn create_billing_subscription(
))?
};
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"))?;
}
let customer_id =
if let Some(existing_customer) = app.db.get_billing_customer_by_user_id(user.id).await? {
CustomerId::from_str(&existing_customer.stripe_customer_id)
.context("failed to parse customer ID")?
} else {
let customer = Customer::create(
&stripe_client,
CreateCustomer {
email: user.email_address.as_deref(),
..Default::default()
},
)
.await?;
distinct_customer_ids
.into_iter()
.next()
.map(|id| CustomerId::from_str(id).context("failed to parse customer ID"))
.transpose()
}?;
customer.id
};
let checkout_session = {
let mut params = CreateCheckoutSession::new();
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.customer = existing_customer_id;
params.customer = Some(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()),
@ -140,6 +138,14 @@ async fn manage_billing_subscription(
))?
};
let customer = app
.db
.get_billing_customer_by_user_id(user.id)
.await?
.ok_or_else(|| anyhow!("billing customer not found"))?;
let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
.context("failed to parse customer ID")?;
let subscription = if let Some(subscription_id) = body.subscription_id {
app.db
.get_billing_subscription_by_id(subscription_id)
@ -158,9 +164,6 @@ async fn manage_billing_subscription(
.ok_or_else(|| anyhow!("user has no active subscriptions"))?
};
let customer_id = CustomerId::from_str(&subscription.stripe_customer_id)
.context("failed to parse customer ID")?;
let flow = match body.intent {
ManageSubscriptionIntent::Cancel => CreateBillingPortalSessionFlowData {
type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,