diff --git a/CHANGELOG.md b/CHANGELOG.md index 8436a71..e74b452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### `makegov-tango` + +#### Added + +- **Budget-account `source_anomalies` and `account_category`** (Tango API 5.7.0). `models::BudgetSourceAnomaly` (with `BudgetSourceAnomalySource` and `BudgetSourceAnomalyRow`) types each element of a row's `source_anomalies` list, and `BudgetSourceAnomaly::from_record` decodes it from a `list_budget_accounts` / `get_budget_account` record, returning an empty list when the field is `[]`, absent or null. Every field is optional and `code` is an open string (`contract_exceeds_obligations`, `assistance_exceeds_obligations`, `contract_without_obligations` today). There is no filter on anomalies. +- **Budget-account `data_through_period`** (Tango API 5.8.0): the File A fiscal period (1–12) an account-year's figures run through, so the latest fiscal year is partial until it reaches 12; null when the row has no File A data. `models::budget_data_through_period` reads it from a record as `Option`, and `ListBudgetAccountsOptions` gains `data_through_period`, `data_through_period_gte`, `data_through_period_lte` (sent as `__gte` / `__lte`) and `data_through_period_isnull` (sent as `data_through_period__isnull`). +- **`account_category` and `account_category_in` on `ListBudgetAccountsOptions`** (sent as `account_category` and `account_category__in`). Values are `budgetary` or `credit_financing` today; credit financing accounts have null `enacted_ba` and are left out of organization budget totals. + +#### Changed + +- **`SHAPE_BUDGET_ACCOUNTS_MINIMAL` matches the API's default shape again.** It adds `data_through_period` (right after `fiscal_year`), `account_category` and `source_anomalies`, plus `attribution_status`, `attribution_confidence` and `contract_obligated_estimated`, which the API's default already carried. +- **The API's default budget-account ordering now puts null `enacted_ba` last** within each fiscal year, with `id` breaking ties so pages are stable. No SDK change is needed; the `ordering` field documents it. + ## [0.2.0] — 2026-09-23 Pre-1.0 (SemVer 0.x): the removals under **Breaking** ship without a deprecation cycle. diff --git a/crates/tango/src/models/budget.rs b/crates/tango/src/models/budget.rs new file mode 100644 index 0000000..5a81123 --- /dev/null +++ b/crates/tango/src/models/budget.rs @@ -0,0 +1,226 @@ +//! `BudgetSourceAnomaly` — typed view of a budget account's `source_anomalies` list, plus a reader for its `data_through_period`. + +use crate::Record; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// One problem found in the source data behind a budget-account row. +/// +/// [`Client::list_budget_accounts`](crate::Client::list_budget_accounts) and [`Client::get_budget_account`](crate::Client::get_budget_account) return untyped [`Record`]s; the `source_anomalies` field in them is a JSON list, `[]` when the row is clean. +/// Decode it with [`BudgetSourceAnomaly::from_record`]. +/// +/// `code` is an open set, so match on the strings you know and treat anything else as a new kind of anomaly. +/// Known codes: `contract_exceeds_obligations` (contract obligations reported above the account's obligations, so `contract_obligated` is capped), `assistance_exceeds_obligations` and `contract_without_obligations` (both flagged, the served values unchanged). +/// +/// Every field is optional, and unknown server-side fields fall through to [`extra`](Self::extra). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BudgetSourceAnomaly { + /// What kind of anomaly this is, e.g. `"contract_exceeds_obligations"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + + /// The row field the anomaly is about, e.g. `"contract_obligated"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + + /// The field whose value [`field`](Self::field) was checked against, e.g. `"obligated_total"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_field: Option, + + /// What was done about it: `"capped"` (the served value was reduced to the bound) or `"flagged"` (served unchanged). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + + /// The value the source data reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reported_value: Option, + + /// The value the API serves for [`field`](Self::field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub served_value: Option, + + /// Short description of the most likely cause. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub likely_cause: Option, + + /// Every served field the anomaly affects, including values derived from [`field`](Self::field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub affected_fields: Option>, + + /// Human-readable explanation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// The source rows behind the anomaly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + + /// Forward-compat catch-all for server-side fields the SDK does not yet model. + #[serde(flatten, default)] + pub extra: HashMap, +} + +/// Read the `data_through_period` field of a budget-account [`Record`]: the File A fiscal period (1-12) the account-year's figures run through. +/// +/// Below 12 the fiscal year is partial. +/// Returns `None` when the row has no File A data (null), the field is not in the requested shape, or the value is not a whole number from 0 to 255. +pub fn budget_data_through_period(record: &Record) -> Option { + record + .get("data_through_period") + .and_then(Value::as_u64) + .and_then(|n| u8::try_from(n).ok()) +} + +impl BudgetSourceAnomaly { + /// Decode the `source_anomalies` field of a budget-account [`Record`]. + /// + /// Returns an empty list when the field is absent (not in the requested shape) or null, and an error when it is present but not a list of anomaly objects. + pub fn from_record(record: &Record) -> serde_json::Result> { + match record.get("source_anomalies") { + None | Some(Value::Null) => Ok(Vec::new()), + Some(v) => serde_json::from_value(v.clone()), + } + } +} + +/// Where a [`BudgetSourceAnomaly`] came from. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BudgetSourceAnomalySource { + /// The source dataset the rows were read from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dataset: Option, + + /// Fiscal year of the source rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fiscal_year: Option, + + /// The source rows that account for the anomaly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rows: Option>, + + /// Forward-compat catch-all for server-side fields the SDK does not yet model. + #[serde(flatten, default)] + pub extra: HashMap, +} + +/// One source row behind a [`BudgetSourceAnomaly`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct BudgetSourceAnomalyRow { + /// Fiscal period (month of the fiscal year) the row was reported in. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fiscal_period: Option, + + /// Award PIID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub piid: Option, + + /// Parent award PIID, for an order under an IDV. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_piid: Option, + + /// Treasury Account Symbol the row was reported against. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tas: Option, + + /// Reporting agency identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reporting_agency_id: Option, + + /// Obligated amount the row reports. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transaction_obligated_amount: Option, + + /// Which source file the row came from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_c_source: Option, + + /// Forward-compat catch-all for server-side fields the SDK does not yet model. + #[serde(flatten, default)] + pub extra: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn record(v: Value) -> Record { + v.as_object().cloned().expect("object") + } + + #[test] + fn decodes_a_capped_contract_anomaly_with_source_rows() { + let r = record(json!({ + "federal_account_symbol": "020-4159", + "source_anomalies": [{ + "code": "contract_exceeds_obligations", + "field": "contract_obligated", + "bound_field": "obligated_total", + "action": "capped", + "reported_value": 73_470_455_401.8, + "served_value": 4_748_670_061.77, + "likely_cause": "unit error", + "affected_fields": ["contract_obligated", "contract_share_of_obligated_capped"], + "message": "reported contracts exceed obligations", + "new_key": 1, + "source": { + "dataset": "file_c", + "fiscal_year": 2025, + "rows": [{ + "fiscal_period": 6, + "piid": "P1", + "parent_piid": null, + "tas": "020-2025/2025-4159", + "reporting_agency_id": "020", + "transaction_obligated_amount": 68_721_785_340.03, + "file_c_source": "award_financial" + }] + } + }] + })); + let got = BudgetSourceAnomaly::from_record(&r).expect("decode"); + assert_eq!(got.len(), 1); + let a = &got[0]; + assert_eq!(a.code.as_deref(), Some("contract_exceeds_obligations")); + assert_eq!(a.action.as_deref(), Some("capped")); + assert_eq!(a.reported_value, Some(73_470_455_401.8)); + assert_eq!(a.served_value, Some(4_748_670_061.77)); + assert_eq!(a.affected_fields.as_ref().map(Vec::len), Some(2)); + assert_eq!(a.extra.get("new_key"), Some(&json!(1))); + let src = a.source.as_ref().expect("source"); + assert_eq!(src.fiscal_year, Some(2025)); + let row = &src.rows.as_ref().expect("rows")[0]; + assert_eq!(row.fiscal_period, Some(6)); + assert_eq!(row.parent_piid, None); + assert_eq!(row.reporting_agency_id.as_deref(), Some("020")); + assert_eq!(row.transaction_obligated_amount, Some(68_721_785_340.03)); + } + + #[test] + fn sparse_anomaly_and_unknown_code_decode() { + let r = record(json!({"source_anomalies": [{"code": "something_new"}]})); + let got = BudgetSourceAnomaly::from_record(&r).expect("decode"); + assert_eq!(got[0].code.as_deref(), Some("something_new")); + assert!(got[0].source.is_none()); + } + + #[test] + fn clean_absent_and_null_are_empty() { + for v in [ + json!({"source_anomalies": []}), + json!({}), + json!({"source_anomalies": null}), + ] { + assert!(BudgetSourceAnomaly::from_record(&record(v)) + .expect("decode") + .is_empty()); + } + } + + #[test] + fn non_list_is_an_error() { + let r = record(json!({"source_anomalies": "oops"})); + assert!(BudgetSourceAnomaly::from_record(&r).is_err()); + } +} diff --git a/crates/tango/src/models/mod.rs b/crates/tango/src/models/mod.rs index aab3203..695fdf8 100644 --- a/crates/tango/src/models/mod.rs +++ b/crates/tango/src/models/mod.rs @@ -13,6 +13,7 @@ //! through `serde_json::to_value` will re-emit those extras. pub mod agency; +pub mod budget; pub mod contract_appeal; pub mod protest; pub mod resolve; @@ -20,6 +21,10 @@ pub mod validate; pub mod webhook; pub use agency::AgencyRecord; +pub use budget::{ + budget_data_through_period, BudgetSourceAnomaly, BudgetSourceAnomalyRow, + BudgetSourceAnomalySource, +}; pub use contract_appeal::ContractAppealRecord; pub use protest::ProtestRecord; pub use resolve::{ResolveCandidate, ResolveInput, ResolveResult, ResolveTargetType}; diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs index d9ee101..0fd103b 100644 --- a/crates/tango/src/resources/budget.rs +++ b/crates/tango/src/resources/budget.rs @@ -6,10 +6,13 @@ //! breakdown, and request-vs-actual contract spend. The schema is wide //! (~63 fields) and shape-driven, so every method returns the untyped //! [`Record`] map like the other resource families. +//! The `source_anomalies` list in each row decodes with +//! [`BudgetSourceAnomaly::from_record`](crate::models::BudgetSourceAnomaly::from_record), +//! and [`budget_data_through_period`](crate::models::budget_data_through_period) reads `data_through_period`. use crate::client::Client; use crate::error::{Error, Result}; -use crate::internal::{apply_pagination, push_opt, ListOptions}; +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; @@ -20,7 +23,8 @@ use std::sync::Arc; /// Options for [`Client::list_budget_accounts`] and [`Client::iterate_budget_accounts`]. /// /// The API rejects an unknown filter name with a 400 rather than ignoring it. -/// 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. +/// 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. +/// There is no filter on `source_anomalies`; it is in the default shape, so every row already carries it. #[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] #[non_exhaustive] pub struct ListBudgetAccountsOptions { @@ -59,6 +63,17 @@ pub struct ListBudgetAccountsOptions { /// Upper bound for `fiscal_year` (inclusive). #[builder(into)] pub fiscal_year_lte: Option, + /// `data_through_period` filter (exact): the File A fiscal period (1-12) the account-year's figures run through. + #[builder(into)] + pub data_through_period: Option, + /// Lower bound for `data_through_period` (inclusive). + #[builder(into)] + pub data_through_period_gte: Option, + /// Upper bound for `data_through_period` (inclusive). + #[builder(into)] + pub data_through_period_lte: Option, + /// `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`). + pub data_through_period_isnull: Option, /// Agency code filter (exact). #[builder(into)] pub agency_code: Option, @@ -77,10 +92,18 @@ pub struct ListBudgetAccountsOptions { /// On/off-budget flag filter (exact). #[builder(into)] pub on_off_budget: Option, + /// Account category filter (exact): `budgetary` or `credit_financing` today. + /// Credit financing accounts have no enacted budget authority (`enacted_ba` is null) and are left out of organization budget totals. + #[builder(into)] + pub account_category: Option, + /// Several account categories, comma-separated (sent as `account_category__in`). + #[builder(into)] + pub account_category_in: Option, /// Free-text search filter. #[builder(into)] pub search: Option, /// Server-side sort spec (prefix `-` for descending). + /// The default is latest fiscal year first, then largest `enacted_ba`, with null `enacted_ba` last and `id` breaking ties. #[builder(into)] pub ordering: Option, @@ -110,6 +133,26 @@ impl ListBudgetAccountsOptions { 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_code", self.agency_code.as_deref()); push_opt(&mut q, "bureau_name", self.bureau_name.as_deref()); push_opt( @@ -120,6 +163,12 @@ impl ListBudgetAccountsOptions { 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 { @@ -278,12 +327,18 @@ mod tests { .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(); @@ -295,6 +350,13 @@ mod tests { 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(), @@ -307,6 +369,14 @@ mod tests { 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")); } @@ -327,6 +397,20 @@ mod tests { ); } + #[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(); diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index 8444fc6..196efab 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -16,15 +16,18 @@ pub const SHAPE_CONTRACTS_MINIMAL: &str = /// Default shape for [`Client::list_budget_accounts`](crate::Client::list_budget_accounts) and [`Client::get_budget_account`](crate::Client::get_budget_account). /// -/// Mirrors the API's own default budget-account shape. +/// Mirrors the API's own default budget-account shape, including `data_through_period`, `account_category` and `source_anomalies` (decode the latter with [`BudgetSourceAnomaly::from_record`](crate::models::BudgetSourceAnomaly::from_record)). pub const SHAPE_BUDGET_ACCOUNTS_MINIMAL: &str = concat!( - "id,federal_account_symbol,fiscal_year,agency_code,agency_name,bureau_name,", - "account_title,bea_category,on_off_budget,subfunction_code,", + "id,federal_account_symbol,fiscal_year,data_through_period,agency_code,agency_name,", + "bureau_name,", + "account_title,bea_category,on_off_budget,subfunction_code,account_category,", "requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total,", - "unobligated_balance,contract_obligated,contract_share_of_obligated_capped,", + "unobligated_balance,contract_obligated,attribution_status,", + "attribution_confidence,contract_obligated_estimated,", + "contract_share_of_obligated_capped,", "assistance_obligated,obligated_to_apportioned_pct_capped,", "obligated_to_enacted_pct_capped,outlayed_to_obligated_pct_capped,", - "ba_growth_next_year_pct", + "ba_growth_next_year_pct,source_anomalies", ); /// Default shape for [`Client::list_entities`](crate::Client::list_entities). diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs index 6ca1033..0465f2b 100644 --- a/crates/tango/tests/routes.rs +++ b/crates/tango/tests/routes.rs @@ -5,8 +5,8 @@ use serde_json::json; use std::time::Duration; use tango::{ BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, Client, EntityBudgetFlowsOptions, - GetDibbsOptions, GetExclusionOptions, GetSbirOptions, ListDibbsAwardsOptions, - ListDibbsRfpsOptions, ListDibbsRfqsOptions, ListExclusionsOptions, + GetDibbsOptions, GetExclusionOptions, GetSbirOptions, ListBudgetAccountsOptions, + ListDibbsAwardsOptions, ListDibbsRfpsOptions, ListDibbsRfqsOptions, ListExclusionsOptions, ListSbirSolicitationsOptions, ListSbirTopicsOptions, }; @@ -235,6 +235,77 @@ async fn sbir_routes() { sol.assert_async().await; } +#[tokio::test] +async fn list_budget_accounts_filters_by_category_and_decodes_anomalies() { + let server = MockServer::start_async().await; + let m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/budget/accounts/") + .query_param("account_category__in", "budgetary,credit_financing"); + then.status(200).json_body(page(json!([{ + "federal_account_symbol": "020-4159", + "account_category": "budgetary", + "source_anomalies": [{ + "code": "contract_exceeds_obligations", + "action": "capped", + "reported_value": 73_470_455_401.8, + "served_value": 4_748_670_061.77 + }] + }]))); + }) + .await; + let p = make_client(&server) + .list_budget_accounts( + ListBudgetAccountsOptions::builder() + .account_category_in("budgetary,credit_financing") + .build(), + ) + .await + .expect("list"); + m.assert_async().await; + let row = &p.results[0]; + assert_eq!(row["account_category"], json!("budgetary")); + let anomalies = tango::models::BudgetSourceAnomaly::from_record(row).expect("decode"); + assert_eq!(anomalies[0].action.as_deref(), Some("capped")); + assert_eq!(anomalies[0].served_value, Some(4_748_670_061.77)); +} + +#[tokio::test] +async fn list_budget_accounts_filters_and_reads_data_through_period() { + let server = MockServer::start_async().await; + let m = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/budget/accounts/") + .query_param("data_through_period__gte", "3") + .query_param("data_through_period__isnull", "false"); + then.status(200).json_body(page(json!([ + {"federal_account_symbol": "097-0100", "fiscal_year": 2026, "data_through_period": 9}, + {"federal_account_symbol": "097-4000", "fiscal_year": 2026, "data_through_period": null} + ]))); + }) + .await; + let p = make_client(&server) + .list_budget_accounts( + ListBudgetAccountsOptions::builder() + .data_through_period_gte("3") + .data_through_period_isnull(false) + .build(), + ) + .await + .expect("list"); + m.assert_async().await; + assert_eq!( + tango::models::budget_data_through_period(&p.results[0]), + Some(9) + ); + assert_eq!( + tango::models::budget_data_through_period(&p.results[1]), + None + ); +} + #[tokio::test] async fn budget_sub_routes_send_their_own_filters() { let server = MockServer::start_async().await; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c3c3d89..194340e 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -129,7 +129,10 @@ One row per federal account and fiscal year, covering the budget lifecycle (requ Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_budget_account`), `BudgetAccountQuartersOptions` (pagination plus `tas`), `BudgetAccountRecipientsOptions` (pagination plus `funding_organization_id`). `SHAPE_BUDGET_ACCOUNTS_MINIMAL` mirrors the API's default shape. -- **The list endpoint rejects an unknown filter name** with a 400 and a did-you-mean, rather than silently returning the unfiltered page. Typed fields cover the identity filters (`federal_account_symbol`, `fiscal_year` and its range, `agency_code`, `bureau_name`, `account_title`, `bea_category`, `on_off_budget`, `subfunction_code`); the numeric `__gte` / `__lte` range filters and the `__in` variants go through `extra`. +- **The list endpoint rejects an unknown filter name** with a 400 and a did-you-mean, rather than silently returning the unfiltered page. Typed fields cover the identity filters (`federal_account_symbol`, `fiscal_year` and its range, `data_through_period` with its range and `data_through_period_isnull`, `agency_code`, `bureau_name`, `account_title`, `bea_category`, `on_off_budget`, `subfunction_code`, `account_category` and `account_category_in`); the numeric `__gte` / `__lte` range filters and the other `__in` variants go through `extra`. +- **`data_through_period`** is in the default shape, right after `fiscal_year`: the File A fiscal period (1–12) the account-year's figures run through, so the latest fiscal year is partial until it reaches 12. It is null when the row has no File A data. Read it with `models::budget_data_through_period(&row)`, which returns `Option`. +- **`account_category`** is an open string, `budgetary` or `credit_financing` today. Credit financing accounts have null `enacted_ba` and are left out of organization budget totals. The default ordering is latest fiscal year first, then largest `enacted_ba`, with null `enacted_ba` last and `id` breaking ties. +- **`source_anomalies`** is in the default shape: a list of problems found in the source data behind the row, `[]` when the row is clean. Decode it with `models::BudgetSourceAnomaly::from_record(&row)`. Each element names a `code` (an open string; `contract_exceeds_obligations`, `assistance_exceeds_obligations` and `contract_without_obligations` today), the `action` taken (`capped` changes the served value, `flagged` does not), the `reported_value` and `served_value`, and the `source` rows behind it. There is no filter on it. - **`{id}` is the row's numeric `id`**, not the federal account symbol. - **`quarters` covers FY2021 onward**; an earlier account-year returns an empty page. - **`recipients` is 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`. diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 5e2379e..c563d70 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -107,7 +107,7 @@ All 34 constants live in `shapes.rs` and are re-exported at the crate root. They | `SHAPE_SLED_REVISIONS_MINIMAL` | `list_sled_opportunity_revisions` | observed_at, sequence, kind, changed_fields, source_declared | | `SHAPE_SLED_FORECASTS_MINIMAL` | `list_sled_forecasts` | forecast identity, agency, advertisement estimate, value band | | `SHAPE_SLED_FORECASTS_COMPREHENSIVE` | `get_sled_forecast` | the above + description, organization, contact | -| `SHAPE_BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts` | the API's default: identity + lifecycle dollars + capped ratios | +| `SHAPE_BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts` | the API's default: identity (including `data_through_period` and `account_category`) + lifecycle dollars + attribution status + capped ratios + `source_anomalies` | | `SHAPE_DIBBS_RFQS_MINIMAL` | `list_dibbs_rfqs` | uuid, solicitation, NSN, part number, nomenclature, quantity, dates, is_open | | `SHAPE_DIBBS_RFPS_MINIMAL` | `list_dibbs_rfps` | uuid, solicitation, NSN, part number, nomenclature, dates, is_open | | `SHAPE_DIBBS_AWARDS_MINIMAL` | `list_dibbs_awards` | uuid, award number, solicitation, NSN, part, awardee CAGE, award date, order total |