Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/src/convert.rs

//! Wire ⇄ Rust, both directions, for the mirrored types. A fact converts
//! WITHOUT touching `signature` or `source`: any re-encoding here would forge our
//! own fact.

use monetize_cloud::{Resource, ServerId, ServerRole, Zone};
use monetize_pay::{Currency, Market, Money};
use monetize_product::{EntitlementFact, State, TenantId, Usage};
use monetize_proto as pb;
use tonic::Status;

/// The product's enforcement kind, on the wire.
pub fn enforcement_to_pb(e: monetize_product::Enforcement) -> pb::Enforcement {
    match e {
        monetize_product::Enforcement::Enforced => pb::Enforcement::Enforced,
        monetize_product::Enforcement::Measured => pb::Enforcement::Measured,
        monetize_product::Enforcement::Provisioned => pb::Enforcement::Provisioned,
    }
}

/// **Where a meter's number is meaningful.** The second axis beside
/// [`enforcement_to_pb`], and the one the fleet total reads: a `Product`-scoped
/// meter is never summed across tenants.
pub fn scope_to_pb(s: monetize_product::Scope) -> pb::Scope {
    match s {
        monetize_product::Scope::Tenant => pb::Scope::Tenant,
        monetize_product::Scope::Product => pb::Scope::Product,
    }
}

pub fn state_to_pb(s: State) -> pb::State {
    match s {
        State::Free => pb::State::Free,
        State::Paid => pb::State::Paid,
        State::Grace => pb::State::Grace,
        State::Suspended => pb::State::Suspended,
        State::Retention => pb::State::Retention,
    }
}

pub fn state_from_pb(s: pb::State) -> Result<State, Status> {
    Ok(match s {
        pb::State::Unspecified => return Err(Status::invalid_argument("state is required")),
        pb::State::Free => State::Free,
        pb::State::Paid => State::Paid,
        pb::State::Grace => State::Grace,
        pb::State::Suspended => State::Suspended,
        pb::State::Retention => State::Retention,
    })
}

pub fn fact_to_pb(f: &EntitlementFact) -> pb::EntitlementFact {
    pb::EntitlementFact {
        tenant: f.tenant.0.clone(),
        plan: f.plan.clone(),
        state: state_to_pb(f.state) as i32,
        paid_until_unix_ms: f.paid_until_unix_ms,
        caps: f.caps.clone(),
        source: f.source.clone(),
        signature: f.signature.clone(),
        issued_unix_ms: f.issued_unix_ms,
        issued_signature: f.issued_signature.clone(),
    }
}

pub fn fact_from_pb(f: &pb::EntitlementFact) -> Result<EntitlementFact, Status> {
    if f.tenant.is_empty() {
        return Err(Status::invalid_argument("fact.tenant is required"));
    }
    Ok(EntitlementFact {
        tenant: TenantId(f.tenant.clone()),
        plan: f.plan.clone(),
        state: state_from_pb(f.state())?,
        paid_until_unix_ms: f.paid_until_unix_ms,
        caps: f.caps.clone(),
        source: f.source.clone(),
        signature: f.signature.clone(),
        issued_unix_ms: f.issued_unix_ms,
        issued_signature: f.issued_signature.clone(),
    })
}

pub fn usage_to_pb(tenant: &TenantId, u: &Usage) -> pb::UsageReport {
    pb::UsageReport {
        tenant: tenant.0.clone(),
        meters: u
            .meters
            .iter()
            .map(|(k, v)| pb::Meter {
                name: k.clone(),
                value: *v,
            })
            .collect(),
        measured_at_unix_ms: u.measured_at_unix_ms,
    }
}

pub fn money_to_pb(m: Money) -> pb::Money {
    pb::Money {
        minor: m.minor,
        currency: match m.currency {
            Currency::Sek => pb::Currency::Sek,
            Currency::Eur => pb::Currency::Eur,
            Currency::Dkk => pb::Currency::Dkk,
        } as i32,
    }
}

pub fn money_from_pb(m: Option<&pb::Money>) -> Result<Money, Status> {
    let m = m.ok_or_else(|| Status::invalid_argument("money is required"))?;
    Ok(Money {
        minor: m.minor,
        currency: match m.currency() {
            pb::Currency::Sek => Currency::Sek,
            pb::Currency::Eur => Currency::Eur,
            pb::Currency::Dkk => Currency::Dkk,
            pb::Currency::Unspecified => {
                return Err(Status::invalid_argument("money.currency is required"))
            }
        },
    })
}

