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
10use 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/// Options for [`Client::list_budget_accounts`] and [`Client::iterate_budget_accounts`].
21///
22/// The API rejects an unknown filter name with a 400 rather than ignoring it.
23/// 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, are reachable via the [`extra`](Self::extra) map.
24#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct ListBudgetAccountsOptions {
27    // ----- Pagination + shape -----
28    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
29    #[builder(into)]
30    pub page: Option<u32>,
31    /// Page size (server caps at 100).
32    #[builder(into)]
33    pub limit: Option<u32>,
34    /// Keyset cursor.
35    #[builder(into)]
36    pub cursor: Option<String>,
37    /// Comma-separated field selector. Use
38    /// [`SHAPE_BUDGET_ACCOUNTS_MINIMAL`](crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
39    /// or roll your own.
40    #[builder(into)]
41    pub shape: Option<String>,
42    /// Collapse nested objects into dot-separated keys.
43    #[builder(default)]
44    pub flat: bool,
45    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
46    #[builder(default)]
47    pub flat_lists: bool,
48
49    // ----- Resource filters -----
50    /// Federal account symbol filter (exact, e.g. `"097-0100"`).
51    #[builder(into)]
52    pub federal_account_symbol: Option<String>,
53    /// `fiscal_year` filter (exact).
54    #[builder(into)]
55    pub fiscal_year: Option<String>,
56    /// Lower bound for `fiscal_year` (inclusive).
57    #[builder(into)]
58    pub fiscal_year_gte: Option<String>,
59    /// Upper bound for `fiscal_year` (inclusive).
60    #[builder(into)]
61    pub fiscal_year_lte: Option<String>,
62    /// Agency code filter (exact).
63    #[builder(into)]
64    pub agency_code: Option<String>,
65    /// Bureau name filter (exact).
66    #[builder(into)]
67    pub bureau_name: Option<String>,
68    /// Case-insensitive substring match on the account title (sent as `account_title__icontains`).
69    #[builder(into)]
70    pub account_title: Option<String>,
71    /// Budget subfunction code filter (exact).
72    #[builder(into)]
73    pub subfunction_code: Option<String>,
74    /// Bureau of Economic Analysis category filter (exact).
75    #[builder(into)]
76    pub bea_category: Option<String>,
77    /// On/off-budget flag filter (exact).
78    #[builder(into)]
79    pub on_off_budget: Option<String>,
80    /// Free-text search filter.
81    #[builder(into)]
82    pub search: Option<String>,
83    /// Server-side sort spec (prefix `-` for descending).
84    #[builder(into)]
85    pub ordering: Option<String>,
86
87    /// Escape hatch for filter keys not yet first-classed on this struct
88    /// (e.g. the `*_gte` / `*_lte` range filters on the numeric metrics).
89    #[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/// Options for [`Client::get_budget_account_quarters`].
135#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
136#[non_exhaustive]
137pub struct BudgetAccountQuartersOptions {
138    /// 1-based page number.
139    #[builder(into)]
140    pub page: Option<u32>,
141    /// Page size (default 25, server caps at 100).
142    #[builder(into)]
143    pub limit: Option<u32>,
144    /// Narrow to a single Treasury Account Symbol. Omit to get every TAS that rolls up under the federal account.
145    #[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/// Options for [`Client::get_budget_account_recipients`].
159#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
160#[non_exhaustive]
161pub struct BudgetAccountRecipientsOptions {
162    /// 1-based page number.
163    #[builder(into)]
164    pub page: Option<u32>,
165    /// Page size (default 25, server caps at 100).
166    #[builder(into)]
167    pub limit: Option<u32>,
168    /// Narrow to a single funding office (an organization UUID).
169    #[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    /// `GET /api/budget/accounts/` — one page of budget-account rollups.
188    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    /// Stream every budget-account rollup matching `opts`.
198    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    /// `GET /api/budget/accounts/{id}/` — a single budget-account rollup by its numeric `id` (from a list row's `id` field).
210    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    /// `GET /api/budget/accounts/{id}/quarters/` — one row per (TAS, quarter) of obligation and outlay flow for a single account-year.
224    ///
225    /// Coverage starts at FY2021; earlier years return an empty page.
226    /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface.
227    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    /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient contract flows for a single account-year, largest `contract_obligated` first.
245    ///
246    /// 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`.
247    /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface.
248    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}