use std::cmp::Ordering;
use super::{
BillingTarget, CheckoutExistingFact, CheckoutExistingStatus, CheckoutRuleError, CheckoutVariant,
CloudSourceCandidate, FinancialFact, FinancialKind, FinancialRestriction, FinancialStatus, NormalizedSubscription,
OperationFailureState, OperationRuleError, OperationSendState, PaymentRuleError, Provider, ProviderLifecycle,
ProviderSubscriptionFacts, SubscriptionMutation, SubscriptionMutationError, SubscriptionRecurring,
};
use crate::access_control::{
EntitlementInput, EntitlementStatus, Plan, TargetType, ValidatedEntitlement, validate_entitlement_input,
};
pub fn normalize_provider_subscription(
facts: ProviderSubscriptionFacts,
) -> Result<NormalizedSubscription, PaymentRuleError> {
if facts.source_id.trim().is_empty() {
return Err(PaymentRuleError::MissingProviderIdentity);
}
if facts.provider == Provider::RevenueCat && facts.gives_access.is_none() {
return Err(PaymentRuleError::MissingAccessDecision);
}
if !matches!(facts.plan, Plan::Pro | Plan::Ai | Plan::Team) {
return Err(PaymentRuleError::InvalidProviderPlan);
}
let recurring_is_valid = match (facts.provider, facts.plan, facts.recurring) {
(Provider::Stripe, Plan::Pro, _)
| (Provider::Stripe, Plan::Ai, SubscriptionRecurring::Yearly)
| (Provider::Stripe, Plan::Team, SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly)
| (Provider::RevenueCat, Plan::Pro | Plan::Ai, SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly) => {
true
}
_ => false,
};
if !recurring_is_valid {
return Err(PaymentRuleError::InvalidProviderRecurring);
}
let lifecycle_is_valid = match facts.provider {
Provider::Stripe => matches!(
facts.lifecycle,
ProviderLifecycle::Active
| ProviderLifecycle::Trialing
| ProviderLifecycle::PastDue
| ProviderLifecycle::Canceled
| ProviderLifecycle::Unpaid
| ProviderLifecycle::Incomplete
| ProviderLifecycle::Paused
| ProviderLifecycle::IncompleteExpired
),
Provider::RevenueCat => matches!(
facts.lifecycle,
ProviderLifecycle::Active
| ProviderLifecycle::Trialing
| ProviderLifecycle::InGracePeriod
| ProviderLifecycle::InBillingRetry
| ProviderLifecycle::Expired
| ProviderLifecycle::Paused
| ProviderLifecycle::Incomplete
),
};
if !lifecycle_is_valid {
return Err(PaymentRuleError::InvalidProviderLifecycle);
}
let plan = match (facts.plan, facts.recurring) {
(Plan::Pro, SubscriptionRecurring::Lifetime) => Plan::LifetimePro,
(plan, _) => plan,
};
let target_type = if plan == Plan::Team {
TargetType::Workspace
} else {
TargetType::User
};
let quantity = if target_type == TargetType::Workspace {
facts
.quantity
.map(|quantity| {
if !quantity.is_finite() || quantity.fract() != 0.0 {
return Err(PaymentRuleError::InvalidEntitlement(
crate::access_control::EntitlementInputError::InvalidQuantity,
));
}
i32::try_from(quantity as i64).map_err(|_| {
PaymentRuleError::InvalidEntitlement(crate::access_control::EntitlementInputError::InvalidQuantity)
})
})
.transpose()?
} else {
None
};
let ValidatedEntitlement::Catalog(access) = validate_entitlement_input(EntitlementInput {
deployment: crate::access_control::Deployment::Cloud,
target_type,
plan: Some(plan),
quantity,
signed: false,
})
.map_err(PaymentRuleError::InvalidEntitlement)?
else {
unreachable!("cloud payment facts cannot produce a signed license")
};
let status = match facts.provider {
Provider::Stripe => match facts.lifecycle {
ProviderLifecycle::Active | ProviderLifecycle::Trialing => EntitlementStatus::Active,
ProviderLifecycle::PastDue => EntitlementStatus::Grace,
ProviderLifecycle::Canceled => EntitlementStatus::Revoked,
ProviderLifecycle::Unpaid
| ProviderLifecycle::Incomplete
| ProviderLifecycle::Paused
| ProviderLifecycle::IncompleteExpired => EntitlementStatus::Expired,
ProviderLifecycle::Expired
| ProviderLifecycle::InGracePeriod
| ProviderLifecycle::InBillingRetry
| ProviderLifecycle::Unknown => return Err(PaymentRuleError::InvalidProviderLifecycle),
},
Provider::RevenueCat => {
if facts.gives_access == Some(false) {
EntitlementStatus::Expired
} else {
match facts.lifecycle {
ProviderLifecycle::Active | ProviderLifecycle::Trialing => EntitlementStatus::Active,
ProviderLifecycle::InGracePeriod | ProviderLifecycle::InBillingRetry => EntitlementStatus::Grace,
ProviderLifecycle::Expired | ProviderLifecycle::Paused | ProviderLifecycle::Incomplete => {
EntitlementStatus::Expired
}
ProviderLifecycle::Unknown
| ProviderLifecycle::PastDue
| ProviderLifecycle::Canceled
| ProviderLifecycle::Unpaid
| ProviderLifecycle::IncompleteExpired => return Err(PaymentRuleError::InvalidProviderLifecycle),
}
}
}
};
if matches!(status, EntitlementStatus::Active | EntitlementStatus::Grace)
&& facts.recurring != SubscriptionRecurring::Lifetime
&& facts.end.is_none()
{
return Err(PaymentRuleError::MissingPeriodEnd);
}
let grace_until = if status == EntitlementStatus::Grace {
Some(
facts
.trial_end
.or(facts.end)
.ok_or(PaymentRuleError::MissingPeriodEnd)?,
)
} else {
None
};
Ok(NormalizedSubscription {
provider: facts.provider,
source_id: facts.source_id,
target_type,
access,
status,
grace_until,
end: facts.end,
will_renew: facts.will_renew,
})
}
pub fn trial_eligible(plan: Plan, already_used: bool) -> bool {
plan == Plan::Ai && !already_used
}
pub const AI_TRIAL_DAYS: i64 = 7;
pub const REVENUECAT_PROVISIONAL_SECONDS: i64 = 10 * 60;
pub fn validate_checkout(
plan: Plan,
recurring: SubscriptionRecurring,
variant: Option<CheckoutVariant>,
target: BillingTarget,
target_exists: bool,
actor_matches_target: bool,
) -> Result<(), CheckoutRuleError> {
let valid = match plan {
Plan::Pro => {
target == BillingTarget::User
&& target_exists
&& actor_matches_target
&& variant.is_none()
&& matches!(
recurring,
SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly | SubscriptionRecurring::Lifetime
)
}
Plan::Ai => {
target == BillingTarget::User
&& target_exists
&& actor_matches_target
&& variant.is_none()
&& recurring == SubscriptionRecurring::Yearly
}
Plan::Team => {
target == BillingTarget::Workspace
&& target_exists
&& variant.is_none()
&& matches!(
recurring,
SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly
)
}
Plan::SelfHostedTeam => {
target == BillingTarget::Instance
&& !target_exists
&& matches!(
recurring,
SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly
)
}
Plan::Free | Plan::LifetimePro | Plan::SelfHostedFree => false,
};
if valid && (plan != Plan::SelfHostedTeam || variant.is_none_or(|value| value == CheckoutVariant::Onetime)) {
Ok(())
} else {
Err(CheckoutRuleError)
}
}
pub fn checkout_has_conflict(
requested_plan: Plan,
requested_recurring: SubscriptionRecurring,
now: chrono::DateTime<chrono::Utc>,
facts: &[CheckoutExistingFact],
) -> bool {
facts.iter().any(|fact| {
let same_plan = match requested_plan {
Plan::Pro => matches!(fact.plan, Plan::Pro | Plan::LifetimePro),
Plan::Ai | Plan::Team | Plan::SelfHostedTeam => fact.plan == requested_plan,
Plan::Free | Plan::LifetimePro | Plan::SelfHostedFree => false,
};
let active = match fact.status {
CheckoutExistingStatus::Provider(status) => {
matches!(
status,
ProviderLifecycle::Active | ProviderLifecycle::Trialing | ProviderLifecycle::PastDue
) && fact.period_end.is_none_or(|end| end > now)
}
CheckoutExistingStatus::Entitlement(EntitlementStatus::Active) => {
fact.expires_at.is_none_or(|expires_at| expires_at > now)
}
CheckoutExistingStatus::Entitlement(EntitlementStatus::Grace) => {
fact.grace_until.is_some_and(|grace_until| grace_until > now)
&& fact.expires_at.is_none_or(|expires_at| expires_at > now)
}
CheckoutExistingStatus::Entitlement(EntitlementStatus::Revoked | EntitlementStatus::Expired) => false,
};
let same_lifetime_channel = requested_recurring != SubscriptionRecurring::Lifetime
|| fact.plan == Plan::LifetimePro
|| fact.recurring == Some(SubscriptionRecurring::Lifetime);
same_plan && active && same_lifetime_channel
})
}
pub fn validate_subscription_mutation_target(
plan: Plan,
target: BillingTarget,
mutation: SubscriptionMutation,
) -> Result<(), SubscriptionMutationError> {
let target_allowed = match mutation {
SubscriptionMutation::Cancel | SubscriptionMutation::Resume => matches!(
(target, plan),
(BillingTarget::User, Plan::Pro | Plan::Ai) | (BillingTarget::Workspace, Plan::Team)
),
SubscriptionMutation::ChangeRecurring(_) => matches!(
(target, plan),
(BillingTarget::User, Plan::Pro)
| (BillingTarget::Workspace, Plan::Team)
| (BillingTarget::Instance, Plan::SelfHostedTeam)
),
SubscriptionMutation::ChangeQuantity(_) => matches!(
(target, plan),
(BillingTarget::Workspace, Plan::Team) | (BillingTarget::Instance, Plan::SelfHostedTeam)
),
};
if target_allowed {
Ok(())
} else {
Err(SubscriptionMutationError::InvalidTarget)
}
}
pub fn validate_subscription_mutation(
plan: Plan,
target: BillingTarget,
recurring: SubscriptionRecurring,
canceled: bool,
mutation: SubscriptionMutation,
) -> Result<(), SubscriptionMutationError> {
validate_subscription_mutation_target(plan, target, mutation)?;
if recurring == SubscriptionRecurring::Lifetime {
return Err(SubscriptionMutationError::Lifetime);
}
match (canceled, mutation) {
(true, SubscriptionMutation::Cancel) => Err(SubscriptionMutationError::AlreadyCanceled),
(false, SubscriptionMutation::Resume) => Err(SubscriptionMutationError::NotCanceled),
(true, SubscriptionMutation::ChangeRecurring(_)) => Err(SubscriptionMutationError::AlreadyCanceled),
(_, SubscriptionMutation::ChangeRecurring(requested)) if requested == recurring => {
Err(SubscriptionMutationError::SameRecurring)
}
(_, SubscriptionMutation::ChangeRecurring(requested))
if !matches!(
requested,
SubscriptionRecurring::Monthly | SubscriptionRecurring::Yearly
) =>
{
Err(SubscriptionMutationError::InvalidRecurring)
}
(_, SubscriptionMutation::ChangeQuantity(0)) => Err(SubscriptionMutationError::InvalidQuantity),
_ => Ok(()),
}
}
pub const fn operation_failure_state(retryable: bool, uncertain: bool, replay_expired: bool) -> OperationFailureState {
if uncertain && replay_expired {
OperationFailureState::Blocked
} else if !retryable && !uncertain {
OperationFailureState::Rejected
} else {
OperationFailureState::Pending
}
}
pub fn operation_send_state(
is_first_unresolved: bool,
first_sent_at: Option<chrono::DateTime<chrono::Utc>>,
replay_deadline: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
) -> Result<OperationSendState, OperationRuleError> {
if !is_first_unresolved {
return Err(OperationRuleError::StepOutOfOrder);
}
if first_sent_at.is_some() && replay_deadline.is_some_and(|deadline| deadline <= now) {
Ok(OperationSendState::Block)
} else {
Ok(OperationSendState::Send)
}
}
pub fn operation_result_allowed(
previous_steps_resolved: bool,
was_sent: bool,
stored_result_matches: Option<bool>,
) -> Result<(), OperationRuleError> {
if !previous_steps_resolved {
Err(OperationRuleError::StepOutOfOrder)
} else if !was_sent {
Err(OperationRuleError::StepNotSent)
} else if stored_result_matches == Some(false) {
Err(OperationRuleError::ResultChanged)
} else {
Ok(())
}
}
pub fn effective_financial_restriction(facts: &[FinancialFact]) -> FinancialRestriction {
facts
.iter()
.map(|fact| match (fact.kind, fact.status) {
(FinancialKind::Refund, FinancialStatus::Pending | FinancialStatus::Failed)
| (FinancialKind::Dispute, FinancialStatus::Won)
| (FinancialKind::Invoice, FinancialStatus::Pending | FinancialStatus::Open | FinancialStatus::Paid) => {
FinancialRestriction::None
}
(FinancialKind::Dispute, FinancialStatus::Open) => FinancialRestriction::Suspend,
(FinancialKind::Refund, FinancialStatus::Succeeded)
| (FinancialKind::Dispute, FinancialStatus::Lost)
| (FinancialKind::Invoice, FinancialStatus::Void | FinancialStatus::Uncollectible) => {
FinancialRestriction::Revoke
}
_ => FinancialRestriction::Revoke,
})
.max()
.unwrap_or(FinancialRestriction::None)
}
pub fn cloud_plan_family(plan: Plan) -> Option<&'static str> {
match plan {
Plan::Pro | Plan::LifetimePro => Some("pro"),
Plan::Ai => Some("ai"),
Plan::Team => Some("team"),
Plan::Free | Plan::SelfHostedFree | Plan::SelfHostedTeam => None,
}
}
pub fn select_cloud_source(candidates: &[CloudSourceCandidate]) -> Option<&CloudSourceCandidate> {
let first = candidates.first()?;
if candidates.iter().any(|candidate| {
candidate.target_id != first.target_id
|| candidate.subscription.target_type != first.subscription.target_type
|| cloud_plan_family(candidate.subscription.access.plan) != cloud_plan_family(first.subscription.access.plan)
}) {
return None;
}
candidates
.iter()
.filter(|candidate| {
candidate.restriction == FinancialRestriction::None
&& matches!(
candidate.subscription.status,
EntitlementStatus::Active | EntitlementStatus::Grace
)
})
.max_by(|left, right| compare_candidates(left, right))
}
fn compare_candidates(left: &CloudSourceCandidate, right: &CloudSourceCandidate) -> Ordering {
let rank = |candidate: &CloudSourceCandidate| {
let subscription = &candidate.subscription;
(
match subscription.status {
EntitlementStatus::Active => 2,
EntitlementStatus::Grace => 1,
EntitlementStatus::Revoked | EntitlementStatus::Expired => 0,
},
match subscription.provider {
Provider::Stripe => 2,
Provider::RevenueCat => 1,
},
plan_rank(subscription.access.plan),
subscription.end,
)
};
rank(left)
.cmp(&rank(right))
.then_with(|| right.provider_namespace.cmp(&left.provider_namespace))
.then_with(|| right.subscription.source_id.cmp(&left.subscription.source_id))
}
const fn plan_rank(plan: Plan) -> u8 {
match plan {
Plan::Team | Plan::SelfHostedTeam => 4,
Plan::LifetimePro => 3,
Plan::Pro => 2,
Plan::Ai => 1,
Plan::Free | Plan::SelfHostedFree => 0,
}
}