Skip to main content

rhood_core/endpoints/
dividends.rs

1use crate::Result;
2use crate::api::paths;
3use crate::client::RobinhoodClient;
4use crate::models::dividend::{Dividend, InterestPayment};
5use rust_decimal::Decimal;
6use std::str::FromStr;
7
8/// Sums the `amount` of dividends in `paid`/`reinvested` states using decimal
9/// arithmetic, returning the total as a string.
10///
11/// Decimal summation preserves the source amounts' scale, so currency values
12/// keep their trailing zeros (e.g. `"0.07" + "0.12" -> "0.19"`, and a lone
13/// `"5.00" -> "5.00"`). Trailing zeros are deliberately *not* stripped.
14pub(crate) fn sum_dividend_amounts(dividends: &[crate::models::dividend::Dividend]) -> String {
15    let total: Decimal = dividends
16        .iter()
17        .filter(|d| matches!(d.state.as_deref(), Some("paid" | "reinvested")))
18        .filter_map(|d| d.amount.as_deref())
19        .filter_map(|a| Decimal::from_str(a).ok())
20        .sum();
21    total.to_string()
22}
23
24impl RobinhoodClient {
25    /// Fetches all dividend payments, optionally filtered by date.
26    ///
27    /// When `since` is provided, only dividends updated on or after that date
28    /// (ISO 8601 format, e.g. "2025-01-01") are returned.
29    ///
30    /// Instrument URLs are resolved to ticker symbols on a best-effort basis
31    /// via [`enrich_dividend_symbols`]; any failure is silently ignored so
32    /// that the caller always receives the raw dividends even when the
33    /// symbol-resolution request fails.
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the HTTP request fails or the response cannot be
38    /// deserialized.
39    ///
40    /// [`enrich_dividend_symbols`]: RobinhoodClient::enrich_dividend_symbols
41    pub async fn get_dividends(&self, since: Option<&str>) -> Result<Vec<Dividend>> {
42        let mut params: Vec<(&str, &str)> = Vec::new();
43        if let Some(date) = since {
44            params.push(("updated_at[gte]", date));
45        }
46        let mut dividends: Vec<Dividend> = self
47            .get_paginated(&self.api_url(paths::DIVIDENDS), &params)
48            .await?;
49        #[expect(
50            clippy::let_underscore_must_use,
51            reason = "the documented best-effort enrichment contract returns raw dividends when symbol resolution fails"
52        )]
53        let _ = self.enrich_dividend_symbols(&mut dividends).await;
54        Ok(dividends)
55    }
56
57    /// Backfills `symbol` on each dividend by resolving its instrument URL to a
58    /// ticker via a single batched `/instruments/?ids=` request. Best-effort:
59    /// dividends whose URL can't be parsed or resolved are left with
60    /// `symbol = None`.
61    pub async fn enrich_dividend_symbols(&self, dividends: &mut [Dividend]) -> Result<()> {
62        let uuids: Vec<String> = dividends
63            .iter()
64            .filter(|d| d.symbol.is_none())
65            .filter_map(|d| d.instrument.as_deref())
66            .filter_map(|url| crate::util::instrument_id_from_url(url))
67            .map(|id| id.to_string())
68            .collect();
69        if uuids.is_empty() {
70            return Ok(());
71        }
72        let map = self.resolve_symbols(&uuids).await?;
73        for d in dividends.iter_mut() {
74            if d.symbol.is_none()
75                && let Some(url) = d.instrument.as_deref()
76                && let Some(id) = crate::util::instrument_id_from_url(url)
77                && let Some(sym) = map.get(id)
78            {
79                d.symbol = Some(sym.clone());
80            }
81        }
82        Ok(())
83    }
84
85    /// Computes the total dividend income received (paid + reinvested) as a
86    /// decimal string that preserves the source amounts' precision. Excludes
87    /// voided/pending dividends.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the dividend fetch fails.
92    pub async fn get_total_dividends(&self) -> Result<String> {
93        let dividends = self.get_dividends(None).await?;
94        Ok(sum_dividend_amounts(&dividends))
95    }
96
97    /// Fetches all interest/sweep payments.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the HTTP request fails or the response cannot be
102    /// deserialized.
103    pub async fn get_interest_payments(&self) -> Result<Vec<InterestPayment>> {
104        self.get_paginated(&self.api_url(paths::SWEEPS), &[]).await
105    }
106}
107
108// Network-exercising tests for the endpoint methods. They build a client
109// pointed at a wiremock server and use the crate-internal `inject_test_auth`
110// (compiled under `cfg(test)`) to get past `require_auth`. These run on every
111// `cargo test` / `cargo nextest run` and count toward coverage.
112#[cfg(test)]
113#[expect(
114    clippy::assertions_on_result_states,
115    reason = "this endpoint test intentionally asserts the propagated error state without unwrapping"
116)]
117mod endpoint_tests {
118    use crate::client::RobinhoodClient;
119    use crate::config::RhoodConfig;
120    use crate::models::dividend::Dividend;
121    use secrecy::SecretString;
122    use wiremock::matchers::{method, path, query_param, query_param_is_missing};
123    use wiremock::{Mock, MockServer, ResponseTemplate};
124
125    const INSTRUMENT_UUID: &str = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
126
127    /// Builds a client whose every base URL points at `base_url`, with an
128    /// injected authenticated session.
129    async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
130        let dir = tempfile::tempdir().unwrap();
131        let mut config = RhoodConfig::default();
132        config.auth.token_cache_path = dir
133            .path()
134            .join("nonexistent-token.json")
135            .to_str()
136            .unwrap()
137            .to_string();
138        config.api.base_url = base_url.to_string();
139        config.api.phoenix_url = base_url.to_string();
140        config.api.bonfire_url = base_url.to_string();
141        let client = RobinhoodClient::with_config(config).unwrap();
142        client
143            .inject_test_auth(
144                SecretString::from("access-token"),
145                "Bearer".to_string(),
146                SecretString::from("refresh-token"),
147            )
148            .await;
149        (dir, client)
150    }
151
152    fn instrument_url() -> String {
153        format!("https://api.robinhood.com/instruments/{INSTRUMENT_UUID}/")
154    }
155
156    #[tokio::test]
157    async fn get_dividends_without_since_omits_date_filter() {
158        let server = MockServer::start().await;
159        Mock::given(method("GET"))
160            .and(path("/dividends/"))
161            .and(query_param_is_missing("updated_at[gte]"))
162            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
163                "results": [
164                    {"id": "div-001", "amount": "1.25", "state": "paid"},
165                    {"id": "div-002", "amount": "0.50", "state": "pending"}
166                ],
167                "next": null,
168                "previous": null
169            })))
170            .mount(&server)
171            .await;
172        let (_dir, client) = client_for_server(&server.uri()).await;
173
174        let dividends = client.get_dividends(None).await.unwrap();
175
176        assert_eq!(dividends.len(), 2);
177        assert_eq!(dividends[0].id.as_deref(), Some("div-001"));
178    }
179
180    #[tokio::test]
181    async fn get_dividends_with_since_sends_date_filter() {
182        let server = MockServer::start().await;
183        Mock::given(method("GET"))
184            .and(path("/dividends/"))
185            .and(query_param("updated_at[gte]", "2025-01-01"))
186            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
187                "results": [{"id": "div-003", "amount": "2.00", "state": "paid"}],
188                "next": null,
189                "previous": null
190            })))
191            .mount(&server)
192            .await;
193        let (_dir, client) = client_for_server(&server.uri()).await;
194
195        let dividends = client.get_dividends(Some("2025-01-01")).await.unwrap();
196
197        assert_eq!(dividends.len(), 1);
198        assert_eq!(dividends[0].id.as_deref(), Some("div-003"));
199    }
200
201    #[tokio::test]
202    async fn get_dividends_enriches_missing_symbols() {
203        let server = MockServer::start().await;
204        Mock::given(method("GET"))
205            .and(path("/dividends/"))
206            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
207                "results": [{
208                    "id": "div-004",
209                    "instrument": instrument_url(),
210                    "amount": "1.00",
211                    "state": "paid"
212                }],
213                "next": null,
214                "previous": null
215            })))
216            .mount(&server)
217            .await;
218        // Symbol resolution: the batched `/instruments/?ids=` lookup.
219        Mock::given(method("GET"))
220            .and(path("/instruments/"))
221            .and(query_param("ids", INSTRUMENT_UUID))
222            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
223                "results": [{"id": INSTRUMENT_UUID, "symbol": "TSLA"}]
224            })))
225            .mount(&server)
226            .await;
227        let (_dir, client) = client_for_server(&server.uri()).await;
228
229        let dividends = client.get_dividends(None).await.unwrap();
230
231        assert_eq!(dividends[0].symbol.as_deref(), Some("TSLA"));
232    }
233
234    #[tokio::test]
235    async fn get_dividends_returns_raw_when_enrichment_fails() {
236        let server = MockServer::start().await;
237        Mock::given(method("GET"))
238            .and(path("/dividends/"))
239            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
240                "results": [{
241                    "id": "div-005",
242                    "instrument": instrument_url(),
243                    "amount": "1.00",
244                    "state": "paid"
245                }],
246                "next": null,
247                "previous": null
248            })))
249            .mount(&server)
250            .await;
251        // Symbol resolution fails so enrichment is best-effort and swallowed.
252        Mock::given(method("GET"))
253            .and(path("/instruments/"))
254            .respond_with(ResponseTemplate::new(500))
255            .mount(&server)
256            .await;
257        let (_dir, client) = client_for_server(&server.uri()).await;
258
259        let dividends = client.get_dividends(None).await.unwrap();
260
261        assert_eq!(dividends.len(), 1);
262        assert_eq!(dividends[0].id.as_deref(), Some("div-005"));
263        assert!(dividends[0].symbol.is_none());
264    }
265
266    #[tokio::test]
267    async fn enrich_dividend_symbols_is_noop_without_unresolved_instruments() {
268        // No instrument URLs to resolve, so no `/instruments/` request is made.
269        let server = MockServer::start().await;
270        let (_dir, client) = client_for_server(&server.uri()).await;
271
272        let mut dividends = vec![Dividend {
273            id: Some("div-006".to_string()),
274            symbol: Some("EXISTING".to_string()),
275            ..Default::default()
276        }];
277        client
278            .enrich_dividend_symbols(&mut dividends)
279            .await
280            .unwrap();
281
282        assert_eq!(dividends[0].symbol.as_deref(), Some("EXISTING"));
283    }
284
285    #[tokio::test]
286    async fn enrich_dividend_symbols_resolves_via_instruments() {
287        let server = MockServer::start().await;
288        Mock::given(method("GET"))
289            .and(path("/instruments/"))
290            .and(query_param("ids", INSTRUMENT_UUID))
291            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
292                "results": [{"id": INSTRUMENT_UUID, "symbol": "AAPL"}]
293            })))
294            .mount(&server)
295            .await;
296        let (_dir, client) = client_for_server(&server.uri()).await;
297
298        let mut dividends = vec![Dividend {
299            id: Some("div-007".to_string()),
300            instrument: Some(instrument_url()),
301            symbol: None,
302            ..Default::default()
303        }];
304        client
305            .enrich_dividend_symbols(&mut dividends)
306            .await
307            .unwrap();
308
309        assert_eq!(dividends[0].symbol.as_deref(), Some("AAPL"));
310    }
311
312    #[tokio::test]
313    async fn get_total_dividends_sums_paid_and_reinvested() {
314        let server = MockServer::start().await;
315        Mock::given(method("GET"))
316            .and(path("/dividends/"))
317            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
318                "results": [
319                    {"id": "d1", "amount": "0.07", "state": "paid"},
320                    {"id": "d2", "amount": "0.12", "state": "reinvested"},
321                    {"id": "d3", "amount": "5.00", "state": "pending"}
322                ],
323                "next": null,
324                "previous": null
325            })))
326            .mount(&server)
327            .await;
328        let (_dir, client) = client_for_server(&server.uri()).await;
329
330        let total = client.get_total_dividends().await.unwrap();
331
332        assert_eq!(total, "0.19");
333    }
334
335    #[tokio::test]
336    async fn get_interest_payments_returns_all() {
337        let server = MockServer::start().await;
338        Mock::given(method("GET"))
339            .and(path("/accounts/sweeps/"))
340            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
341                "results": [{
342                    "id": "int-001",
343                    "amount": {"amount": "2.99", "currency_code": "USD"},
344                    "direction": "credit",
345                    "payout_type": "eom_payment"
346                }],
347                "next": null,
348                "previous": null
349            })))
350            .mount(&server)
351            .await;
352        let (_dir, client) = client_for_server(&server.uri()).await;
353
354        let payments = client.get_interest_payments().await.unwrap();
355
356        assert_eq!(payments.len(), 1);
357        assert_eq!(payments[0].display_id(), "int-001");
358    }
359
360    #[tokio::test]
361    async fn get_dividends_propagates_server_error() {
362        let server = MockServer::start().await;
363        Mock::given(method("GET"))
364            .and(path("/dividends/"))
365            .respond_with(ResponseTemplate::new(500))
366            .mount(&server)
367            .await;
368        let (_dir, client) = client_for_server(&server.uri()).await;
369
370        assert!(client.get_dividends(None).await.is_err());
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use crate::models::dividend::{Dividend, InterestPayment};
377
378    #[test]
379    fn sum_dividend_amounts_avoids_float_error() {
380        use crate::models::dividend::Dividend;
381        let mk = |state: &str, amount: &str| Dividend {
382            amount: Some(amount.into()),
383            state: Some(state.into()),
384            ..Default::default()
385        };
386        let divs = vec![
387            mk("paid", "0.07"),
388            mk("reinvested", "0.12"),
389            mk("pending", "5.00"), // excluded
390            mk("voided", "9.00"),  // excluded
391        ];
392        assert_eq!(super::sum_dividend_amounts(&divs), "0.19");
393    }
394
395    #[test]
396    fn sum_dividend_amounts_preserves_trailing_zeros() {
397        use crate::models::dividend::Dividend;
398        let mk = |state: &str, amount: &str| Dividend {
399            amount: Some(amount.into()),
400            state: Some(state.into()),
401            ..Default::default()
402        };
403        // A whole-dollar total keeps its currency precision rather than
404        // collapsing to "5".
405        let divs = vec![mk("paid", "2.50"), mk("reinvested", "2.50")];
406        assert_eq!(super::sum_dividend_amounts(&divs), "5.00");
407        // Empty / all-excluded sums to a plain zero.
408        assert_eq!(super::sum_dividend_amounts(&[]), "0");
409    }
410
411    #[test]
412    fn dividend_deserializes_full() {
413        let json = r#"{
414            "id": "div-001",
415            "url": "https://api.robinhood.com/dividends/div-001/",
416            "account": "https://api.robinhood.com/accounts/ABC123/",
417            "instrument": "https://api.robinhood.com/instruments/inst-001/",
418            "amount": "1.25",
419            "rate": "0.25",
420            "position": "5.0000",
421            "withholding": "0.00",
422            "record_date": "2026-03-15",
423            "payable_date": "2026-03-20",
424            "paid_at": "2026-03-20T10:00:00Z",
425            "state": "paid",
426            "nra_withholding": "0.00",
427            "drip_enabled": true
428        }"#;
429        let div: Dividend = serde_json::from_str(json).unwrap();
430        assert_eq!(div.id.as_deref(), Some("div-001"));
431        assert_eq!(div.amount.as_deref(), Some("1.25"));
432        assert_eq!(div.state.as_deref(), Some("paid"));
433        assert_eq!(div.drip_enabled, Some(true));
434    }
435
436    #[test]
437    fn dividend_handles_missing_fields() {
438        let json = r#"{"id": "div-002", "state": "pending"}"#;
439        let div: Dividend = serde_json::from_str(json).unwrap();
440        assert_eq!(div.id.as_deref(), Some("div-002"));
441        assert!(div.amount.is_none());
442        assert!(div.paid_at.is_none());
443        // symbol is not in raw API response so must default to None
444        assert!(div.symbol.is_none());
445    }
446
447    #[test]
448    fn dividend_symbol_field_deserializes() {
449        // When the JSON happens to include "symbol" (e.g. after enrichment
450        // round-trip through serde), it should populate the field.
451        let json = r#"{"id": "div-003", "symbol": "AAPL", "state": "paid"}"#;
452        let div: Dividend = serde_json::from_str(json).unwrap();
453        assert_eq!(div.symbol.as_deref(), Some("AAPL"));
454        assert_eq!(div.id.as_deref(), Some("div-003"));
455    }
456
457    #[test]
458    fn enrich_dividend_symbols_applies_map() {
459        // Unit test for the assignment loop logic, mirroring the positions test.
460        let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
461        let url = format!("https://api.robinhood.com/instruments/{uuid}/");
462        let mut div = Dividend {
463            id: Some("div-004".to_string()),
464            instrument: Some(url.clone()),
465            symbol: None,
466            ..Default::default()
467        };
468
469        // Simulate what enrich_dividend_symbols does after calling resolve_symbols
470        let mut map = std::collections::HashMap::new();
471        map.insert(uuid.to_string(), "TSLA".to_string());
472
473        let dividends: &mut [Dividend] = std::slice::from_mut(&mut div);
474        for d in dividends.iter_mut() {
475            if d.symbol.is_none()
476                && let Some(instrument_url) = d.instrument.as_deref()
477                && let Some(id) = crate::util::instrument_id_from_url(instrument_url)
478                && let Some(sym) = map.get(id)
479            {
480                d.symbol = Some(sym.clone());
481            }
482        }
483
484        assert_eq!(div.symbol.as_deref(), Some("TSLA"));
485    }
486
487    #[test]
488    fn enrich_dividend_symbols_skips_already_set() {
489        let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
490        let url = format!("https://api.robinhood.com/instruments/{uuid}/");
491        let mut div = Dividend {
492            instrument: Some(url),
493            symbol: Some("EXISTING".to_string()),
494            ..Default::default()
495        };
496
497        let mut map = std::collections::HashMap::new();
498        map.insert(uuid.to_string(), "REPLACED".to_string());
499
500        // Only apply if symbol is None (mirrors enrich logic)
501        let dividends: &mut [Dividend] = std::slice::from_mut(&mut div);
502        for d in dividends.iter_mut() {
503            if d.symbol.is_none()
504                && let Some(instrument_url) = d.instrument.as_deref()
505                && let Some(id) = crate::util::instrument_id_from_url(instrument_url)
506                && let Some(sym) = map.get(id)
507            {
508                d.symbol = Some(sym.clone());
509            }
510        }
511
512        // Should not be replaced because symbol was already set
513        assert_eq!(div.symbol.as_deref(), Some("EXISTING"));
514    }
515
516    #[test]
517    fn interest_payment_deserializes_real_api_shape() {
518        let json = r#"{
519            "amount": {
520                "amount": "2.99",
521                "currency_code": "USD",
522                "currency_id": "1072fc76-1862-41ab-82c2-485837590762"
523            },
524            "direction": "credit",
525            "id": "9c6fe185-e563-4d33-95b0-6c8fe558bcf1",
526            "account_number": "767920911",
527            "pay_date": "2026-03-31T21:00:00Z",
528            "pay_period_start": "2026-03-31T21:00:00Z",
529            "pay_period_end": "2026-03-31T21:00:00Z",
530            "payout_type": "eom_payment",
531            "reason": "interest_payment"
532        }"#;
533        let payment: InterestPayment = serde_json::from_str(json).unwrap();
534        assert_eq!(payment.display_id(), "9c6fe185-e563-4d33-95b0-6c8fe558bcf1");
535        assert_eq!(payment.display_amount(), "2.99");
536        assert_eq!(payment.display_payout_type(), "eom_payment");
537        assert_eq!(payment.display_pay_date(), "2026-03-31T21:00:00Z");
538        assert_eq!(payment.direction.as_deref(), Some("credit"));
539        assert_eq!(payment.account_number.as_deref(), Some("767920911"));
540        assert_eq!(payment.reason.as_deref(), Some("interest_payment"));
541        let amount = payment.amount.unwrap();
542        assert_eq!(amount.currency_code.as_deref(), Some("USD"));
543        assert_eq!(
544            amount.currency_id.as_deref(),
545            Some("1072fc76-1862-41ab-82c2-485837590762")
546        );
547    }
548
549    #[test]
550    fn interest_payment_deserializes_missing_fields() {
551        let json = r#"{}"#;
552        let payment: InterestPayment = serde_json::from_str(json).unwrap();
553        assert_eq!(payment.display_id(), "");
554        assert_eq!(payment.display_amount(), "");
555        assert!(payment.direction.is_none());
556        assert!(payment.pay_date.is_none());
557    }
558}