use super::event::{
ChangeTiming, DisputeOutcome, LifecycleEvent, LifecycleKind, PeriodType, Provider,
};
use time::OffsetDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Status {
Incomplete,
Active,
InGracePeriod,
InBillingRetry,
Paused,
Expired,
Revoked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionState {
pub status: Status,
pub period_type: PeriodType,
pub renews: bool,
pub access_until: Option<OffsetDateTime>,
pub pending_product: Option<String>,
pub last_event_at: OffsetDateTime,
pub revoked: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum PastDuePolicy {
#[default]
InGracePeriod,
InBillingRetry,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum DisputePolicy {
#[default]
RevokeOnLoss,
RevokeOnOpen,
Ignore,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LifecyclePolicy {
pub stripe_past_due: PastDuePolicy,
pub billing_retry_grants_access: bool,
pub refund_revokes: bool,
pub dispute: DisputePolicy,
}
impl Default for LifecyclePolicy {
fn default() -> Self {
Self {
stripe_past_due: PastDuePolicy::InGracePeriod,
billing_retry_grants_access: false,
refund_revokes: true,
dispute: DisputePolicy::RevokeOnLoss,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Transition {
Apply(SubscriptionState),
IgnoreStale,
Invalid(&'static str),
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn transition(
state: Option<&SubscriptionState>,
event: &LifecycleEvent,
policy: &LifecyclePolicy,
) -> Transition {
let fresh = SubscriptionState {
status: Status::Incomplete,
period_type: event.period_type,
renews: false,
access_until: None,
pending_product: None,
last_event_at: event.occurred_at,
revoked: false,
};
let prior = state.unwrap_or(&fresh);
let t = event.occurred_at;
if t < prior.last_event_at && !event.kind.is_monotonic_safe() {
return Transition::IgnoreStale;
}
let terminal = matches!(prior.status, Status::Expired | Status::Revoked);
let ends_at = event.period.as_ref().map(|period| period.ends_at);
let next = match &event.kind {
LifecycleKind::Started | LifecycleKind::Renewed { .. } | LifecycleKind::Recovered => {
Some(paid(event, true, prior.access_until))
}
LifecycleKind::OneTimePurchase { .. } => Some(paid(event, false, None)),
LifecycleKind::TemporaryGrant { until } => Some(SubscriptionState {
status: Status::Active,
period_type: event.period_type,
renews: false,
access_until: Some(*until),
pending_product: None,
last_event_at: t,
revoked: false,
}),
LifecycleKind::CancellationScheduled { .. } => (!terminal).then(|| SubscriptionState {
renews: false,
access_until: ends_at.or(prior.access_until),
..prior.clone()
}),
LifecycleKind::Uncancelled => (!terminal).then(|| SubscriptionState {
renews: true,
..prior.clone()
}),
LifecycleKind::PauseScheduled { .. } => (!terminal).then(|| prior.clone()),
LifecycleKind::ProductChanged {
to_product,
effective,
} => (!terminal).then(|| SubscriptionState {
pending_product: match effective {
ChangeTiming::Immediate => None,
ChangeTiming::NextRenewal => Some(to_product.clone()),
},
..prior.clone()
}),
LifecycleKind::BillingIssue { grace_until } => {
if terminal {
None
} else if prior.status == Status::Incomplete {
Some(prior.clone())
} else if let Some(grace) = grace_until {
Some(SubscriptionState {
status: Status::InGracePeriod,
access_until: Some(*grace),
..prior.clone()
})
} else if (event.provider == Provider::Stripe
&& policy.stripe_past_due == PastDuePolicy::InGracePeriod)
|| policy.billing_retry_grants_access
{
Some(SubscriptionState {
status: Status::InGracePeriod,
access_until: ends_at.or(prior.access_until),
..prior.clone()
})
} else {
Some(SubscriptionState {
status: Status::InBillingRetry,
..prior.clone()
})
}
}
LifecycleKind::Paused => (!terminal).then(|| SubscriptionState {
status: Status::Paused,
..prior.clone()
}),
LifecycleKind::Resumed => {
if terminal {
None
} else if prior.status == Status::Paused {
Some(SubscriptionState {
status: Status::Active,
renews: true,
access_until: ends_at.or(prior.access_until),
..prior.clone()
})
} else {
Some(prior.clone())
}
}
LifecycleKind::Expired { .. } => Some(if prior.status == Status::Revoked {
prior.clone()
} else {
SubscriptionState {
status: Status::Expired,
renews: false,
..prior.clone()
}
}),
LifecycleKind::Extended { new_ends_at } => {
if prior.status == Status::Revoked {
None
} else {
Some(SubscriptionState {
status: if prior.status == Status::Expired {
Status::Active
} else {
prior.status
},
access_until: Some(*new_ends_at),
..prior.clone()
})
}
}
LifecycleKind::Refunded { partial, .. } => {
if policy.refund_revokes && !partial {
Some(revoke(prior))
} else {
Some(prior.clone())
}
}
LifecycleKind::RefundReversed => Some(restore(prior)),
LifecycleKind::DisputeOpened
| LifecycleKind::DisputeUpdated
| LifecycleKind::DisputeFundsWithdrawn => {
if policy.dispute == DisputePolicy::RevokeOnOpen {
Some(revoke(prior))
} else {
Some(prior.clone())
}
}
LifecycleKind::DisputeClosed { outcome } => match outcome {
DisputeOutcome::Lost => {
if policy.dispute == DisputePolicy::Ignore {
Some(prior.clone())
} else {
Some(revoke(prior))
}
}
DisputeOutcome::Won => Some(restorable(prior, policy)),
DisputeOutcome::Other(_) => Some(prior.clone()),
},
LifecycleKind::DisputeFundsReinstated => Some(restorable(prior, policy)),
LifecycleKind::Transferred { .. }
| LifecycleKind::PriceChangeConsentRequired
| LifecycleKind::PriceChangeConsentApproved
| LifecycleKind::InvoiceIssued
| LifecycleKind::Test
| LifecycleKind::Unrecognized => Some(prior.clone()),
};
match next {
None => Transition::Invalid("event cannot move an expired or revoked subscription"),
Some(mut next) => {
next.last_event_at = prior.last_event_at.max(t);
next.revoked = next.status == Status::Revoked;
Transition::Apply(next)
}
}
}
#[must_use]
pub fn gives_access(state: &SubscriptionState, now: OffsetDateTime) -> bool {
if state.revoked {
return false;
}
matches!(state.status, Status::Active | Status::InGracePeriod)
&& state.access_until.is_none_or(|until| now < until)
}
fn paid(
event: &LifecycleEvent,
renews: bool,
fallback: Option<OffsetDateTime>,
) -> SubscriptionState {
SubscriptionState {
status: Status::Active,
period_type: event.period_type,
renews,
access_until: event
.period
.as_ref()
.map(|period| period.ends_at)
.or(fallback),
pending_product: None,
last_event_at: event.occurred_at,
revoked: false,
}
}
fn revoke(prior: &SubscriptionState) -> SubscriptionState {
SubscriptionState {
status: Status::Revoked,
renews: false,
revoked: true,
..prior.clone()
}
}
fn restorable(prior: &SubscriptionState, policy: &LifecyclePolicy) -> SubscriptionState {
if prior.revoked && policy.dispute != DisputePolicy::Ignore {
SubscriptionState {
status: Status::Active,
revoked: false,
..prior.clone()
}
} else {
prior.clone()
}
}
fn restore(prior: &SubscriptionState) -> SubscriptionState {
if prior.revoked {
SubscriptionState {
status: Status::Active,
revoked: false,
..prior.clone()
}
} else {
prior.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::billing::event::{
CancelReason, DisputeOutcome, Environment, ExpirationReason, LifecycleEvent, Ownership,
Period, ProviderCustomer, RefundOrigin, SourceRef, Store,
};
use time::Duration;
use time::format_description::well_known::Rfc3339;
fn all_statuses() -> Vec<Status> {
let all = vec![
Status::Incomplete,
Status::Active,
Status::InGracePeriod,
Status::InBillingRetry,
Status::Paused,
Status::Expired,
Status::Revoked,
];
for status in &all {
match status {
Status::Incomplete
| Status::Active
| Status::InGracePeriod
| Status::InBillingRetry
| Status::Paused
| Status::Expired
| Status::Revoked => {}
}
}
all
}
macro_rules! kind_samples {
($($pattern:pat => $sample:expr),+ $(,)?) => {
fn kind_samples() -> Vec<(&'static str, LifecycleKind)> {
vec![$((stringify!($pattern), $sample)),+]
}
#[expect(dead_code, reason = "its only job is to be exhaustive")]
fn every_kind_is_sampled(kind: LifecycleKind) {
match kind {
$($pattern => {}),+
}
}
};
}
kind_samples! {
LifecycleKind::Started => LifecycleKind::Started,
LifecycleKind::Renewed { .. } => LifecycleKind::Renewed { trial_conversion: false },
LifecycleKind::CancellationScheduled { .. } => LifecycleKind::CancellationScheduled {
reason: CancelReason::Unsubscribe,
},
LifecycleKind::Uncancelled => LifecycleKind::Uncancelled,
LifecycleKind::BillingIssue { .. } => LifecycleKind::BillingIssue { grace_until: None },
LifecycleKind::Recovered => LifecycleKind::Recovered,
LifecycleKind::PauseScheduled { .. } => LifecycleKind::PauseScheduled { resumes_at: None },
LifecycleKind::Paused => LifecycleKind::Paused,
LifecycleKind::Resumed => LifecycleKind::Resumed,
LifecycleKind::Expired { .. } => LifecycleKind::Expired { reason: ExpirationReason::Unknown },
LifecycleKind::Extended { .. } => LifecycleKind::Extended { new_ends_at: instant("2026-07-01T00:00:00Z") },
LifecycleKind::ProductChanged { .. } => LifecycleKind::ProductChanged {
to_product: "pro_annual".to_owned(),
effective: ChangeTiming::NextRenewal,
},
LifecycleKind::Refunded { .. } => LifecycleKind::Refunded {
partial: false,
origin: RefundOrigin::Store,
},
LifecycleKind::RefundReversed => LifecycleKind::RefundReversed,
LifecycleKind::DisputeOpened => LifecycleKind::DisputeOpened,
LifecycleKind::DisputeUpdated => LifecycleKind::DisputeUpdated,
LifecycleKind::DisputeClosed { .. } => LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Won,
},
LifecycleKind::DisputeFundsWithdrawn => LifecycleKind::DisputeFundsWithdrawn,
LifecycleKind::DisputeFundsReinstated => LifecycleKind::DisputeFundsReinstated,
LifecycleKind::OneTimePurchase { .. } => LifecycleKind::OneTimePurchase { consumable: None },
LifecycleKind::Transferred { .. } => LifecycleKind::Transferred {
from: vec!["u_old".to_owned()],
to: vec!["u_1".to_owned()],
},
LifecycleKind::TemporaryGrant { .. } => LifecycleKind::TemporaryGrant {
until: instant("2026-06-01T00:00:00Z"),
},
LifecycleKind::PriceChangeConsentRequired => LifecycleKind::PriceChangeConsentRequired,
LifecycleKind::PriceChangeConsentApproved => LifecycleKind::PriceChangeConsentApproved,
LifecycleKind::InvoiceIssued => LifecycleKind::InvoiceIssued,
LifecycleKind::Test => LifecycleKind::Test,
LifecycleKind::Unrecognized => LifecycleKind::Unrecognized,
}
const START: &str = "2026-05-01T00:00:00Z";
const TRIAL_END: &str = "2026-05-15T00:00:00Z";
const PAID_END: &str = "2026-06-15T00:00:00Z";
fn instant(text: &str) -> OffsetDateTime {
OffsetDateTime::parse(text, &Rfc3339).expect("test instant parses")
}
fn period(starts_at: OffsetDateTime, ends_at: OffsetDateTime) -> Period {
Period { starts_at, ends_at }
}
fn rc_event(kind: LifecycleKind, at: OffsetDateTime) -> LifecycleEvent {
LifecycleEvent {
provider: Provider::RevenueCat,
store: Store::AppStore,
event_id: format!("evt-{}", at.unix_timestamp()),
occurred_at: at,
environment: Environment::Production,
provider_customer: ProviderCustomer {
id: "u_1".to_owned(),
original_id: None,
aliases: Vec::new(),
},
source: SourceRef::Subscription("sub_1".to_owned()),
product_id: "pro".to_owned(),
entitlement_keys: vec!["pro".to_owned()],
period: None,
period_type: PeriodType::Normal,
ownership: Ownership::Purchased,
kind,
raw_kind: "TEST".to_owned(),
}
}
fn whole_refund(at: OffsetDateTime) -> LifecycleEvent {
rc_event(
LifecycleKind::Refunded {
partial: false,
origin: RefundOrigin::Store,
},
at,
)
}
fn representative(status: Status, at: OffsetDateTime) -> SubscriptionState {
SubscriptionState {
status,
period_type: PeriodType::Normal,
renews: true,
access_until: Some(at + Duration::days(30)),
pending_product: None,
last_event_at: at,
revoked: status == Status::Revoked,
}
}
fn trial_started(trial_end: OffsetDateTime) -> SubscriptionState {
let mut started = rc_event(LifecycleKind::Started, instant(START));
started.period_type = PeriodType::Trial;
started.period = Some(period(instant(START), trial_end));
applied(transition(None, &started, &LifecyclePolicy::default()))
}
fn step(prior: &SubscriptionState, event: &LifecycleEvent) -> SubscriptionState {
applied(transition(Some(prior), event, &LifecyclePolicy::default()))
}
fn applied(outcome: Transition) -> SubscriptionState {
match outcome {
Transition::Apply(state) => state,
other => panic!("expected Apply, got {other:?}"),
}
}
fn stay(status: Status, terminal: bool) -> Option<Status> {
(!terminal).then_some(status)
}
fn restored(status: Status) -> Status {
if status == Status::Revoked {
Status::Active
} else {
status
}
}
fn expected(status: Status, kind: &LifecycleKind) -> Option<Status> {
let terminal = matches!(status, Status::Expired | Status::Revoked);
match kind {
LifecycleKind::Started
| LifecycleKind::Renewed { .. }
| LifecycleKind::Recovered
| LifecycleKind::OneTimePurchase { .. }
| LifecycleKind::TemporaryGrant { .. } => Some(Status::Active),
LifecycleKind::CancellationScheduled { .. }
| LifecycleKind::Uncancelled
| LifecycleKind::PauseScheduled { .. }
| LifecycleKind::ProductChanged { .. } => stay(status, terminal),
LifecycleKind::BillingIssue { .. } => {
if status == Status::Incomplete {
Some(Status::Incomplete)
} else {
stay(Status::InBillingRetry, terminal)
}
}
LifecycleKind::Paused => stay(Status::Paused, terminal),
LifecycleKind::Resumed => {
if status == Status::Paused {
Some(Status::Active)
} else {
stay(status, terminal)
}
}
LifecycleKind::Expired { .. } => Some(if status == Status::Revoked {
Status::Revoked
} else {
Status::Expired
}),
LifecycleKind::Extended { .. } => {
if status == Status::Revoked {
None
} else if status == Status::Expired {
Some(Status::Active)
} else {
Some(status)
}
}
LifecycleKind::Refunded { .. }
| LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Lost,
} => Some(Status::Revoked),
LifecycleKind::RefundReversed
| LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Won,
}
| LifecycleKind::DisputeFundsReinstated => Some(restored(status)),
LifecycleKind::DisputeOpened
| LifecycleKind::DisputeUpdated
| LifecycleKind::DisputeFundsWithdrawn
| LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Other(_),
}
| LifecycleKind::Transferred { .. }
| LifecycleKind::PriceChangeConsentRequired
| LifecycleKind::PriceChangeConsentApproved
| LifecycleKind::InvoiceIssued
| LifecycleKind::Test
| LifecycleKind::Unrecognized => Some(status),
}
}
#[test]
#[allow(clippy::too_many_lines)] fn the_table_holds_for_every_status_and_kind() {
let prior_at = instant("2026-05-01T00:00:00Z");
let event_at = instant("2026-05-02T00:00:00Z");
let mut samples = kind_samples();
samples.push((
"DisputeClosed { outcome: Lost }",
LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Lost,
},
));
for status in all_statuses() {
let prior = representative(status, prior_at);
for (name, kind) in &samples {
let event = rc_event((*kind).clone(), event_at);
let outcome = transition(Some(&prior), &event, &LifecyclePolicy::default());
match expected(status, kind) {
None => assert!(
matches!(outcome, Transition::Invalid(_)),
"{status:?} + {name}: expected Invalid, got {outcome:?}"
),
Some(want) => match outcome {
Transition::Apply(next) => {
assert_eq!(next.status, want, "{status:?} + {name}");
assert_eq!(
next.revoked,
next.status == Status::Revoked,
"the revoked invariant after {status:?} + {name}"
);
}
other => panic!("{status:?} + {name}: expected Apply, got {other:?}"),
},
}
}
}
}
#[test]
fn trial_start_and_conversion() {
let trial = trial_started(instant(TRIAL_END));
assert_eq!(trial.status, Status::Active);
assert_eq!(trial.period_type, PeriodType::Trial);
assert!(trial.renews);
assert_eq!(trial.access_until, Some(instant(TRIAL_END)));
assert!(gives_access(&trial, instant(START)));
let mut renewed = rc_event(
LifecycleKind::Renewed {
trial_conversion: true,
},
instant(TRIAL_END),
);
renewed.period = Some(period(instant(TRIAL_END), instant(PAID_END)));
let paid = step(&trial, &renewed);
assert_eq!(paid.status, Status::Active);
assert_eq!(paid.period_type, PeriodType::Normal);
assert_eq!(paid.access_until, Some(instant(PAID_END)));
assert!(gives_access(&paid, instant(TRIAL_END)));
}
#[test]
fn cancel_during_trial_keeps_access_to_the_period_end() {
let trial = trial_started(instant(TRIAL_END));
let cancel = rc_event(
LifecycleKind::CancellationScheduled {
reason: CancelReason::Unsubscribe,
},
instant("2026-05-10T00:00:00Z"),
);
let cancelled = step(&trial, &cancel);
assert_eq!(cancelled.status, Status::Active);
assert!(!cancelled.renews);
assert_eq!(cancelled.access_until, Some(instant(TRIAL_END)));
assert!(gives_access(&cancelled, instant("2026-05-14T23:59:59Z")));
assert!(!gives_access(&cancelled, instant(TRIAL_END)));
let expired = rc_event(
LifecycleKind::Expired {
reason: ExpirationReason::Unsubscribe,
},
instant(TRIAL_END),
);
let ended = step(&cancelled, &expired);
assert_eq!(ended.status, Status::Expired);
assert!(!ended.renews);
assert!(!gives_access(&ended, instant(TRIAL_END)));
}
#[test]
fn billing_issues_follow_the_grace_and_the_policy() {
let active = representative(Status::Active, instant(START));
let issue_at = instant("2026-05-05T00:00:00Z");
let grace_at = instant("2026-05-10T00:00:00Z");
let issue = rc_event(
LifecycleKind::BillingIssue {
grace_until: Some(grace_at),
},
issue_at,
);
let in_grace = step(&active, &issue);
assert_eq!(in_grace.status, Status::InGracePeriod);
assert_eq!(in_grace.access_until, Some(grace_at));
assert!(gives_access(&in_grace, instant("2026-05-09T23:59:59Z")));
assert!(!gives_access(&in_grace, grace_at));
let issue = rc_event(LifecycleKind::BillingIssue { grace_until: None }, issue_at);
let retry = step(&active, &issue);
assert_eq!(retry.status, Status::InBillingRetry);
assert_eq!(retry.access_until, active.access_until);
assert!(!gives_access(&retry, issue_at));
let mut stripe_issue =
rc_event(LifecycleKind::BillingIssue { grace_until: None }, issue_at);
stripe_issue.provider = Provider::Stripe;
assert_eq!(step(&active, &stripe_issue).status, Status::InGracePeriod);
let retry_policy = LifecyclePolicy {
stripe_past_due: PastDuePolicy::InBillingRetry,
..LifecyclePolicy::default()
};
let next = applied(transition(Some(&active), &stripe_issue, &retry_policy));
assert_eq!(next.status, Status::InBillingRetry);
assert!(!gives_access(&next, issue_at));
}
#[test]
fn a_refund_applies_before_or_after_a_renewal() {
let renewed_at = instant("2026-05-02T00:00:00Z");
let mut renewed = rc_event(
LifecycleKind::Renewed {
trial_conversion: false,
},
renewed_at,
);
renewed.period = Some(period(renewed_at, instant("2026-06-02T00:00:00Z")));
let active = applied(transition(None, &renewed, &LifecyclePolicy::default()));
assert!(gives_access(&active, renewed_at));
let revoked = step(&active, &whole_refund(instant(START)));
assert_eq!(revoked.status, Status::Revoked);
assert!(revoked.revoked);
assert!(!revoked.renews);
assert!(!gives_access(&revoked, renewed_at));
assert_eq!(revoked.last_event_at, renewed_at);
let later = step(&active, &whole_refund(instant("2026-05-03T00:00:00Z")));
assert_eq!(later.status, Status::Revoked);
assert_eq!(later.last_event_at, instant("2026-05-03T00:00:00Z"));
}
#[test]
fn a_stale_renewal_after_an_expiration_is_ignored() {
let expired = SubscriptionState {
status: Status::Expired,
renews: false,
..representative(Status::Active, instant("2026-05-02T00:00:00Z"))
};
let older = rc_event(
LifecycleKind::Renewed {
trial_conversion: false,
},
instant(START),
);
assert_eq!(
transition(Some(&expired), &older, &LifecyclePolicy::default()),
Transition::IgnoreStale
);
}
#[test]
fn disputes_revoke_on_loss_and_restore_on_a_win() {
let active = representative(Status::Active, instant(START));
let opened = rc_event(
LifecycleKind::DisputeOpened,
instant("2026-05-05T00:00:00Z"),
);
let flagged = step(&active, &opened);
assert_eq!(flagged.status, Status::Active);
assert!(gives_access(&flagged, instant("2026-05-05T00:00:00Z")));
let lost = rc_event(
LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Lost,
},
instant("2026-05-06T00:00:00Z"),
);
let revoked = step(&flagged, &lost);
assert_eq!(revoked.status, Status::Revoked);
assert!(!gives_access(&revoked, instant("2026-05-06T00:00:00Z")));
let won = rc_event(
LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Won,
},
instant("2026-05-07T00:00:00Z"),
);
let restored_state = step(&revoked, &won);
assert_eq!(restored_state.status, Status::Active);
assert!(!restored_state.revoked);
assert!(gives_access(
&restored_state,
instant("2026-05-07T00:00:00Z")
));
}
#[test]
fn product_change_and_activation_touch_pending_product() {
let active = representative(Status::Active, instant(START));
let at = instant("2026-05-02T00:00:00Z");
let change = |to_product: &str, effective| {
rc_event(
LifecycleKind::ProductChanged {
to_product: to_product.to_owned(),
effective,
},
at,
)
};
let scheduled = step(&active, &change("pro_annual", ChangeTiming::NextRenewal));
assert_eq!(scheduled.pending_product.as_deref(), Some("pro_annual"));
let switched = step(&scheduled, &change("pro_m", ChangeTiming::Immediate));
assert_eq!(switched.pending_product, None);
let mut renewed = rc_event(
LifecycleKind::Renewed {
trial_conversion: false,
},
at,
);
renewed.period = Some(period(at, instant(PAID_END)));
assert_eq!(step(&scheduled, &renewed).pending_product, None);
}
#[test]
fn the_policy_knobs_move_the_outcome() {
let active = representative(Status::Active, instant(START));
let at = instant("2026-05-05T00:00:00Z");
let opened = rc_event(LifecycleKind::DisputeOpened, at);
let lost = rc_event(
LifecycleKind::DisputeClosed {
outcome: DisputeOutcome::Lost,
},
at,
);
let partial = rc_event(
LifecycleKind::Refunded {
partial: true,
origin: RefundOrigin::Merchant,
},
at,
);
let issue = rc_event(LifecycleKind::BillingIssue { grace_until: None }, at);
let open_revokes = LifecyclePolicy {
dispute: DisputePolicy::RevokeOnOpen,
..LifecyclePolicy::default()
};
assert_eq!(
applied(transition(Some(&active), &opened, &open_revokes)).status,
Status::Revoked
);
let ignore = LifecyclePolicy {
dispute: DisputePolicy::Ignore,
..LifecyclePolicy::default()
};
assert_eq!(
applied(transition(Some(&active), &lost, &ignore)).status,
Status::Active
);
assert_eq!(step(&active, &partial).status, Status::Active);
let keeps = LifecyclePolicy {
refund_revokes: false,
..LifecyclePolicy::default()
};
assert_eq!(
applied(transition(Some(&active), &whole_refund(at), &keeps)).status,
Status::Active
);
let grants = LifecyclePolicy {
billing_retry_grants_access: true,
..LifecyclePolicy::default()
};
let granted = applied(transition(Some(&active), &issue, &grants));
assert_eq!(granted.status, Status::InGracePeriod);
assert!(gives_access(&granted, at));
}
#[test]
fn a_revoked_state_never_grants_access() {
let past = instant("2026-01-01T00:00:00Z");
let now = instant("2026-06-01T00:00:00Z");
let future = instant("2027-01-01T00:00:00Z");
for status in all_statuses() {
for access_until in [None, Some(past), Some(future)] {
for instant_at in [past, now, future] {
let state = SubscriptionState {
revoked: true,
access_until,
..representative(status, now)
};
assert!(
!gives_access(&state, instant_at),
"{status:?} with access until {access_until:?} at {instant_at:?}"
);
}
}
}
}
}