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
8pub(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 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), ¶ms)
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 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 pub async fn get_total_dividends(&self) -> Result<String> {
93 let dividends = self.get_dividends(None).await?;
94 Ok(sum_dividend_amounts(÷nds))
95 }
96
97 pub async fn get_interest_payments(&self) -> Result<Vec<InterestPayment>> {
104 self.get_paginated(&self.api_url(paths::SWEEPS), &[]).await
105 }
106}
107
108#[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 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 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 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 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"), mk("voided", "9.00"), ];
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 let divs = vec![mk("paid", "2.50"), mk("reinvested", "2.50")];
406 assert_eq!(super::sum_dividend_amounts(&divs), "5.00");
407 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 assert!(div.symbol.is_none());
445 }
446
447 #[test]
448 fn dividend_symbol_field_deserializes() {
449 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 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 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 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 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}