use crate::Result;
use crate::api::paths;
use crate::client::RobinhoodClient;
use crate::models::dividend::{Dividend, InterestPayment};
use rust_decimal::Decimal;
use std::str::FromStr;
pub(crate) fn sum_dividend_amounts(dividends: &[crate::models::dividend::Dividend]) -> String {
let total: Decimal = dividends
.iter()
.filter(|d| matches!(d.state.as_deref(), Some("paid" | "reinvested")))
.filter_map(|d| d.amount.as_deref())
.filter_map(|a| Decimal::from_str(a).ok())
.sum();
total.to_string()
}
impl RobinhoodClient {
pub async fn get_dividends(&self, since: Option<&str>) -> Result<Vec<Dividend>> {
let mut params: Vec<(&str, &str)> = Vec::new();
if let Some(date) = since {
params.push(("updated_at[gte]", date));
}
let mut dividends: Vec<Dividend> = self
.get_paginated(&self.api_url(paths::DIVIDENDS), ¶ms)
.await?;
#[expect(
clippy::let_underscore_must_use,
reason = "the documented best-effort enrichment contract returns raw dividends when symbol resolution fails"
)]
let _ = self.enrich_dividend_symbols(&mut dividends).await;
Ok(dividends)
}
pub async fn enrich_dividend_symbols(&self, dividends: &mut [Dividend]) -> Result<()> {
let uuids: Vec<String> = dividends
.iter()
.filter(|d| d.symbol.is_none())
.filter_map(|d| d.instrument.as_deref())
.filter_map(|url| crate::util::instrument_id_from_url(url))
.map(|id| id.to_string())
.collect();
if uuids.is_empty() {
return Ok(());
}
let map = self.resolve_symbols(&uuids).await?;
for d in dividends.iter_mut() {
if d.symbol.is_none()
&& let Some(url) = d.instrument.as_deref()
&& let Some(id) = crate::util::instrument_id_from_url(url)
&& let Some(sym) = map.get(id)
{
d.symbol = Some(sym.clone());
}
}
Ok(())
}
pub async fn get_total_dividends(&self) -> Result<String> {
let dividends = self.get_dividends(None).await?;
Ok(sum_dividend_amounts(÷nds))
}
pub async fn get_interest_payments(&self) -> Result<Vec<InterestPayment>> {
self.get_paginated(&self.api_url(paths::SWEEPS), &[]).await
}
}
#[cfg(test)]
#[expect(
clippy::assertions_on_result_states,
reason = "this endpoint test intentionally asserts the propagated error state without unwrapping"
)]
mod endpoint_tests {
use crate::client::RobinhoodClient;
use crate::config::RhoodConfig;
use crate::models::dividend::Dividend;
use secrecy::SecretString;
use wiremock::matchers::{method, path, query_param, query_param_is_missing};
use wiremock::{Mock, MockServer, ResponseTemplate};
const INSTRUMENT_UUID: &str = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
let dir = tempfile::tempdir().unwrap();
let mut config = RhoodConfig::default();
config.auth.token_cache_path = dir
.path()
.join("nonexistent-token.json")
.to_str()
.unwrap()
.to_string();
config.api.base_url = base_url.to_string();
config.api.phoenix_url = base_url.to_string();
config.api.bonfire_url = base_url.to_string();
let client = RobinhoodClient::with_config(config).unwrap();
client
.inject_test_auth(
SecretString::from("access-token"),
"Bearer".to_string(),
SecretString::from("refresh-token"),
)
.await;
(dir, client)
}
fn instrument_url() -> String {
format!("https://api.robinhood.com/instruments/{INSTRUMENT_UUID}/")
}
#[tokio::test]
async fn get_dividends_without_since_omits_date_filter() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.and(query_param_is_missing("updated_at[gte]"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "div-001", "amount": "1.25", "state": "paid"},
{"id": "div-002", "amount": "0.50", "state": "pending"}
],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let dividends = client.get_dividends(None).await.unwrap();
assert_eq!(dividends.len(), 2);
assert_eq!(dividends[0].id.as_deref(), Some("div-001"));
}
#[tokio::test]
async fn get_dividends_with_since_sends_date_filter() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.and(query_param("updated_at[gte]", "2025-01-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "div-003", "amount": "2.00", "state": "paid"}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let dividends = client.get_dividends(Some("2025-01-01")).await.unwrap();
assert_eq!(dividends.len(), 1);
assert_eq!(dividends[0].id.as_deref(), Some("div-003"));
}
#[tokio::test]
async fn get_dividends_enriches_missing_symbols() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{
"id": "div-004",
"instrument": instrument_url(),
"amount": "1.00",
"state": "paid"
}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/instruments/"))
.and(query_param("ids", INSTRUMENT_UUID))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": INSTRUMENT_UUID, "symbol": "TSLA"}]
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let dividends = client.get_dividends(None).await.unwrap();
assert_eq!(dividends[0].symbol.as_deref(), Some("TSLA"));
}
#[tokio::test]
async fn get_dividends_returns_raw_when_enrichment_fails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{
"id": "div-005",
"instrument": instrument_url(),
"amount": "1.00",
"state": "paid"
}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/instruments/"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let dividends = client.get_dividends(None).await.unwrap();
assert_eq!(dividends.len(), 1);
assert_eq!(dividends[0].id.as_deref(), Some("div-005"));
assert!(dividends[0].symbol.is_none());
}
#[tokio::test]
async fn enrich_dividend_symbols_is_noop_without_unresolved_instruments() {
let server = MockServer::start().await;
let (_dir, client) = client_for_server(&server.uri()).await;
let mut dividends = vec![Dividend {
id: Some("div-006".to_string()),
symbol: Some("EXISTING".to_string()),
..Default::default()
}];
client
.enrich_dividend_symbols(&mut dividends)
.await
.unwrap();
assert_eq!(dividends[0].symbol.as_deref(), Some("EXISTING"));
}
#[tokio::test]
async fn enrich_dividend_symbols_resolves_via_instruments() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/instruments/"))
.and(query_param("ids", INSTRUMENT_UUID))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": INSTRUMENT_UUID, "symbol": "AAPL"}]
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let mut dividends = vec![Dividend {
id: Some("div-007".to_string()),
instrument: Some(instrument_url()),
symbol: None,
..Default::default()
}];
client
.enrich_dividend_symbols(&mut dividends)
.await
.unwrap();
assert_eq!(dividends[0].symbol.as_deref(), Some("AAPL"));
}
#[tokio::test]
async fn get_total_dividends_sums_paid_and_reinvested() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "d1", "amount": "0.07", "state": "paid"},
{"id": "d2", "amount": "0.12", "state": "reinvested"},
{"id": "d3", "amount": "5.00", "state": "pending"}
],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let total = client.get_total_dividends().await.unwrap();
assert_eq!(total, "0.19");
}
#[tokio::test]
async fn get_interest_payments_returns_all() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/accounts/sweeps/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{
"id": "int-001",
"amount": {"amount": "2.99", "currency_code": "USD"},
"direction": "credit",
"payout_type": "eom_payment"
}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let payments = client.get_interest_payments().await.unwrap();
assert_eq!(payments.len(), 1);
assert_eq!(payments[0].display_id(), "int-001");
}
#[tokio::test]
async fn get_dividends_propagates_server_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/dividends/"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
assert!(client.get_dividends(None).await.is_err());
}
}
#[cfg(test)]
mod tests {
use crate::models::dividend::{Dividend, InterestPayment};
#[test]
fn sum_dividend_amounts_avoids_float_error() {
use crate::models::dividend::Dividend;
let mk = |state: &str, amount: &str| Dividend {
amount: Some(amount.into()),
state: Some(state.into()),
..Default::default()
};
let divs = vec![
mk("paid", "0.07"),
mk("reinvested", "0.12"),
mk("pending", "5.00"), mk("voided", "9.00"), ];
assert_eq!(super::sum_dividend_amounts(&divs), "0.19");
}
#[test]
fn sum_dividend_amounts_preserves_trailing_zeros() {
use crate::models::dividend::Dividend;
let mk = |state: &str, amount: &str| Dividend {
amount: Some(amount.into()),
state: Some(state.into()),
..Default::default()
};
let divs = vec![mk("paid", "2.50"), mk("reinvested", "2.50")];
assert_eq!(super::sum_dividend_amounts(&divs), "5.00");
assert_eq!(super::sum_dividend_amounts(&[]), "0");
}
#[test]
fn dividend_deserializes_full() {
let json = r#"{
"id": "div-001",
"url": "https://api.robinhood.com/dividends/div-001/",
"account": "https://api.robinhood.com/accounts/ABC123/",
"instrument": "https://api.robinhood.com/instruments/inst-001/",
"amount": "1.25",
"rate": "0.25",
"position": "5.0000",
"withholding": "0.00",
"record_date": "2026-03-15",
"payable_date": "2026-03-20",
"paid_at": "2026-03-20T10:00:00Z",
"state": "paid",
"nra_withholding": "0.00",
"drip_enabled": true
}"#;
let div: Dividend = serde_json::from_str(json).unwrap();
assert_eq!(div.id.as_deref(), Some("div-001"));
assert_eq!(div.amount.as_deref(), Some("1.25"));
assert_eq!(div.state.as_deref(), Some("paid"));
assert_eq!(div.drip_enabled, Some(true));
}
#[test]
fn dividend_handles_missing_fields() {
let json = r#"{"id": "div-002", "state": "pending"}"#;
let div: Dividend = serde_json::from_str(json).unwrap();
assert_eq!(div.id.as_deref(), Some("div-002"));
assert!(div.amount.is_none());
assert!(div.paid_at.is_none());
assert!(div.symbol.is_none());
}
#[test]
fn dividend_symbol_field_deserializes() {
let json = r#"{"id": "div-003", "symbol": "AAPL", "state": "paid"}"#;
let div: Dividend = serde_json::from_str(json).unwrap();
assert_eq!(div.symbol.as_deref(), Some("AAPL"));
assert_eq!(div.id.as_deref(), Some("div-003"));
}
#[test]
fn enrich_dividend_symbols_applies_map() {
let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
let url = format!("https://api.robinhood.com/instruments/{uuid}/");
let mut div = Dividend {
id: Some("div-004".to_string()),
instrument: Some(url.clone()),
symbol: None,
..Default::default()
};
let mut map = std::collections::HashMap::new();
map.insert(uuid.to_string(), "TSLA".to_string());
let dividends: &mut [Dividend] = std::slice::from_mut(&mut div);
for d in dividends.iter_mut() {
if d.symbol.is_none()
&& let Some(instrument_url) = d.instrument.as_deref()
&& let Some(id) = crate::util::instrument_id_from_url(instrument_url)
&& let Some(sym) = map.get(id)
{
d.symbol = Some(sym.clone());
}
}
assert_eq!(div.symbol.as_deref(), Some("TSLA"));
}
#[test]
fn enrich_dividend_symbols_skips_already_set() {
let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
let url = format!("https://api.robinhood.com/instruments/{uuid}/");
let mut div = Dividend {
instrument: Some(url),
symbol: Some("EXISTING".to_string()),
..Default::default()
};
let mut map = std::collections::HashMap::new();
map.insert(uuid.to_string(), "REPLACED".to_string());
let dividends: &mut [Dividend] = std::slice::from_mut(&mut div);
for d in dividends.iter_mut() {
if d.symbol.is_none()
&& let Some(instrument_url) = d.instrument.as_deref()
&& let Some(id) = crate::util::instrument_id_from_url(instrument_url)
&& let Some(sym) = map.get(id)
{
d.symbol = Some(sym.clone());
}
}
assert_eq!(div.symbol.as_deref(), Some("EXISTING"));
}
#[test]
fn interest_payment_deserializes_real_api_shape() {
let json = r#"{
"amount": {
"amount": "2.99",
"currency_code": "USD",
"currency_id": "1072fc76-1862-41ab-82c2-485837590762"
},
"direction": "credit",
"id": "9c6fe185-e563-4d33-95b0-6c8fe558bcf1",
"account_number": "767920911",
"pay_date": "2026-03-31T21:00:00Z",
"pay_period_start": "2026-03-31T21:00:00Z",
"pay_period_end": "2026-03-31T21:00:00Z",
"payout_type": "eom_payment",
"reason": "interest_payment"
}"#;
let payment: InterestPayment = serde_json::from_str(json).unwrap();
assert_eq!(payment.display_id(), "9c6fe185-e563-4d33-95b0-6c8fe558bcf1");
assert_eq!(payment.display_amount(), "2.99");
assert_eq!(payment.display_payout_type(), "eom_payment");
assert_eq!(payment.display_pay_date(), "2026-03-31T21:00:00Z");
assert_eq!(payment.direction.as_deref(), Some("credit"));
assert_eq!(payment.account_number.as_deref(), Some("767920911"));
assert_eq!(payment.reason.as_deref(), Some("interest_payment"));
let amount = payment.amount.unwrap();
assert_eq!(amount.currency_code.as_deref(), Some("USD"));
assert_eq!(
amount.currency_id.as_deref(),
Some("1072fc76-1862-41ab-82c2-485837590762")
);
}
#[test]
fn interest_payment_deserializes_missing_fields() {
let json = r#"{}"#;
let payment: InterestPayment = serde_json::from_str(json).unwrap();
assert_eq!(payment.display_id(), "");
assert_eq!(payment.display_amount(), "");
assert!(payment.direction.is_none());
assert!(payment.pay_date.is_none());
}
}