# Lifecycle

Two pure functions that classify a license snapshot's lifecycle state. Consumed by `Gate` and useful directly in UI / banner logic.

```rust
use akira_billing::{compute_state, trial_days_left, LicenseState};
```

## LicenseState

```rust
pub enum LicenseState {
    None,
    Invalid,
    Active,
    Trialing,
    Grace,
    Expired,
}

impl LicenseState {
    pub fn as_str(&self) -> &'static str;       // lowercase, matches the JS SDK strings
}

impl std::fmt::Display for LicenseState { /* writes as_str() */ }
```

| Variant | When |
|---------|------|
| `None` | `payload` was `None` |
| `Invalid` | `valid_until` empty or unparseable |
| `Active` | `now <= valid_until`, not a trial |
| `Trialing` | `now <= valid_until`, payload flagged as trial |
| `Grace` | `valid_until < now <= valid_until + grace` |
| `Expired` | `now > valid_until + grace` |

## compute_state

```rust
pub fn compute_state(
    payload: Option<&LicenseSnapshotPayload>,
    grace: Duration,
    now: DateTime<Utc>,
) -> LicenseState;
```

`grace` is a `chrono::Duration`. Convert from `payload.offline_grace_days.unwrap_or(0)` via `Duration::days(days as i64)`. `Gate` performs this conversion when `grace_window` is provided to `GateOptions`.

```rust
use chrono::{Duration, Utc};

let state = compute_state(
    Some(&decoded.payload),
    Duration::days(payload.offline_grace_days.unwrap_or(0) as i64),
    Utc::now(),
);

match state {
    LicenseState::Trialing => banner("trial"),
    LicenseState::Grace    => banner("reconnect to keep offline mode"),
    LicenseState::Expired  => banner("license expired"),
    _ => {}
}
```

### Trial detection

A payload is classified as a trial when either is true:

- `payload.features["__trial"] == Some(true)` (server flag)
- `payload.plan_key.ends_with(":trial")` (naming convention)

Identical to the JS SDK heuristic.

## trial_days_left

```rust
pub fn trial_days_left(
    payload: Option<&LicenseSnapshotPayload>,
    now: DateTime<Utc>,
) -> i64;
```

Returns `ceil((valid_until - now) / 1 day)`, or `0` when:

- `payload` is `None`
- The payload is not a trial
- `valid_until` is unparseable
- The trial already expired (`now >= expiry`)

```rust
let left = trial_days_left(Some(&decoded.payload), Utc::now());
if left > 0 {
    println!("{} days left in trial", left);
}
```

`compute_state(...) == LicenseState::Trialing` is the better gate; `trial_days_left` is for the human-readable badge.

## Testing

Inject `now` directly:

```rust
use chrono::{Duration, TimeZone, Utc};

let payload = LicenseSnapshotPayload {
    valid_until: "2026-05-19T00:00:00Z".into(),
    /* … */
};

let grace = Duration::days(1);

assert_eq!(
    compute_state(Some(&payload), grace, Utc.with_ymd_and_hms(2026, 5, 18, 23, 59, 0).unwrap()),
    LicenseState::Active,
);
assert_eq!(
    compute_state(Some(&payload), grace, Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap()),
    LicenseState::Grace,
);
assert_eq!(
    compute_state(Some(&payload), grace, Utc.with_ymd_and_hms(2026, 5, 20, 1, 0, 0).unwrap()),
    LicenseState::Expired,
);
```

---

Navigation: [← Usage](32-usage.md) · **Lifecycle** · [Loopback →](40-loopback.md)