pub fn market_to_pb(m: Market) -> pb::Market {
    match m {
        Market::Se => pb::Market::Se,
        Market::Fi => pb::Market::Fi,
        Market::Dk => pb::Market::Dk,
    }
}

pub fn resource_to_pb(r: &Resource) -> pb::Resource {
    use pb::resource::{Compute, Disk, Kind, Server};
    let kind = match r {
        Resource::Disk { server, gib } => Kind::Disk(Disk {
            server: server.0.clone(),
            gib: *gib,
        }),
        Resource::Server { plan, zone, role } => Kind::Server(Server {
            plan: plan.clone(),
            zone: zone.0.clone(),
            role: match role {
                ServerRole::Primary => pb::ServerRole::Primary,
                ServerRole::Replica => pb::ServerRole::Replica,
            } as i32,
        }),
        Resource::Compute { server, plan } => Kind::Compute(Compute {
            server: server.0.clone(),
            plan: plan.clone(),
        }),
    };
    pb::Resource { kind: Some(kind) }
}

pub fn resource_from_pb(r: &pb::Resource) -> Result<Resource, Status> {
    use pb::resource::Kind;
    Ok(
        match r
            .kind
            .as_ref()
            .ok_or_else(|| Status::invalid_argument("resource.kind is required"))?
        {
            Kind::Disk(d) => Resource::Disk {
                server: ServerId(d.server.clone()),
                gib: d.gib,
            },
            Kind::Server(s) => Resource::Server {
                plan: s.plan.clone(),
                zone: Zone(s.zone.clone()),
                role: match s.role() {
                    pb::ServerRole::Primary => ServerRole::Primary,
                    pb::ServerRole::Replica => ServerRole::Replica,
                    pb::ServerRole::Unspecified => {
                        return Err(Status::invalid_argument("server.role is required"))
                    }
                },
            },
            Kind::Compute(c) => Resource::Compute {
                server: ServerId(c.server.clone()),
                plan: c.plan.clone(),
            },
        },
    )
}

pub fn row_to_pb(r: &monetize::ledger::Row) -> pb::LedgerRow {
    pb::LedgerRow {
        reference: r.step.reference().to_string(),
        seq: r.seq,
        at_unix_ms: r.at_unix_ms,
        // `Step::name` is the one writer of these strings: the wire row, the
        // console badge and `LedgerAppended.step` cannot drift apart.
        kind: r.step.name().into(),
        json: serde_json::to_string(&r.step).unwrap_or_default(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resources_money_and_facts_round_trip_through_the_wire() {
        for r in [
            Resource::Disk {
                server: ServerId("gunnar-se-sto1".into()),
                gib: 10,
            },
            Resource::Server {
                plan: "2xCPU-4GB".into(),
                zone: Zone("fi-hel1".into()),
                role: ServerRole::Replica,
            },
            Resource::Compute {
                server: ServerId("gunnar-se-sto1".into()),
                plan: "4xCPU-8GB".into(),
            },
        ] {
            assert_eq!(resource_from_pb(&resource_to_pb(&r)).unwrap(), r);
        }
        let m = Money {
            minor: 22_000,
            currency: Currency::Sek,
        };
        assert_eq!(money_from_pb(Some(&money_to_pb(m))).unwrap(), m);
        assert!(money_from_pb(None).is_err());
        assert_eq!(
            (market_to_pb(Market::Se), market_to_pb(Market::Fi)),
            (pb::Market::Se, pb::Market::Fi)
        );
        let fact = monetize::Signer::generate().sign_fact(EntitlementFact {
            tenant: TenantId("team/sub".into()),
            plan: "gunnar/team/sub/2026-09-03+pack_bytes=7".into(),
            state: State::Grace,
            paid_until_unix_ms: Some(5),
            caps: [("pack_bytes".to_string(), 7u64)].into(),
            source: "payment:mock:gunnar/team/sub/2026-09-03+pack_bytes=7".into(),
            signature: vec![],
            issued_unix_ms: None,
            issued_signature: vec![],
        });
        // The round trip carries the stamp too, which is the half a wire
        // conversion is most likely to drop in silence.
        assert!(fact.issued_unix_ms.is_some() && fact.issued_signature.len() == 64);
        assert_eq!(fact_from_pb(&fact_to_pb(&fact)).unwrap(), fact);
        let mut bad = fact_to_pb(&fact);
        bad.state = 0;
        assert!(fact_from_pb(&bad).is_err());
    }
}