# License

Offline-snapshot helpers. Decode the base64-wrapped JSON payload, verify the Ed25519 signature, compute remaining quota, and reason about expiry / grace / updates-window.

```rust
use akira_billing::{
    decode_license, verify_license, compute_remaining, RemainingValue,
    is_expired, is_in_grace, can_use_update, period_reset_at,
    DecodedLicense,
};
use akira_billing::types::{SignedLicense, LicenseSnapshotPayload, UsageFeatureState};
```

## SignedLicense (wire shape)

```rust
pub struct SignedLicense {
    pub key_id: String,
    pub algorithm: String,        // currently "ed25519"
    pub payload: String,          // base64(LicenseSnapshotPayload JSON)
    pub signature: String,        // base64(Ed25519 signature over payload bytes)
    pub valid_until: String,      // ISO-8601, mirror of payload.valid_until
}
```

Store verbatim. Never re-encode `payload` — the signature covers the exact bytes the server signed.

## decode_license

```rust
pub fn decode_license(signed: &SignedLicense) -> Result<DecodedLicense, Error>;

pub struct DecodedLicense {
    pub raw: SignedLicense,
    pub payload: LicenseSnapshotPayload,
}
```

Decodes the base64 payload and parses the JSON. Returns `Error::Api { status: 0, code }` on either failure (status 0 to mark "no HTTP round trip happened").

## verify_license

```rust
pub fn verify_license(signed: &SignedLicense, public_key_b64: &str) -> Result<bool, Error>;
```

- Returns `Ok(false)` when `algorithm != "ed25519"`.
- Decodes payload + signature + public-key from base64.
- Validates `public_key.len() == 32` and `signature.len() == 64`.
- Calls `ed25519_dalek::VerifyingKey::verify`. Returns `Ok(true)` on success, `Ok(false)` on signature mismatch.

```rust
let keys = client.public_license_keys().await?;
let active = keys.keys.iter()
    .find(|k| k.key_id == signed.key_id)
    .unwrap_or_else(|| &keys.keys[0]);

let ok = verify_license(&signed, &active.public_key_base64)?;
if !ok {
    return Err(anyhow::anyhow!("forged license"));
}
```

## LicenseSnapshotPayload

The deserialized JSON. Mirror of the JS SDK shape (snake_case wire form):

```rust
pub struct LicenseSnapshotPayload {
    pub v: Option<u32>,
    pub key_id: String,
    pub customer_id: String,
    pub product_key: String,
    pub plan_key: String,
    pub licensing_mode: Option<LicensingMode>,    // "offline_snapshot" | "online_realtime"
    pub features: HashMap<String, bool>,
    pub usage: HashMap<String, UsageFeatureState>,
    pub fingerprint_hash: String,
    pub serial: u64,
    pub issued_at: String,
    pub valid_until: String,
    pub paid_up_until: Option<String>,
    pub fallback_release_date: Option<String>,
    pub updates_window_days: Option<u32>,
    pub offline_grace_days: Option<u32>,
}

pub enum UsageFeatureState {
    Bool {
        enabled: bool,
    },
    Counter {
        allowance: u64,
        period: UsagePeriod,
        period_start: String,
        period_end: String,
        consumed_at_issue: u64,
    },
}
```

## compute_remaining

```rust
pub fn compute_remaining(
    payload: &LicenseSnapshotPayload,
    feature: &str,
    consumed_local: u64,
) -> Option<RemainingValue>;

pub enum RemainingValue {
    Finite(u64),
    Unlimited,
}

impl RemainingValue {
    pub fn is_zero(&self) -> bool;
}
```

Returns:

- `None` — feature absent from `payload.usage`.
- `Some(Unlimited)` — boolean feature with `enabled: true`.
- `Some(Finite(0))` — boolean feature with `enabled: false`.
- `Some(Finite(n))` — `allowance.saturating_sub(consumed_at_issue + consumed_local)`.

`consumed_local` is the in-memory tally since the snapshot was issued.

```rust
match compute_remaining(&decoded.payload, "agent_run", local) {
    Some(RemainingValue::Unlimited) => allow(),
    Some(RemainingValue::Finite(n)) if n > 0 => { allow(); record(1); }
    Some(RemainingValue::Finite(_)) => show_paywall(),
    None => deny_missing_feature(),
}
```

## is_expired / is_in_grace

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

pub fn is_in_grace(
    payload: &LicenseSnapshotPayload,
    grace_seconds: u64,
    now: Option<DateTime<Utc>>,
) -> bool;
```

Both default `now` to `Utc::now()` when `None`. `grace_seconds` is the offline grace window; convert from `payload.offline_grace_days.unwrap_or(0) as u64 * 86_400`.

## can_use_update

```rust
pub fn can_use_update(payload: &LicenseSnapshotPayload, release_date: DateTime<Utc>) -> bool;
```

Enforces the "perpetual fallback + updates window" model:

- If neither `paid_up_until` nor `fallback_release_date` is set → returns `true` (no restriction).
- Effective cutoff = `max(paid_up_until, fallback_release_date) + updates_window_days days`.
- Returns `release_date <= cutoff`.

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

let release = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
if !can_use_update(&payload, release) {
    show_renew_prompt();
}
```

## period_reset_at

```rust
pub fn period_reset_at(payload: &LicenseSnapshotPayload, feature: &str) -> Option<DateTime<Utc>>;
```

Returns the counter's `period_end` parsed as a UTC date. `None` when the feature is missing or boolean. Surface in the UI as "Resets on …".

## Worked example — offline gate

```rust
use akira_billing::{
    decode_license, verify_license, compute_remaining, is_in_grace, RemainingValue,
};

let res = client
    .license_refresh(LicenseRefreshPayload {
        product: "unified-dev",
        fingerprint: &fp,
    })
    .await?;

let decoded = decode_license(&res.license)?;

let pub_keys = client.public_license_keys().await?;
let active = pub_keys.keys.iter()
    .find(|k| k.key_id == res.license.key_id)
    .ok_or_else(|| anyhow::anyhow!("no public key"))?;

anyhow::ensure!(verify_license(&res.license, &active.public_key_base64)?, "forged license");

let grace = decoded.payload.offline_grace_days.unwrap_or(0) as u64 * 86_400;
anyhow::ensure!(is_in_grace(&decoded.payload, grace, None), "license expired");

match compute_remaining(&decoded.payload, "agent_run", local_consumed) {
    Some(RemainingValue::Unlimited)         => run_agent().await?,
    Some(RemainingValue::Finite(n)) if n > 0 => run_agent().await?,
    _                                        => return Err(anyhow::anyhow!("quota reached")),
}
```

Wrap the whole sequence behind `Gate::require("agent_run")` — see [31-gate](31-gate.md).

---

Navigation: [← OAuth](24-oauth.md) · **License** · [Gate →](31-gate.md)
