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());
}
}