1use 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct AgentRoleResponse {
47 pub name: String,
49 pub period: String,
51 pub period_start: DateTime<Utc>,
53 pub resets_at: DateTime<Utc>,
55 pub code_cap_usd: Option<Decimal>,
57 pub operator_cap_usd: Option<Decimal>,
59 pub cap_usd: Option<Decimal>,
61 pub spent_usd: Decimal,
63 pub reserved_usd: Decimal,
65 pub remaining_usd: Option<Decimal>,
67 pub updated_at: DateTime<Utc>,
69}
70
71impl AgentRoleResponse {
72 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#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
117#[derive(Debug, Deserialize)]
118pub struct ListAgentRolesQuery {
119 pub page: Option<u32>,
121 pub per_page: Option<u32>,
123}
124
125#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
131#[derive(Debug, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct UpdateAgentRoleCostCapRequest {
134 #[serde(deserialize_with = "deserialize_required_option")]
137 #[cfg_attr(feature = "openapi", schema(required = true))]
138 pub operator_cap_usd: Option<Decimal>,
139}
140
141fn 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}