Skip to main content

ironflow_api/entities/
agent_role.rs

1//! Agent role request and response DTOs.
2
3use std::str::FromStr;
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Deserializer, Serialize};
8
9use ironflow_core::agent_role::CostPeriod;
10use ironflow_store::entities::AgentRoleRecord;
11
12use crate::error::ApiError;
13
14/// Cost consumed by an agent role over its current period, and its cap.
15///
16/// # Examples
17///
18/// ```
19/// use chrono::Utc;
20/// use ironflow_api::entities::AgentRoleResponse;
21/// use ironflow_store::entities::AgentRoleRecord;
22/// use rust_decimal::Decimal;
23/// use uuid::Uuid;
24///
25/// # fn example() -> Result<(), ironflow_api::error::ApiError> {
26/// let now = Utc::now();
27/// let record = AgentRoleRecord {
28///     id: Uuid::now_v7(),
29///     name: "reviewer".to_string(),
30///     code_cap_usd: Some(Decimal::TEN),
31///     operator_cap_usd: None,
32///     period: "month".to_string(),
33///     period_start: now,
34///     spent_usd: Decimal::TWO,
35///     reserved_usd: Decimal::ONE,
36///     created_at: now,
37///     updated_at: now,
38/// };
39/// let response = AgentRoleResponse::from_record(record, now)?;
40/// assert_eq!(response.remaining_usd, Some(Decimal::new(7, 0)));
41/// # Ok(())
42/// # }
43/// ```
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct AgentRoleResponse {
47    /// Name of the role, as declared by `AgentRole::name`.
48    pub name: String,
49    /// Period of the counter: `day` or `month` (calendar, UTC).
50    pub period: String,
51    /// Start of the current period.
52    pub period_start: DateTime<Utc>,
53    /// When the counter resets.
54    pub resets_at: DateTime<Utc>,
55    /// Cap declared in code, as last seen by a step of the role.
56    pub code_cap_usd: Option<Decimal>,
57    /// Cap set by an operator; it can only lower the code cap.
58    pub operator_cap_usd: Option<Decimal>,
59    /// Cap enforced: the lower of the code and operator caps.
60    pub cap_usd: Option<Decimal>,
61    /// Cost settled in the current period.
62    pub spent_usd: Decimal,
63    /// Cost held by the steps of the role running now.
64    pub reserved_usd: Decimal,
65    /// Room left under the cap, never negative. `null` without a cap.
66    pub remaining_usd: Option<Decimal>,
67    /// Last change of the counter or of a cap.
68    pub updated_at: DateTime<Utc>,
69}
70
71impl AgentRoleResponse {
72    /// Build the response at `now`. A counter whose period is over reads as
73    /// zero over the current period: the store only resets it on the next
74    /// step of the role.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`ApiError::Internal`] if the stored period is unknown.
79    ///
80    /// # Examples
81    ///
82    /// See [`AgentRoleResponse`].
83    pub fn from_record(record: AgentRoleRecord, now: DateTime<Utc>) -> Result<Self, ApiError> {
84        let period = CostPeriod::from_str(&record.period).map_err(|e| {
85            ApiError::Internal(format!(
86                "agent role '{}' has an unknown period '{}': {e}",
87                record.name, record.period
88            ))
89        })?;
90        let current = period.start(now);
91        let (period_start, spent_usd) = if record.period_start < current {
92            (current, Decimal::ZERO)
93        } else {
94            (record.period_start, record.spent_usd)
95        };
96        let cap_usd = record.effective_cap_usd();
97        let remaining_usd =
98            cap_usd.map(|cap| (cap - spent_usd - record.reserved_usd).max(Decimal::ZERO));
99        Ok(Self {
100            name: record.name,
101            period: period.as_str().to_string(),
102            period_start,
103            resets_at: period.next_start(now),
104            code_cap_usd: record.code_cap_usd,
105            operator_cap_usd: record.operator_cap_usd,
106            cap_usd,
107            spent_usd,
108            reserved_usd: record.reserved_usd,
109            remaining_usd,
110            updated_at: record.updated_at,
111        })
112    }
113}
114
115/// Query parameters for listing agent roles.
116#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
117#[derive(Debug, Deserialize)]
118pub struct ListAgentRolesQuery {
119    /// Page number (1-based, defaults to 1).
120    pub page: Option<u32>,
121    /// Items per page (defaults to 50, max 100).
122    pub per_page: Option<u32>,
123}
124
125/// Set or clear the operator cap of an agent role.
126///
127/// The field is required and unknown fields are refused: only an explicit
128/// `null` clears the cap, so `{}` or a misspelled key cannot raise the
129/// enforced cap back to the code cap.
130#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
131#[derive(Debug, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct UpdateAgentRoleCostCapRequest {
134    /// Cap in USD over the role's period, at least 0; `null` clears it. It can
135    /// only lower the cap declared in code.
136    #[serde(deserialize_with = "deserialize_required_option")]
137    #[cfg_attr(feature = "openapi", schema(required = true))]
138    pub operator_cap_usd: Option<Decimal>,
139}
140
141/// Deserialize an `Option` without serde's "missing means `None`" fallback:
142/// with a `deserialize_with` and no `default`, a missing key is an error.
143fn deserialize_required_option<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
144where
145    D: Deserializer<'de>,
146{
147    Option::<Decimal>::deserialize(deserializer)
148}
149
150#[cfg(test)]
151mod tests {
152    use chrono::TimeZone;
153    use serde_json::from_str;
154    use uuid::Uuid;
155
156    use super::*;
157
158    fn record(period_start: DateTime<Utc>) -> AgentRoleRecord {
159        AgentRoleRecord {
160            id: Uuid::now_v7(),
161            name: "reviewer".to_string(),
162            code_cap_usd: Some(Decimal::TEN),
163            operator_cap_usd: Some(Decimal::from(20)),
164            period: "month".to_string(),
165            period_start,
166            spent_usd: Decimal::from(4),
167            reserved_usd: Decimal::ONE,
168            created_at: period_start,
169            updated_at: period_start,
170        }
171    }
172
173    fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
174        Utc.with_ymd_and_hms(y, m, d, 0, 0, 0).single().unwrap()
175    }
176
177    #[test]
178    fn reports_spent_reserved_and_remaining_under_the_lower_cap() {
179        let response = AgentRoleResponse::from_record(record(at(2026, 10, 1)), at(2026, 10, 27))
180            .expect("valid record");
181        assert_eq!(response.cap_usd, Some(Decimal::TEN));
182        assert_eq!(response.spent_usd, Decimal::from(4));
183        assert_eq!(response.reserved_usd, Decimal::ONE);
184        assert_eq!(response.remaining_usd, Some(Decimal::from(5)));
185        assert_eq!(response.resets_at, at(2026, 11, 1));
186    }
187
188    #[test]
189    fn a_stale_period_reads_as_zero() {
190        let response = AgentRoleResponse::from_record(record(at(2026, 9, 1)), at(2026, 10, 27))
191            .expect("valid record");
192        assert_eq!(response.spent_usd, Decimal::ZERO);
193        assert_eq!(response.period_start, at(2026, 10, 1));
194        assert_eq!(response.remaining_usd, Some(Decimal::from(9)));
195    }
196
197    #[test]
198    fn remaining_floors_at_zero_and_is_null_without_cap() {
199        let mut over = record(at(2026, 10, 1));
200        over.spent_usd = Decimal::from(15);
201        let response =
202            AgentRoleResponse::from_record(over, at(2026, 10, 27)).expect("valid record");
203        assert_eq!(response.remaining_usd, Some(Decimal::ZERO));
204
205        let mut uncapped = record(at(2026, 10, 1));
206        uncapped.code_cap_usd = None;
207        uncapped.operator_cap_usd = None;
208        let response =
209            AgentRoleResponse::from_record(uncapped, at(2026, 10, 27)).expect("valid record");
210        assert_eq!(response.remaining_usd, None);
211        assert_eq!(response.cap_usd, None);
212    }
213
214    #[test]
215    fn cap_request_requires_the_field_and_refuses_unknown_ones() {
216        let set: UpdateAgentRoleCostCapRequest =
217            from_str(r#"{"operator_cap_usd": 5}"#).expect("cap");
218        assert_eq!(set.operator_cap_usd, Some(Decimal::from(5)));
219        let clear: UpdateAgentRoleCostCapRequest =
220            from_str(r#"{"operator_cap_usd": null}"#).expect("explicit null");
221        assert_eq!(clear.operator_cap_usd, None);
222
223        assert!(from_str::<UpdateAgentRoleCostCapRequest>("{}").is_err());
224        assert!(from_str::<UpdateAgentRoleCostCapRequest>(r#"{"operator_cap": 5}"#).is_err());
225    }
226
227    #[test]
228    fn an_unknown_period_is_an_internal_error() {
229        let mut broken = record(at(2026, 10, 1));
230        broken.period = "week".to_string();
231        let err = AgentRoleResponse::from_record(broken, at(2026, 10, 27)).expect_err("unknown");
232        assert!(matches!(err, ApiError::Internal(ref m) if m.contains("week")));
233    }
234}