use crate::client::Client;
use crate::error::{Error, Result};
use crate::internal::{apply_pagination, push_opt, push_opt_bool, ListOptions};
use crate::pagination::{FetchFn, Page, PageStream};
use crate::resources::agencies::urlencoding;
use crate::Record;
use bon::Builder;
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListBudgetAccountsOptions {
#[builder(into)]
pub page: Option<u32>,
#[builder(into)]
pub limit: Option<u32>,
#[builder(into)]
pub cursor: Option<String>,
#[builder(into)]
pub shape: Option<String>,
#[builder(default)]
pub flat: bool,
#[builder(default)]
pub flat_lists: bool,
#[builder(into)]
pub federal_account_symbol: Option<String>,
#[builder(into)]
pub fiscal_year: Option<String>,
#[builder(into)]
pub fiscal_year_gte: Option<String>,
#[builder(into)]
pub fiscal_year_lte: Option<String>,
#[builder(into)]
pub data_through_period: Option<String>,
#[builder(into)]
pub data_through_period_gte: Option<String>,
#[builder(into)]
pub data_through_period_lte: Option<String>,
pub data_through_period_isnull: Option<bool>,
#[builder(into)]
pub agency: Option<String>,
#[builder(into)]
pub agency_code: Option<String>,
#[builder(into)]
pub bureau_name: Option<String>,
#[builder(into)]
pub account_title: Option<String>,
#[builder(into)]
pub subfunction_code: Option<String>,
#[builder(into)]
pub bea_category: Option<String>,
#[builder(into)]
pub on_off_budget: Option<String>,
#[builder(into)]
pub account_category: Option<String>,
#[builder(into)]
pub account_category_in: Option<String>,
#[builder(into)]
pub search: Option<String>,
#[builder(into)]
pub ordering: Option<String>,
#[builder(default)]
pub extra: BTreeMap<String, String>,
}
impl ListBudgetAccountsOptions {
fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
apply_pagination(
&mut q,
self.page,
self.limit,
self.cursor.as_deref(),
self.shape.as_deref(),
self.flat,
self.flat_lists,
);
push_opt(
&mut q,
"federal_account_symbol",
self.federal_account_symbol.as_deref(),
);
push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
push_opt(&mut q, "fiscal_year__gte", self.fiscal_year_gte.as_deref());
push_opt(&mut q, "fiscal_year__lte", self.fiscal_year_lte.as_deref());
push_opt(
&mut q,
"data_through_period",
self.data_through_period.as_deref(),
);
push_opt(
&mut q,
"data_through_period__gte",
self.data_through_period_gte.as_deref(),
);
push_opt(
&mut q,
"data_through_period__lte",
self.data_through_period_lte.as_deref(),
);
push_opt_bool(
&mut q,
"data_through_period__isnull",
self.data_through_period_isnull,
);
push_opt(&mut q, "agency", self.agency.as_deref());
push_opt(&mut q, "agency_code", self.agency_code.as_deref());
push_opt(&mut q, "bureau_name", self.bureau_name.as_deref());
push_opt(
&mut q,
"account_title__icontains",
self.account_title.as_deref(),
);
push_opt(&mut q, "subfunction_code", self.subfunction_code.as_deref());
push_opt(&mut q, "bea_category", self.bea_category.as_deref());
push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref());
push_opt(&mut q, "account_category", self.account_category.as_deref());
push_opt(
&mut q,
"account_category__in",
self.account_category_in.as_deref(),
);
push_opt(&mut q, "search", self.search.as_deref());
push_opt(&mut q, "ordering", self.ordering.as_deref());
for (k, v) in &self.extra {
if !v.is_empty() {
q.push((k.clone(), v.clone()));
}
}
q
}
}
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct BudgetAccountQuartersOptions {
#[builder(into)]
pub page: Option<u32>,
#[builder(into)]
pub limit: Option<u32>,
#[builder(into)]
pub tas: Option<String>,
}
impl BudgetAccountQuartersOptions {
fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
push_opt(&mut q, "tas", self.tas.as_deref());
q
}
}
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct BudgetAccountRecipientsOptions {
#[builder(into)]
pub page: Option<u32>,
#[builder(into)]
pub limit: Option<u32>,
#[builder(into)]
pub funding_organization_id: Option<String>,
}
impl BudgetAccountRecipientsOptions {
fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
push_opt(
&mut q,
"funding_organization_id",
self.funding_organization_id.as_deref(),
);
q
}
}
impl Client {
pub async fn list_budget_accounts(
&self,
opts: ListBudgetAccountsOptions,
) -> Result<Page<Record>> {
let q = opts.to_query();
self.get_page("/api/budget/accounts/", &q).await
}
pub fn iterate_budget_accounts(&self, opts: ListBudgetAccountsOptions) -> PageStream<Record> {
let opts = Arc::new(opts);
let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
let mut next = (*opts).clone();
next.page = page;
next.cursor = cursor;
Box::pin(async move { client.list_budget_accounts(next).await })
});
PageStream::new(self.clone(), fetch)
}
pub async fn get_budget_account(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account: id is required".into(),
response: None,
});
}
let mut q = Vec::new();
opts.unwrap_or_default().apply(&mut q);
let path = format!("/api/budget/accounts/{}/", urlencoding(id));
self.get_json::<Record>(&path, &q).await
}
pub async fn get_budget_account_quarters(
&self,
id: &str,
opts: Option<BudgetAccountQuartersOptions>,
) -> Result<Page<Record>> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account_quarters: id is required".into(),
response: None,
});
}
let q = opts.unwrap_or_default().to_query();
let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id));
self.get_page(&path, &q).await
}
pub async fn get_budget_account_recipients(
&self,
id: &str,
opts: Option<BudgetAccountRecipientsOptions>,
) -> Result<Page<Record>> {
if id.is_empty() {
return Err(Error::Validation {
message: "get_budget_account_recipients: id is required".into(),
response: None,
});
}
let q = opts.unwrap_or_default().to_query();
let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id));
self.get_page(&path, &q).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
}
#[test]
fn options_emit_all_filters() {
let opts = ListBudgetAccountsOptions::builder()
.federal_account_symbol("097-0100")
.fiscal_year("2024")
.fiscal_year_gte("2020")
.fiscal_year_lte("2025")
.data_through_period("9")
.data_through_period_gte("3")
.data_through_period_lte("11")
.data_through_period_isnull(false)
.agency_code("9700")
.bureau_name("Operation and Maintenance")
.account_title("readiness")
.subfunction_code("051")
.bea_category("discretionary")
.on_off_budget("on")
.account_category("credit_financing")
.account_category_in("budgetary,credit_financing")
.search("operations")
.ordering("-enacted_ba")
.build();
let q = opts.to_query();
assert_eq!(
get_q(&q, "federal_account_symbol").as_deref(),
Some("097-0100")
);
assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020"));
assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025"));
assert_eq!(get_q(&q, "data_through_period").as_deref(), Some("9"));
assert_eq!(get_q(&q, "data_through_period__gte").as_deref(), Some("3"));
assert_eq!(get_q(&q, "data_through_period__lte").as_deref(), Some("11"));
assert_eq!(
get_q(&q, "data_through_period__isnull").as_deref(),
Some("false")
);
assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700"));
assert_eq!(
get_q(&q, "bureau_name").as_deref(),
Some("Operation and Maintenance")
);
assert_eq!(
get_q(&q, "account_title__icontains").as_deref(),
Some("readiness")
);
assert_eq!(get_q(&q, "subfunction_code").as_deref(), Some("051"));
assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary"));
assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on"));
assert_eq!(
get_q(&q, "account_category").as_deref(),
Some("credit_financing")
);
assert_eq!(
get_q(&q, "account_category__in").as_deref(),
Some("budgetary,credit_financing")
);
assert_eq!(get_q(&q, "search").as_deref(), Some("operations"));
assert_eq!(get_q(&q, "ordering").as_deref(), Some("-enacted_ba"));
}
#[test]
fn pagination_and_shape_emit() {
let opts = ListBudgetAccountsOptions::builder()
.page(2u32)
.limit(50u32)
.shape(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
assert_eq!(
get_q(&q, "shape").as_deref(),
Some(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL)
);
}
#[test]
fn minimal_shape_carries_category_and_anomalies() {
let fields: Vec<&str> = crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL.split(',').collect();
assert!(fields.contains(&"account_category"));
assert!(fields.contains(&"source_anomalies"));
}
#[test]
fn minimal_shape_puts_data_through_period_after_fiscal_year() {
let fields: Vec<&str> = crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL.split(',').collect();
let fy = fields.iter().position(|f| *f == "fiscal_year").unwrap();
assert_eq!(fields[fy + 1], "data_through_period");
}
#[test]
fn extra_forwards_range_filters() {
let mut extra = BTreeMap::new();
extra.insert("enacted_ba__gte".to_string(), "1000000".to_string());
let opts = ListBudgetAccountsOptions::builder().extra(extra).build();
let q = opts.to_query();
assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000"));
}
#[test]
fn quarters_options_emit_tas_and_pagination() {
let q = BudgetAccountQuartersOptions::builder()
.page(2u32)
.limit(50u32)
.tas("097-2020/2021-0100")
.build()
.to_query();
assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
assert_eq!(get_q(&q, "tas").as_deref(), Some("097-2020/2021-0100"));
assert_eq!(q.len(), 3);
}
#[test]
fn recipients_options_emit_funding_organization_id() {
let q = BudgetAccountRecipientsOptions::builder()
.funding_organization_id("0b7e4c1e-0000-4000-8000-000000000000")
.build()
.to_query();
assert_eq!(
q,
vec![(
"funding_organization_id".to_string(),
"0b7e4c1e-0000-4000-8000-000000000000".to_string()
)]
);
}
#[tokio::test]
async fn get_budget_account_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn get_budget_account_quarters_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account_quarters("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn get_budget_account_recipients_empty_id_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_budget_account_recipients("", None)
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("id")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn list_budget_accounts_agency_filter_emits() {
let q = ListBudgetAccountsOptions::builder()
.agency("EPA")
.build()
.to_query();
assert_eq!(get_q(&q, "agency").as_deref(), Some("EPA"));
}
}