From cb282919760b55d8aa05cc42a3cb41bcf94f4e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:34:51 +0000 Subject: [PATCH] fix(teams): link an owner's trial subscription to their team A team created while its owner was on a Pro trial stayed a free team. Team creation and the invite/join backfill look up the owner's subscription with Stripe's `active` filter, which excludes `trialing`. So the trial never got team metadata, invitees joined without Pro, and no seats were added. Onboarding hits this whenever someone skips the team step, starts a trial, and creates a team later. The lookup now lists the customer's uncanceled subscriptions and picks an active one, else a trialing one. Past-due, unpaid, and incomplete subscriptions are still not linked. --- crates/teams/src/domain/customer_repo.rs | 3 +- crates/teams/src/domain/model.rs | 2 +- crates/teams/src/domain/team_repo.rs | 2 +- .../src/inbound/axum_router/premium_user.rs | 6 +- crates/teams/src/outbound/customer_repo.rs | 32 ++++++-- .../teams/src/outbound/customer_repo/test.rs | 81 +++++++++++++++++++ docs/agent-guide/settings.md | 2 + 7 files changed, 114 insertions(+), 14 deletions(-) diff --git a/crates/teams/src/domain/customer_repo.rs b/crates/teams/src/domain/customer_repo.rs index 3c8f6b5f9e9..50df411530b 100644 --- a/crates/teams/src/domain/customer_repo.rs +++ b/crates/teams/src/domain/customer_repo.rs @@ -53,7 +53,8 @@ pub trait CustomerRepository: Clone + Send + Sync + 'static { team_owner_id: &MacroUserIdStr<'_>, ) -> impl Future> + Send; - /// Get the customers subscription id + /// The customer's active subscription, or their trialing one when none is + /// active. Errors with `SubscriptionNotActive` when they have neither. fn get_subscription_id_for_customer( &self, customer_id: &stripe::CustomerId, diff --git a/crates/teams/src/domain/model.rs b/crates/teams/src/domain/model.rs index e735a6fc881..0c3ae186719 100644 --- a/crates/teams/src/domain/model.rs +++ b/crates/teams/src/domain/model.rs @@ -726,7 +726,7 @@ pub enum CustomerError { #[error("No stripe customer id")] /// The customer does not have a stripe customer id NoStripeCustomerId, - /// The subscription is not active + /// The customer has no active or trialing subscription #[error("Subscription is not active")] SubscriptionNotActive, /// Invalid promotion code diff --git a/crates/teams/src/domain/team_repo.rs b/crates/teams/src/domain/team_repo.rs index 28c0e88e16a..117609be6b2 100644 --- a/crates/teams/src/domain/team_repo.rs +++ b/crates/teams/src/domain/team_repo.rs @@ -382,7 +382,7 @@ pub trait TeamService: Clone + Send + Sync + 'static { subscription_id: Option<&stripe::SubscriptionId>, ) -> impl Future> + Send; - /// Returns the user's active stripe subscription id when the user is premium. + /// Returns the user's active or trialing Stripe subscription id, if any. fn is_user_premium( &self, user_id: &MacroUserIdStr<'_>, diff --git a/crates/teams/src/inbound/axum_router/premium_user.rs b/crates/teams/src/inbound/axum_router/premium_user.rs index f8cea62f483..885b21a4819 100644 --- a/crates/teams/src/inbound/axum_router/premium_user.rs +++ b/crates/teams/src/inbound/axum_router/premium_user.rs @@ -21,11 +21,11 @@ use crate::domain::{model::TeamError, team_repo::TeamService}; use super::TeamRouterState; /// Extractor that ensures the authenticated user is a premium user -/// (has an active stripe subscription). +/// (has an active or trialing Stripe subscription). pub struct PremiumUserExtractor { /// The authenticated premium user's id. pub macro_user_id: MacroUserIdStr<'static>, - /// The authenticated premium user's active Stripe subscription id. + /// The authenticated premium user's active or trialing Stripe subscription id. pub subscription_id: stripe::SubscriptionId, _authorization: PhantomData Auth>, } @@ -36,7 +36,7 @@ pub enum PremiumUserRejection { /// The request could not be authorized. #[error("authorization failed")] Authorization(MacroAuthorizationRejection), - /// The user does not have an active subscription. + /// The user has no active or trialing subscription. #[error("active subscription required")] NotPremium, /// The premium check could not be performed. diff --git a/crates/teams/src/outbound/customer_repo.rs b/crates/teams/src/outbound/customer_repo.rs index 67fb79fa872..30dfd067e90 100644 --- a/crates/teams/src/outbound/customer_repo.rs +++ b/crates/teams/src/outbound/customer_repo.rs @@ -699,22 +699,38 @@ impl CustomerRepository for CustomerRepositoryImpl { &self, customer_id: &stripe::CustomerId, ) -> Result { + // Stripe's `active` filter excludes `trialing`, so list every + // uncanceled subscription and choose here. A trial grants Pro like + // an active subscription, and a team created during the trial must + // link it. let mut params = stripe::ListSubscriptions::new(); params.customer = Some(customer_id.clone()); - params.status = Some(stripe::SubscriptionStatusFilter::Active); - params.limit = Some(1); + params.limit = Some(MAX_SUBSCRIPTIONS_PER_CUSTOMER); let subscriptions = stripe::Subscription::list(&self.client, ¶ms) .await .map_err(|e| CustomerError::StorageLayerError(e.into()))?; - subscriptions - .data - .into_iter() - .next() - .map(|sub| sub.id) - .ok_or(CustomerError::SubscriptionNotActive) + paying_subscription(subscriptions.data).ok_or(CustomerError::SubscriptionNotActive) + } +} + +/// Customers hold one live subscription; the cap only bounds the listing. +const MAX_SUBSCRIPTIONS_PER_CUSTOMER: u64 = 100; + +/// The subscription a team should bill through: an active one, else a trial. +fn paying_subscription(subscriptions: Vec) -> Option { + let mut trialing = None; + for subscription in subscriptions { + match subscription.status { + stripe::SubscriptionStatus::Active => return Some(subscription.id), + stripe::SubscriptionStatus::Trialing => { + trialing.get_or_insert(subscription.id); + } + _ => {} + } } + trialing } const OWNED_KEY: &str = "macro_plan_schedule"; diff --git a/crates/teams/src/outbound/customer_repo/test.rs b/crates/teams/src/outbound/customer_repo/test.rs index 44592dde0ac..85e31a71d68 100644 --- a/crates/teams/src/outbound/customer_repo/test.rs +++ b/crates/teams/src/outbound/customer_repo/test.rs @@ -705,3 +705,84 @@ async fn cancelling_a_guard_holder_releases_the_subscription_for_another_instanc .unwrap() .unwrap(); } + +/// Lists the customer's subscriptions with the given statuses and returns the +/// one a team would bill through. +async fn subscription_for_customer( + pool: sqlx::PgPool, + statuses: &[(&str, stripe::SubscriptionStatus)], +) -> Result { + let server = MockServer::start().await; + let list = stripe::List { + data: statuses + .iter() + .map(|(id, status)| stripe::Subscription { + id: id.parse().unwrap(), + status: status.clone(), + ..subscription() + }) + .collect::>(), + has_more: false, + total_count: None, + url: "/v1/subscriptions".into(), + }; + Mock::given(method("GET")) + .and(path("/v1/subscriptions")) + .respond_with(ResponseTemplate::new(200).set_body_json(response(&list))) + .expect(1) + .mount(&server) + .await; + let customer = "cus_test".parse().unwrap(); + let found = repo(&server, pool) + .get_subscription_id_for_customer(&customer) + .await; + let requests = server.received_requests().await.unwrap(); + let query = requests[0].url.query().unwrap_or_default().to_owned(); + assert!(query.contains("customer=cus_test"), "{query}"); + assert!( + !query.contains("status="), + "lists every uncanceled status: {query}" + ); + found +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_trialing_subscription_is_linked_when_none_is_active(pool: sqlx::PgPool) { + let found = subscription_for_customer( + pool, + &[ + ("sub_past_due", stripe::SubscriptionStatus::PastDue), + ("sub_trial", stripe::SubscriptionStatus::Trialing), + ], + ) + .await + .unwrap(); + assert_eq!(found.as_str(), "sub_trial"); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn an_active_subscription_wins_over_a_trial(pool: sqlx::PgPool) { + let found = subscription_for_customer( + pool, + &[ + ("sub_trial", stripe::SubscriptionStatus::Trialing), + ("sub_active", stripe::SubscriptionStatus::Active), + ], + ) + .await + .unwrap(); + assert_eq!(found.as_str(), "sub_active"); +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn unpaid_or_incomplete_subscriptions_are_not_linked(pool: sqlx::PgPool) { + let found = subscription_for_customer( + pool, + &[ + ("sub_unpaid", stripe::SubscriptionStatus::Unpaid), + ("sub_incomplete", stripe::SubscriptionStatus::Incomplete), + ], + ) + .await; + assert!(matches!(found, Err(CustomerError::SubscriptionNotActive))); +} diff --git a/docs/agent-guide/settings.md b/docs/agent-guide/settings.md index 158d8ad5ce4..165cb2a7e77 100644 --- a/docs/agent-guide/settings.md +++ b/docs/agent-guide/settings.md @@ -131,6 +131,8 @@ auto-join must keep working beyond five members and the former stage-plan limits Free-team joins do not create a paid subscription, bill a seat, or grant premium roles. Teams with an existing paid subscription retain their per-seat billing; enterprise teams retain their billing bypass. +A team created or invited from while its owner is on a Pro trial bills through +that trial, so members who join get Pro, like on an active subscription. On a local stack started with `--no-doppler`, verify free-team creation from a fresh passwordless signup. It must work without Stripe credentials.