Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/teams/src/domain/customer_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ pub trait CustomerRepository: Clone + Send + Sync + 'static {
team_owner_id: &MacroUserIdStr<'_>,
) -> impl Future<Output = Result<(), CustomerError>> + 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,
Expand Down
2 changes: 1 addition & 1 deletion crates/teams/src/domain/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/teams/src/domain/team_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ pub trait TeamService: Clone + Send + Sync + 'static {
subscription_id: Option<&stripe::SubscriptionId>,
) -> impl Future<Output = Result<Team, CreateTeamError>> + 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<'_>,
Expand Down
6 changes: 3 additions & 3 deletions crates/teams/src/inbound/axum_router/premium_user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Auth> {
/// 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<fn() -> Auth>,
}
Expand All @@ -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.
Expand Down
32 changes: 24 additions & 8 deletions crates/teams/src/outbound/customer_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,22 +699,38 @@ impl CustomerRepository for CustomerRepositoryImpl {
&self,
customer_id: &stripe::CustomerId,
) -> Result<stripe::SubscriptionId, CustomerError> {
// 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let subscriptions = stripe::Subscription::list(&self.client, &params)
.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<stripe::Subscription>) -> Option<stripe::SubscriptionId> {
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";
Expand Down
81 changes: 81 additions & 0 deletions crates/teams/src/outbound/customer_repo/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<stripe::SubscriptionId, CustomerError> {
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::<Vec<_>>(),
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)));
}
2 changes: 2 additions & 0 deletions docs/agent-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading