Skip to main content

tango/resources/
budget.rs

1//! `/api/budget/accounts/` — federal-account x fiscal-year budget rollups.
2//!
3//! One row per `(federal_account_symbol, fiscal_year)` covering the full
4//! budget lifecycle (requested → enacted → apportioned → obligated →
5//! outlayed), pre-computed ratios + trends, the contract/assistance/unlinked
6//! breakdown, and request-vs-actual contract spend. The schema is wide
7//! (~63 fields) and shape-driven, so every method returns the untyped
8//! [`Record`] map like the other resource families.
9//! The `source_anomalies` list in each row decodes with
10//! [`BudgetSourceAnomaly::from_record`](crate::models::BudgetSourceAnomaly::from_record),
11//! and [`budget_data_through_period`](crate::models::budget_data_through_period) reads `data_through_period`.
12
13use 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/// Options for [`Client::list_budget_accounts`] and [`Client::iterate_budget_accounts`].
24///
25/// The API rejects an unknown filter name with a 400 rather than ignoring it.
26/// The exact, `__gte` and `__lte` range filters on the numeric lifecycle and ratio fields (e.g. `enacted_ba__gte`), and the `__in` multi-value variants other than [`account_category_in`](Self::account_category_in), are reachable via the [`extra`](Self::extra) map.
27/// There is no filter on `source_anomalies`; it is in the default shape, so every row already carries it.
28#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ListBudgetAccountsOptions {
31    // ----- Pagination + shape -----
32    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
33    #[builder(into)]
34    pub page: Option<u32>,
35    /// Page size (server caps at 100).
36    #[builder(into)]
37    pub limit: Option<u32>,
38    /// Keyset cursor.
39    #[builder(into)]
40    pub cursor: Option<String>,
41    /// Comma-separated field selector. Use
42    /// [`SHAPE_BUDGET_ACCOUNTS_MINIMAL`](crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
43    /// or roll your own.
44    #[builder(into)]
45    pub shape: Option<String>,
46    /// Collapse nested objects into dot-separated keys.
47    #[builder(default)]
48    pub flat: bool,
49    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
50    #[builder(default)]
51    pub flat_lists: bool,
52
53    // ----- Resource filters -----
54    /// Federal account symbol filter (exact, e.g. `"097-0100"`).
55    #[builder(into)]
56    pub federal_account_symbol: Option<String>,
57    /// `fiscal_year` filter (exact).
58    #[builder(into)]
59    pub fiscal_year: Option<String>,
60    /// Lower bound for `fiscal_year` (inclusive).
61    #[builder(into)]
62    pub fiscal_year_gte: Option<String>,
63    /// Upper bound for `fiscal_year` (inclusive).
64    #[builder(into)]
65    pub fiscal_year_lte: Option<String>,
66    /// `data_through_period` filter (exact): the File A fiscal period (1-12) the account-year's figures run through.
67    #[builder(into)]
68    pub data_through_period: Option<String>,
69    /// Lower bound for `data_through_period` (inclusive).
70    #[builder(into)]
71    pub data_through_period_gte: Option<String>,
72    /// Upper bound for `data_through_period` (inclusive).
73    #[builder(into)]
74    pub data_through_period_lte: Option<String>,
75    /// `Some(true)` keeps only rows with no File A data (null `data_through_period`), `Some(false)` only rows with it (sent as `data_through_period__isnull`).
76    pub data_through_period_isnull: Option<bool>,
77    /// A Tango agency name, abbreviation, code or organization key (e.g. `EPA`), matched against the account's resolved organization. Matches by name, abbreviation, or code (CGAC, FPDS, Federal Hierarchy key or AAC) and includes every sub-agency and office beneath it; OR several with `|`. A token that does not resolve falls back to an exact match on `agency_code`.
78    #[builder(into)]
79    pub agency: Option<String>,
80    /// Agency code filter (exact).
81    #[builder(into)]
82    pub agency_code: Option<String>,
83    /// Bureau name filter (exact).
84    #[builder(into)]
85    pub bureau_name: Option<String>,
86    /// Case-insensitive substring match on the account title (sent as `account_title__icontains`).
87    #[builder(into)]
88    pub account_title: Option<String>,
89    /// Budget subfunction code filter (exact).
90    #[builder(into)]
91    pub subfunction_code: Option<String>,
92    /// Bureau of Economic Analysis category filter (exact).
93    #[builder(into)]
94    pub bea_category: Option<String>,
95    /// On/off-budget flag filter (exact).
96    #[builder(into)]
97    pub on_off_budget: Option<String>,
98    /// Account category filter (exact): `budgetary` or `credit_financing` today.
99    /// Credit financing accounts have no enacted budget authority (`enacted_ba` is null) and are left out of organization budget totals.
100    #[builder(into)]
101    pub account_category: Option<String>,
102    /// Several account categories, comma-separated (sent as `account_category__in`).
103    #[builder(into)]
104    pub account_category_in: Option<String>,
105    /// Free-text search filter.
106    #[builder(into)]
107    pub search: Option<String>,
108    /// Server-side sort spec (prefix `-` for descending).
109    /// The default is latest fiscal year first, then largest `enacted_ba`, with null `enacted_ba` last and `id` breaking ties.
110    #[builder(into)]
111    pub ordering: Option<String>,
112
113    /// Escape hatch for filter keys not yet first-classed on this struct
114    /// (e.g. the `*_gte` / `*_lte` range filters on the numeric metrics).
115    #[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/// Options for [`Client::get_budget_account_quarters`].
188#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct BudgetAccountQuartersOptions {
191    /// 1-based page number.
192    #[builder(into)]
193    pub page: Option<u32>,
194    /// Page size (default 25, server caps at 100).
195    #[builder(into)]
196    pub limit: Option<u32>,
197    /// Narrow to a single Treasury Account Symbol. Omit to get every TAS that rolls up under the federal account.
198    #[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/// Options for [`Client::get_budget_account_recipients`].
212#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
213#[non_exhaustive]
214pub struct BudgetAccountRecipientsOptions {
215    /// 1-based page number.
216    #[builder(into)]
217    pub page: Option<u32>,
218    /// Page size (default 25, server caps at 100).
219    #[builder(into)]
220    pub limit: Option<u32>,
221    /// Narrow to a single funding office (an organization UUID).
222    #[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    /// `GET /api/budget/accounts/` — one page of budget-account rollups.
241    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    /// Stream every budget-account rollup matching `opts`.
250    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    /// `GET /api/budget/accounts/{id}/` — a single budget-account rollup by its numeric `id` (from a list row's `id` field).
262    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    /// `GET /api/budget/accounts/{id}/quarters/` — one row per (TAS, quarter) of obligation and outlay flow for a single account-year.
276    ///
277    /// Coverage starts at FY2021; earlier years return an empty page.
278    /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface.
279    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    /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient contract flows for a single account-year, largest `contract_obligated` first.
296    ///
297    /// Contract flows only. Each row carries the resolved `funding_office` and `recipient`, plus a capped `contracts` list; a row that hits the cap sets `contracts_truncated`.
298    /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface.
299    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}