1use crate::client::Client;
14use crate::error::{Error, Result};
15use crate::internal::{apply_pagination, push_opt, push_opt_bool, ListOptions};
16use crate::pagination::{FetchFn, Page, PageStream};
17use crate::resources::agencies::urlencoding;
18use crate::Record;
19use bon::Builder;
20use std::collections::BTreeMap;
21use std::sync::Arc;
22
23#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ListBudgetAccountsOptions {
31 #[builder(into)]
34 pub page: Option<u32>,
35 #[builder(into)]
37 pub limit: Option<u32>,
38 #[builder(into)]
40 pub cursor: Option<String>,
41 #[builder(into)]
45 pub shape: Option<String>,
46 #[builder(default)]
48 pub flat: bool,
49 #[builder(default)]
51 pub flat_lists: bool,
52
53 #[builder(into)]
56 pub federal_account_symbol: Option<String>,
57 #[builder(into)]
59 pub fiscal_year: Option<String>,
60 #[builder(into)]
62 pub fiscal_year_gte: Option<String>,
63 #[builder(into)]
65 pub fiscal_year_lte: Option<String>,
66 #[builder(into)]
68 pub data_through_period: Option<String>,
69 #[builder(into)]
71 pub data_through_period_gte: Option<String>,
72 #[builder(into)]
74 pub data_through_period_lte: Option<String>,
75 pub data_through_period_isnull: Option<bool>,
77 #[builder(into)]
79 pub agency: Option<String>,
80 #[builder(into)]
82 pub agency_code: Option<String>,
83 #[builder(into)]
85 pub bureau_name: Option<String>,
86 #[builder(into)]
88 pub account_title: Option<String>,
89 #[builder(into)]
91 pub subfunction_code: Option<String>,
92 #[builder(into)]
94 pub bea_category: Option<String>,
95 #[builder(into)]
97 pub on_off_budget: Option<String>,
98 #[builder(into)]
101 pub account_category: Option<String>,
102 #[builder(into)]
104 pub account_category_in: Option<String>,
105 #[builder(into)]
107 pub search: Option<String>,
108 #[builder(into)]
111 pub ordering: Option<String>,
112
113 #[builder(default)]
116 pub extra: BTreeMap<String, String>,
117}
118
119impl ListBudgetAccountsOptions {
120 fn to_query(&self) -> Vec<(String, String)> {
121 let mut q = Vec::new();
122 apply_pagination(
123 &mut q,
124 self.page,
125 self.limit,
126 self.cursor.as_deref(),
127 self.shape.as_deref(),
128 self.flat,
129 self.flat_lists,
130 );
131 push_opt(
132 &mut q,
133 "federal_account_symbol",
134 self.federal_account_symbol.as_deref(),
135 );
136 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
137 push_opt(&mut q, "fiscal_year__gte", self.fiscal_year_gte.as_deref());
138 push_opt(&mut q, "fiscal_year__lte", self.fiscal_year_lte.as_deref());
139 push_opt(
140 &mut q,
141 "data_through_period",
142 self.data_through_period.as_deref(),
143 );
144 push_opt(
145 &mut q,
146 "data_through_period__gte",
147 self.data_through_period_gte.as_deref(),
148 );
149 push_opt(
150 &mut q,
151 "data_through_period__lte",
152 self.data_through_period_lte.as_deref(),
153 );
154 push_opt_bool(
155 &mut q,
156 "data_through_period__isnull",
157 self.data_through_period_isnull,
158 );
159 push_opt(&mut q, "agency", self.agency.as_deref());
160 push_opt(&mut q, "agency_code", self.agency_code.as_deref());
161 push_opt(&mut q, "bureau_name", self.bureau_name.as_deref());
162 push_opt(
163 &mut q,
164 "account_title__icontains",
165 self.account_title.as_deref(),
166 );
167 push_opt(&mut q, "subfunction_code", self.subfunction_code.as_deref());
168 push_opt(&mut q, "bea_category", self.bea_category.as_deref());
169 push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref());
170 push_opt(&mut q, "account_category", self.account_category.as_deref());
171 push_opt(
172 &mut q,
173 "account_category__in",
174 self.account_category_in.as_deref(),
175 );
176 push_opt(&mut q, "search", self.search.as_deref());
177 push_opt(&mut q, "ordering", self.ordering.as_deref());
178 for (k, v) in &self.extra {
179 if !v.is_empty() {
180 q.push((k.clone(), v.clone()));
181 }
182 }
183 q
184 }
185}
186
187#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct BudgetAccountQuartersOptions {
191 #[builder(into)]
193 pub page: Option<u32>,
194 #[builder(into)]
196 pub limit: Option<u32>,
197 #[builder(into)]
199 pub tas: Option<String>,
200}
201
202impl BudgetAccountQuartersOptions {
203 fn to_query(&self) -> Vec<(String, String)> {
204 let mut q = Vec::new();
205 apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
206 push_opt(&mut q, "tas", self.tas.as_deref());
207 q
208 }
209}
210
211#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
213#[non_exhaustive]
214pub struct BudgetAccountRecipientsOptions {
215 #[builder(into)]
217 pub page: Option<u32>,
218 #[builder(into)]
220 pub limit: Option<u32>,
221 #[builder(into)]
223 pub funding_organization_id: Option<String>,
224}
225
226impl BudgetAccountRecipientsOptions {
227 fn to_query(&self) -> Vec<(String, String)> {
228 let mut q = Vec::new();
229 apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
230 push_opt(
231 &mut q,
232 "funding_organization_id",
233 self.funding_organization_id.as_deref(),
234 );
235 q
236 }
237}
238
239impl Client {
240 pub async fn list_budget_accounts(
242 &self,
243 opts: ListBudgetAccountsOptions,
244 ) -> Result<Page<Record>> {
245 let q = opts.to_query();
246 self.get_page("/api/budget/accounts/", &q).await
247 }
248
249 pub fn iterate_budget_accounts(&self, opts: ListBudgetAccountsOptions) -> PageStream<Record> {
251 let opts = Arc::new(opts);
252 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
253 let mut next = (*opts).clone();
254 next.page = page;
255 next.cursor = cursor;
256 Box::pin(async move { client.list_budget_accounts(next).await })
257 });
258 PageStream::new(self.clone(), fetch)
259 }
260
261 pub async fn get_budget_account(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
263 if id.is_empty() {
264 return Err(Error::Validation {
265 message: "get_budget_account: id is required".into(),
266 response: None,
267 });
268 }
269 let mut q = Vec::new();
270 opts.unwrap_or_default().apply(&mut q);
271 let path = format!("/api/budget/accounts/{}/", urlencoding(id));
272 self.get_json::<Record>(&path, &q).await
273 }
274
275 pub async fn get_budget_account_quarters(
280 &self,
281 id: &str,
282 opts: Option<BudgetAccountQuartersOptions>,
283 ) -> Result<Page<Record>> {
284 if id.is_empty() {
285 return Err(Error::Validation {
286 message: "get_budget_account_quarters: id is required".into(),
287 response: None,
288 });
289 }
290 let q = opts.unwrap_or_default().to_query();
291 let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id));
292 self.get_page(&path, &q).await
293 }
294
295 pub async fn get_budget_account_recipients(
300 &self,
301 id: &str,
302 opts: Option<BudgetAccountRecipientsOptions>,
303 ) -> Result<Page<Record>> {
304 if id.is_empty() {
305 return Err(Error::Validation {
306 message: "get_budget_account_recipients: id is required".into(),
307 response: None,
308 });
309 }
310 let q = opts.unwrap_or_default().to_query();
311 let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id));
312 self.get_page(&path, &q).await
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
321 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
322 }
323
324 #[test]
325 fn options_emit_all_filters() {
326 let opts = ListBudgetAccountsOptions::builder()
327 .federal_account_symbol("097-0100")
328 .fiscal_year("2024")
329 .fiscal_year_gte("2020")
330 .fiscal_year_lte("2025")
331 .data_through_period("9")
332 .data_through_period_gte("3")
333 .data_through_period_lte("11")
334 .data_through_period_isnull(false)
335 .agency_code("9700")
336 .bureau_name("Operation and Maintenance")
337 .account_title("readiness")
338 .subfunction_code("051")
339 .bea_category("discretionary")
340 .on_off_budget("on")
341 .account_category("credit_financing")
342 .account_category_in("budgetary,credit_financing")
343 .search("operations")
344 .ordering("-enacted_ba")
345 .build();
346 let q = opts.to_query();
347 assert_eq!(
348 get_q(&q, "federal_account_symbol").as_deref(),
349 Some("097-0100")
350 );
351 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
352 assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020"));
353 assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025"));
354 assert_eq!(get_q(&q, "data_through_period").as_deref(), Some("9"));
355 assert_eq!(get_q(&q, "data_through_period__gte").as_deref(), Some("3"));
356 assert_eq!(get_q(&q, "data_through_period__lte").as_deref(), Some("11"));
357 assert_eq!(
358 get_q(&q, "data_through_period__isnull").as_deref(),
359 Some("false")
360 );
361 assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700"));
362 assert_eq!(
363 get_q(&q, "bureau_name").as_deref(),
364 Some("Operation and Maintenance")
365 );
366 assert_eq!(
367 get_q(&q, "account_title__icontains").as_deref(),
368 Some("readiness")
369 );
370 assert_eq!(get_q(&q, "subfunction_code").as_deref(), Some("051"));
371 assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary"));
372 assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on"));
373 assert_eq!(
374 get_q(&q, "account_category").as_deref(),
375 Some("credit_financing")
376 );
377 assert_eq!(
378 get_q(&q, "account_category__in").as_deref(),
379 Some("budgetary,credit_financing")
380 );
381 assert_eq!(get_q(&q, "search").as_deref(), Some("operations"));
382 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-enacted_ba"));
383 }
384
385 #[test]
386 fn pagination_and_shape_emit() {
387 let opts = ListBudgetAccountsOptions::builder()
388 .page(2u32)
389 .limit(50u32)
390 .shape(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
391 .build();
392 let q = opts.to_query();
393 assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
394 assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
395 assert_eq!(
396 get_q(&q, "shape").as_deref(),
397 Some(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
398 );
399 }
400
401 #[test]
402 fn minimal_shape_carries_category_and_anomalies() {
403 let fields: Vec<&str> = crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL.split(',').collect();
404 assert!(fields.contains(&"account_category"));
405 assert!(fields.contains(&"source_anomalies"));
406 }
407
408 #[test]
409 fn minimal_shape_puts_data_through_period_after_fiscal_year() {
410 let fields: Vec<&str> = crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL.split(',').collect();
411 let fy = fields.iter().position(|f| *f == "fiscal_year").unwrap();
412 assert_eq!(fields[fy + 1], "data_through_period");
413 }
414
415 #[test]
416 fn extra_forwards_range_filters() {
417 let mut extra = BTreeMap::new();
418 extra.insert("enacted_ba__gte".to_string(), "1000000".to_string());
419 let opts = ListBudgetAccountsOptions::builder().extra(extra).build();
420 let q = opts.to_query();
421 assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000"));
422 }
423
424 #[test]
425 fn quarters_options_emit_tas_and_pagination() {
426 let q = BudgetAccountQuartersOptions::builder()
427 .page(2u32)
428 .limit(50u32)
429 .tas("097-2020/2021-0100")
430 .build()
431 .to_query();
432 assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
433 assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
434 assert_eq!(get_q(&q, "tas").as_deref(), Some("097-2020/2021-0100"));
435 assert_eq!(q.len(), 3);
436 }
437
438 #[test]
439 fn recipients_options_emit_funding_organization_id() {
440 let q = BudgetAccountRecipientsOptions::builder()
441 .funding_organization_id("0b7e4c1e-0000-4000-8000-000000000000")
442 .build()
443 .to_query();
444 assert_eq!(
445 q,
446 vec![(
447 "funding_organization_id".to_string(),
448 "0b7e4c1e-0000-4000-8000-000000000000".to_string()
449 )]
450 );
451 }
452
453 #[tokio::test]
454 async fn get_budget_account_empty_id_returns_validation() {
455 let client = Client::builder().api_key("x").build().expect("build");
456 let err = client
457 .get_budget_account("", None)
458 .await
459 .expect_err("must error");
460 match err {
461 Error::Validation { message, .. } => assert!(message.contains("id")),
462 other => panic!("expected Validation, got {other:?}"),
463 }
464 }
465
466 #[tokio::test]
467 async fn get_budget_account_quarters_empty_id_returns_validation() {
468 let client = Client::builder().api_key("x").build().expect("build");
469 let err = client
470 .get_budget_account_quarters("", None)
471 .await
472 .expect_err("must error");
473 match err {
474 Error::Validation { message, .. } => assert!(message.contains("id")),
475 other => panic!("expected Validation, got {other:?}"),
476 }
477 }
478
479 #[tokio::test]
480 async fn get_budget_account_recipients_empty_id_returns_validation() {
481 let client = Client::builder().api_key("x").build().expect("build");
482 let err = client
483 .get_budget_account_recipients("", None)
484 .await
485 .expect_err("must error");
486 match err {
487 Error::Validation { message, .. } => assert!(message.contains("id")),
488 other => panic!("expected Validation, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn list_budget_accounts_agency_filter_emits() {
494 let q = ListBudgetAccountsOptions::builder()
495 .agency("EPA")
496 .build()
497 .to_query();
498 assert_eq!(get_q(&q, "agency").as_deref(), Some("EPA"));
499 }
500}