1use crate::client::Client;
11use crate::error::{Error, Result};
12use crate::internal::{apply_pagination, push_opt, ListOptions};
13use crate::pagination::{FetchFn, Page, PageStream};
14use crate::resources::agencies::urlencoding;
15use crate::Record;
16use bon::Builder;
17use std::collections::BTreeMap;
18use std::sync::Arc;
19
20#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct ListBudgetAccountsOptions {
27 #[builder(into)]
30 pub page: Option<u32>,
31 #[builder(into)]
33 pub limit: Option<u32>,
34 #[builder(into)]
36 pub cursor: Option<String>,
37 #[builder(into)]
41 pub shape: Option<String>,
42 #[builder(default)]
44 pub flat: bool,
45 #[builder(default)]
47 pub flat_lists: bool,
48
49 #[builder(into)]
52 pub federal_account_symbol: Option<String>,
53 #[builder(into)]
55 pub fiscal_year: Option<String>,
56 #[builder(into)]
58 pub fiscal_year_gte: Option<String>,
59 #[builder(into)]
61 pub fiscal_year_lte: Option<String>,
62 #[builder(into)]
64 pub agency_code: Option<String>,
65 #[builder(into)]
67 pub bureau_name: Option<String>,
68 #[builder(into)]
70 pub account_title: Option<String>,
71 #[builder(into)]
73 pub subfunction_code: Option<String>,
74 #[builder(into)]
76 pub bea_category: Option<String>,
77 #[builder(into)]
79 pub on_off_budget: Option<String>,
80 #[builder(into)]
82 pub search: Option<String>,
83 #[builder(into)]
85 pub ordering: Option<String>,
86
87 #[builder(default)]
90 pub extra: BTreeMap<String, String>,
91}
92
93impl ListBudgetAccountsOptions {
94 fn to_query(&self) -> Vec<(String, String)> {
95 let mut q = Vec::new();
96 apply_pagination(
97 &mut q,
98 self.page,
99 self.limit,
100 self.cursor.as_deref(),
101 self.shape.as_deref(),
102 self.flat,
103 self.flat_lists,
104 );
105 push_opt(
106 &mut q,
107 "federal_account_symbol",
108 self.federal_account_symbol.as_deref(),
109 );
110 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
111 push_opt(&mut q, "fiscal_year__gte", self.fiscal_year_gte.as_deref());
112 push_opt(&mut q, "fiscal_year__lte", self.fiscal_year_lte.as_deref());
113 push_opt(&mut q, "agency_code", self.agency_code.as_deref());
114 push_opt(&mut q, "bureau_name", self.bureau_name.as_deref());
115 push_opt(
116 &mut q,
117 "account_title__icontains",
118 self.account_title.as_deref(),
119 );
120 push_opt(&mut q, "subfunction_code", self.subfunction_code.as_deref());
121 push_opt(&mut q, "bea_category", self.bea_category.as_deref());
122 push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref());
123 push_opt(&mut q, "search", self.search.as_deref());
124 push_opt(&mut q, "ordering", self.ordering.as_deref());
125 for (k, v) in &self.extra {
126 if !v.is_empty() {
127 q.push((k.clone(), v.clone()));
128 }
129 }
130 q
131 }
132}
133
134#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
136#[non_exhaustive]
137pub struct BudgetAccountQuartersOptions {
138 #[builder(into)]
140 pub page: Option<u32>,
141 #[builder(into)]
143 pub limit: Option<u32>,
144 #[builder(into)]
146 pub tas: Option<String>,
147}
148
149impl BudgetAccountQuartersOptions {
150 fn to_query(&self) -> Vec<(String, String)> {
151 let mut q = Vec::new();
152 apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
153 push_opt(&mut q, "tas", self.tas.as_deref());
154 q
155 }
156}
157
158#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
160#[non_exhaustive]
161pub struct BudgetAccountRecipientsOptions {
162 #[builder(into)]
164 pub page: Option<u32>,
165 #[builder(into)]
167 pub limit: Option<u32>,
168 #[builder(into)]
170 pub funding_organization_id: Option<String>,
171}
172
173impl BudgetAccountRecipientsOptions {
174 fn to_query(&self) -> Vec<(String, String)> {
175 let mut q = Vec::new();
176 apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
177 push_opt(
178 &mut q,
179 "funding_organization_id",
180 self.funding_organization_id.as_deref(),
181 );
182 q
183 }
184}
185
186impl Client {
187 pub async fn list_budget_accounts(
189 &self,
190 opts: ListBudgetAccountsOptions,
191 ) -> Result<Page<Record>> {
192 let q = opts.to_query();
193 let bytes = self.get_bytes("/api/budget/accounts/", &q).await?;
194 Page::decode(&bytes)
195 }
196
197 pub fn iterate_budget_accounts(&self, opts: ListBudgetAccountsOptions) -> PageStream<Record> {
199 let opts = Arc::new(opts);
200 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
201 let mut next = (*opts).clone();
202 next.page = page;
203 next.cursor = cursor;
204 Box::pin(async move { client.list_budget_accounts(next).await })
205 });
206 PageStream::new(self.clone(), fetch)
207 }
208
209 pub async fn get_budget_account(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
211 if id.is_empty() {
212 return Err(Error::Validation {
213 message: "get_budget_account: id is required".into(),
214 response: None,
215 });
216 }
217 let mut q = Vec::new();
218 opts.unwrap_or_default().apply(&mut q);
219 let path = format!("/api/budget/accounts/{}/", urlencoding(id));
220 self.get_json::<Record>(&path, &q).await
221 }
222
223 pub async fn get_budget_account_quarters(
228 &self,
229 id: &str,
230 opts: Option<BudgetAccountQuartersOptions>,
231 ) -> Result<Page<Record>> {
232 if id.is_empty() {
233 return Err(Error::Validation {
234 message: "get_budget_account_quarters: id is required".into(),
235 response: None,
236 });
237 }
238 let q = opts.unwrap_or_default().to_query();
239 let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id));
240 let bytes = self.get_bytes(&path, &q).await?;
241 Page::decode(&bytes)
242 }
243
244 pub async fn get_budget_account_recipients(
249 &self,
250 id: &str,
251 opts: Option<BudgetAccountRecipientsOptions>,
252 ) -> Result<Page<Record>> {
253 if id.is_empty() {
254 return Err(Error::Validation {
255 message: "get_budget_account_recipients: id is required".into(),
256 response: None,
257 });
258 }
259 let q = opts.unwrap_or_default().to_query();
260 let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id));
261 let bytes = self.get_bytes(&path, &q).await?;
262 Page::decode(&bytes)
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
271 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
272 }
273
274 #[test]
275 fn options_emit_all_filters() {
276 let opts = ListBudgetAccountsOptions::builder()
277 .federal_account_symbol("097-0100")
278 .fiscal_year("2024")
279 .fiscal_year_gte("2020")
280 .fiscal_year_lte("2025")
281 .agency_code("9700")
282 .bureau_name("Operation and Maintenance")
283 .account_title("readiness")
284 .subfunction_code("051")
285 .bea_category("discretionary")
286 .on_off_budget("on")
287 .search("operations")
288 .ordering("-enacted_ba")
289 .build();
290 let q = opts.to_query();
291 assert_eq!(
292 get_q(&q, "federal_account_symbol").as_deref(),
293 Some("097-0100")
294 );
295 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
296 assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020"));
297 assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025"));
298 assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700"));
299 assert_eq!(
300 get_q(&q, "bureau_name").as_deref(),
301 Some("Operation and Maintenance")
302 );
303 assert_eq!(
304 get_q(&q, "account_title__icontains").as_deref(),
305 Some("readiness")
306 );
307 assert_eq!(get_q(&q, "subfunction_code").as_deref(), Some("051"));
308 assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary"));
309 assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on"));
310 assert_eq!(get_q(&q, "search").as_deref(), Some("operations"));
311 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-enacted_ba"));
312 }
313
314 #[test]
315 fn pagination_and_shape_emit() {
316 let opts = ListBudgetAccountsOptions::builder()
317 .page(2u32)
318 .limit(50u32)
319 .shape(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
320 .build();
321 let q = opts.to_query();
322 assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
323 assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
324 assert_eq!(
325 get_q(&q, "shape").as_deref(),
326 Some(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
327 );
328 }
329
330 #[test]
331 fn extra_forwards_range_filters() {
332 let mut extra = BTreeMap::new();
333 extra.insert("enacted_ba__gte".to_string(), "1000000".to_string());
334 let opts = ListBudgetAccountsOptions::builder().extra(extra).build();
335 let q = opts.to_query();
336 assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000"));
337 }
338
339 #[test]
340 fn quarters_options_emit_tas_and_pagination() {
341 let q = BudgetAccountQuartersOptions::builder()
342 .page(2u32)
343 .limit(50u32)
344 .tas("097-2020/2021-0100")
345 .build()
346 .to_query();
347 assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
348 assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
349 assert_eq!(get_q(&q, "tas").as_deref(), Some("097-2020/2021-0100"));
350 assert_eq!(q.len(), 3);
351 }
352
353 #[test]
354 fn recipients_options_emit_funding_organization_id() {
355 let q = BudgetAccountRecipientsOptions::builder()
356 .funding_organization_id("0b7e4c1e-0000-4000-8000-000000000000")
357 .build()
358 .to_query();
359 assert_eq!(
360 q,
361 vec![(
362 "funding_organization_id".to_string(),
363 "0b7e4c1e-0000-4000-8000-000000000000".to_string()
364 )]
365 );
366 }
367
368 #[tokio::test]
369 async fn get_budget_account_empty_id_returns_validation() {
370 let client = Client::builder().api_key("x").build().expect("build");
371 let err = client
372 .get_budget_account("", None)
373 .await
374 .expect_err("must error");
375 match err {
376 Error::Validation { message, .. } => assert!(message.contains("id")),
377 other => panic!("expected Validation, got {other:?}"),
378 }
379 }
380
381 #[tokio::test]
382 async fn get_budget_account_quarters_empty_id_returns_validation() {
383 let client = Client::builder().api_key("x").build().expect("build");
384 let err = client
385 .get_budget_account_quarters("", None)
386 .await
387 .expect_err("must error");
388 match err {
389 Error::Validation { message, .. } => assert!(message.contains("id")),
390 other => panic!("expected Validation, got {other:?}"),
391 }
392 }
393
394 #[tokio::test]
395 async fn get_budget_account_recipients_empty_id_returns_validation() {
396 let client = Client::builder().api_key("x").build().expect("build");
397 let err = client
398 .get_budget_account_recipients("", None)
399 .await
400 .expect_err("must error");
401 match err {
402 Error::Validation { message, .. } => assert!(message.contains("id")),
403 other => panic!("expected Validation, got {other:?}"),
404 }
405 }
406}