use chrono::Utc;
use uuid::Uuid;
use crate::error::ApiResult;
use crate::models::{Organization, Plan, Subscription, SubscriptionStatus};
use crate::AppState;
pub const PAST_DUE_GRACE_SECONDS: i64 = 24 * 60 * 60;
pub async fn effective_plan(state: &AppState, org: &Organization) -> ApiResult<Plan> {
let stored = org.plan();
let sub = Subscription::find_by_org(state.db.pool(), org.id).await?;
Ok(resolve_effective_plan(stored, sub.as_ref(), Utc::now()))
}
fn resolve_effective_plan(
stored: Plan,
sub: Option<&Subscription>,
now: chrono::DateTime<Utc>,
) -> Plan {
if stored == Plan::Free {
return Plan::Free;
}
let Some(sub) = sub else {
tracing::warn!(
org_plan = %stored,
"effective_plan: paid plan has no subscription row; trusting stored plan (legacy/manual/seed org)"
);
return stored;
};
match sub.status() {
SubscriptionStatus::Active | SubscriptionStatus::Trialing => stored,
SubscriptionStatus::PastDue => {
let elapsed = (now - sub.updated_at).num_seconds();
if elapsed > PAST_DUE_GRACE_SECONDS {
tracing::info!(
org_plan = %stored,
past_due_seconds = elapsed,
"effective_plan: past_due beyond grace; downgrading to Free for gating"
);
Plan::Free
} else {
stored
}
}
other => {
tracing::info!(
org_plan = %stored,
status = %other,
"effective_plan: inactive subscription; downgrading to Free for gating"
);
Plan::Free
}
}
}
pub async fn effective_plan_for_org_id(state: &AppState, org_id: Uuid) -> ApiResult<Plan> {
let org = Organization::find_by_id(state.db.pool(), org_id)
.await?
.ok_or_else(|| crate::error::ApiError::InvalidRequest("Organization not found".into()))?;
effective_plan(state, &org).await
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn sub_with(status: SubscriptionStatus, updated_at: chrono::DateTime<Utc>) -> Subscription {
Subscription {
id: Uuid::new_v4(),
org_id: Uuid::new_v4(),
stripe_subscription_id: "sub_test".into(),
stripe_customer_id: "cus_test".into(),
price_id: "price_test".into(),
plan: "team".into(),
status: status.to_string(),
current_period_start: Utc::now(),
current_period_end: Utc::now(),
cancel_at_period_end: false,
canceled_at: None,
created_at: Utc::now(),
updated_at,
}
}
#[test]
fn free_stays_free_regardless_of_subscription() {
let now = Utc::now();
assert_eq!(resolve_effective_plan(Plan::Free, None, now), Plan::Free);
let sub = sub_with(SubscriptionStatus::Active, now);
assert_eq!(resolve_effective_plan(Plan::Free, Some(&sub), now), Plan::Free);
}
#[test]
fn paid_with_no_subscription_trusts_stored_plan() {
let now = Utc::now();
assert_eq!(resolve_effective_plan(Plan::Team, None, now), Plan::Team);
assert_eq!(resolve_effective_plan(Plan::Pro, None, now), Plan::Pro);
}
#[test]
fn active_subscription_keeps_paid_plan() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::Active, now);
assert_eq!(resolve_effective_plan(Plan::Team, Some(&sub), now), Plan::Team);
}
#[test]
fn trialing_subscription_keeps_paid_plan() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::Trialing, now);
assert_eq!(resolve_effective_plan(Plan::Pro, Some(&sub), now), Plan::Pro);
}
#[test]
fn canceled_subscription_downgrades_to_free() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::Canceled, now);
assert_eq!(resolve_effective_plan(Plan::Team, Some(&sub), now), Plan::Free);
}
#[test]
fn unpaid_subscription_downgrades_to_free() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::Unpaid, now);
assert_eq!(resolve_effective_plan(Plan::Team, Some(&sub), now), Plan::Free);
}
#[test]
fn incomplete_expired_subscription_downgrades_to_free() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::IncompleteExpired, now);
assert_eq!(resolve_effective_plan(Plan::Pro, Some(&sub), now), Plan::Free);
}
#[test]
fn past_due_within_grace_keeps_paid_plan() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::PastDue, now - Duration::hours(1));
assert_eq!(resolve_effective_plan(Plan::Team, Some(&sub), now), Plan::Team);
}
#[test]
fn past_due_beyond_grace_downgrades_to_free() {
let now = Utc::now();
let sub = sub_with(SubscriptionStatus::PastDue, now - Duration::hours(25));
assert_eq!(resolve_effective_plan(Plan::Team, Some(&sub), now), Plan::Free);
}
}