Skip to main content

tango/models/
budget.rs

1//! `BudgetSourceAnomaly` — typed view of a budget account's `source_anomalies` list, plus a reader for its `data_through_period`.
2
3use crate::Record;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7
8/// One problem found in the source data behind a budget-account row.
9///
10/// [`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.
11/// Decode it with [`BudgetSourceAnomaly::from_record`].
12///
13/// `code` is an open set, so match on the strings you know and treat anything else as a new kind of anomaly.
14/// 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).
15///
16/// Every field is optional, and unknown server-side fields fall through to [`extra`](Self::extra).
17#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
18pub struct BudgetSourceAnomaly {
19    /// What kind of anomaly this is, e.g. `"contract_exceeds_obligations"`.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub code: Option<String>,
22
23    /// The row field the anomaly is about, e.g. `"contract_obligated"`.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub field: Option<String>,
26
27    /// The field whose value [`field`](Self::field) was checked against, e.g. `"obligated_total"`.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub bound_field: Option<String>,
30
31    /// What was done about it: `"capped"` (the served value was reduced to the bound) or `"flagged"` (served unchanged).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub action: Option<String>,
34
35    /// The value the source data reported.
36    #[serde(
37        default,
38        deserialize_with = "number_or_numeric_string",
39        skip_serializing_if = "Option::is_none"
40    )]
41    pub reported_value: Option<f64>,
42
43    /// The value the API serves for [`field`](Self::field).
44    #[serde(
45        default,
46        deserialize_with = "number_or_numeric_string",
47        skip_serializing_if = "Option::is_none"
48    )]
49    pub served_value: Option<f64>,
50
51    /// Short description of the most likely cause.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub likely_cause: Option<String>,
54
55    /// Every served field the anomaly affects, including values derived from [`field`](Self::field).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub affected_fields: Option<Vec<String>>,
58
59    /// Human-readable explanation.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub message: Option<String>,
62
63    /// The source rows behind the anomaly.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub source: Option<BudgetSourceAnomalySource>,
66
67    /// Forward-compat catch-all for server-side fields the SDK does not yet model.
68    #[serde(flatten, default)]
69    pub extra: HashMap<String, Value>,
70}
71
72/// 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.
73///
74/// Below 12 the fiscal year is partial.
75/// 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.
76pub fn budget_data_through_period(record: &Record) -> Option<u8> {
77    record
78        .get("data_through_period")
79        .and_then(Value::as_u64)
80        .and_then(|n| u8::try_from(n).ok())
81}
82
83/// Decode an amount sent as a JSON number or as a numeric string, since the API passes these values through as the source data carries them.
84pub(crate) fn number_or_numeric_string<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
85where
86    D: serde::Deserializer<'de>,
87{
88    match Option::<Value>::deserialize(deserializer)? {
89        None | Some(Value::Null) => Ok(None),
90        Some(Value::Number(n)) => Ok(n.as_f64()),
91        Some(Value::String(s)) => s
92            .trim()
93            .parse::<f64>()
94            .map(Some)
95            .map_err(serde::de::Error::custom),
96        Some(other) => Err(serde::de::Error::custom(format!(
97            "expected a number or numeric string, got {other}"
98        ))),
99    }
100}
101
102impl BudgetSourceAnomaly {
103    /// Decode the `source_anomalies` field of a budget-account [`Record`].
104    ///
105    /// 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.
106    pub fn from_record(record: &Record) -> serde_json::Result<Vec<Self>> {
107        match record.get("source_anomalies") {
108            None | Some(Value::Null) => Ok(Vec::new()),
109            Some(v) => serde_json::from_value(v.clone()),
110        }
111    }
112}
113
114/// Where a [`BudgetSourceAnomaly`] came from.
115#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
116pub struct BudgetSourceAnomalySource {
117    /// The source dataset the rows were read from.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub dataset: Option<String>,
120
121    /// Fiscal year of the source rows.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub fiscal_year: Option<i64>,
124
125    /// The source rows that account for the anomaly.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub rows: Option<Vec<BudgetSourceAnomalyRow>>,
128
129    /// Forward-compat catch-all for server-side fields the SDK does not yet model.
130    #[serde(flatten, default)]
131    pub extra: HashMap<String, Value>,
132}
133
134/// One source row behind a [`BudgetSourceAnomaly`].
135#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
136pub struct BudgetSourceAnomalyRow {
137    /// Fiscal period (month of the fiscal year) the row was reported in.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub fiscal_period: Option<i64>,
140
141    /// Award PIID.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub piid: Option<String>,
144
145    /// Parent award PIID, for an order under an IDV.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub parent_piid: Option<String>,
148
149    /// Treasury Account Symbol the row was reported against.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub tas: Option<String>,
152
153    /// Reporting agency identifier.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub reporting_agency_id: Option<String>,
156
157    /// Obligated amount the row reports.
158    #[serde(
159        default,
160        deserialize_with = "number_or_numeric_string",
161        skip_serializing_if = "Option::is_none"
162    )]
163    pub transaction_obligated_amount: Option<f64>,
164
165    /// Which source file the row came from.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub file_c_source: Option<String>,
168
169    /// Forward-compat catch-all for server-side fields the SDK does not yet model.
170    #[serde(flatten, default)]
171    pub extra: HashMap<String, Value>,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use serde_json::json;
178
179    fn record(v: Value) -> Record {
180        v.as_object().cloned().expect("object")
181    }
182
183    #[test]
184    fn decodes_a_capped_contract_anomaly_with_source_rows() {
185        let r = record(json!({
186            "federal_account_symbol": "020-4159",
187            "source_anomalies": [{
188                "code": "contract_exceeds_obligations",
189                "field": "contract_obligated",
190                "bound_field": "obligated_total",
191                "action": "capped",
192                "reported_value": 73_470_455_401.8,
193                "served_value": 4_748_670_061.77,
194                "likely_cause": "unit error",
195                "affected_fields": ["contract_obligated", "contract_share_of_obligated_capped"],
196                "message": "reported contracts exceed obligations",
197                "new_key": 1,
198                "source": {
199                    "dataset": "file_c",
200                    "fiscal_year": 2025,
201                    "rows": [{
202                        "fiscal_period": 6,
203                        "piid": "P1",
204                        "parent_piid": null,
205                        "tas": "020-2025/2025-4159",
206                        "reporting_agency_id": "020",
207                        "transaction_obligated_amount": 68_721_785_340.03,
208                        "file_c_source": "award_financial"
209                    }]
210                }
211            }]
212        }));
213        let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
214        assert_eq!(got.len(), 1);
215        let a = &got[0];
216        assert_eq!(a.code.as_deref(), Some("contract_exceeds_obligations"));
217        assert_eq!(a.action.as_deref(), Some("capped"));
218        assert_eq!(a.reported_value, Some(73_470_455_401.8));
219        assert_eq!(a.served_value, Some(4_748_670_061.77));
220        assert_eq!(a.affected_fields.as_ref().map(Vec::len), Some(2));
221        assert_eq!(a.extra.get("new_key"), Some(&json!(1)));
222        let src = a.source.as_ref().expect("source");
223        assert_eq!(src.fiscal_year, Some(2025));
224        let row = &src.rows.as_ref().expect("rows")[0];
225        assert_eq!(row.fiscal_period, Some(6));
226        assert_eq!(row.parent_piid, None);
227        assert_eq!(row.reporting_agency_id.as_deref(), Some("020"));
228        assert_eq!(row.transaction_obligated_amount, Some(68_721_785_340.03));
229    }
230
231    #[test]
232    fn sparse_anomaly_and_unknown_code_decode() {
233        let r = record(json!({"source_anomalies": [{"code": "something_new"}]}));
234        let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
235        assert_eq!(got[0].code.as_deref(), Some("something_new"));
236        assert!(got[0].source.is_none());
237    }
238
239    #[test]
240    fn clean_absent_and_null_are_empty() {
241        for v in [
242            json!({"source_anomalies": []}),
243            json!({}),
244            json!({"source_anomalies": null}),
245        ] {
246            assert!(BudgetSourceAnomaly::from_record(&record(v))
247                .expect("decode")
248                .is_empty());
249        }
250    }
251
252    #[test]
253    fn amounts_sent_as_strings_decode() {
254        let r = record(json!({"source_anomalies": [{
255            "reported_value": "73470455401.80",
256            "served_value": null,
257            "source": {"rows": [{"transaction_obligated_amount": "-12.5"}]}
258        }]}));
259        let got = BudgetSourceAnomaly::from_record(&r).expect("decode");
260        assert_eq!(got[0].reported_value, Some(73_470_455_401.8));
261        assert_eq!(got[0].served_value, None);
262        let rows = got[0]
263            .source
264            .as_ref()
265            .and_then(|s| s.rows.as_ref())
266            .expect("rows");
267        assert_eq!(rows[0].transaction_obligated_amount, Some(-12.5));
268    }
269
270    #[test]
271    fn non_list_is_an_error() {
272        let r = record(json!({"source_anomalies": "oops"}));
273        assert!(BudgetSourceAnomaly::from_record(&r).is_err());
274    }
275}