Skip to main content

rhood_core/endpoints/
recurring.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::recurring::{
4    CreateRecurringAssetPayload, CreateRecurringPayload, CreateRecurringRequest, MoneyAmount,
5    NextInvestmentDate, RecurringFrequency, RecurringInvestment, RecurringState,
6    UpdateRecurringPayload, UpdateRecurringRequest,
7};
8use crate::{Result, RhoodError};
9
10impl RobinhoodClient {
11    /// Fetches all recurring investment schedules.
12    ///
13    /// # Errors
14    ///
15    /// Returns an error if the HTTP request fails or the response cannot be
16    /// deserialized.
17    pub async fn get_recurring_investments(&self) -> Result<Vec<RecurringInvestment>> {
18        self.get_paginated(&self.bonfire_url(paths::RECURRING_SCHEDULES), &[])
19            .await
20    }
21
22    /// Creates a new recurring investment schedule.
23    ///
24    /// Resolves the symbol to its instrument ID and discovers the account
25    /// number before submitting. Requires writable mode.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
30    /// Returns [`RhoodError::InvalidSymbol`] if the symbol cannot be resolved.
31    /// Also returns an error on HTTP or deserialization failures.
32    pub async fn create_recurring_investment(
33        &self,
34        create_recurring_request: &CreateRecurringRequest,
35    ) -> Result<RecurringInvestment> {
36        self.require_writable()?;
37
38        let instrument = self
39            .cached_instrument(&create_recurring_request.symbol)
40            .await?
41            .ok_or_else(|| RhoodError::InvalidSymbol(create_recurring_request.symbol.clone()))?;
42        let instrument_id = instrument
43            .id
44            .clone()
45            .ok_or_else(|| RhoodError::InvalidSymbol(create_recurring_request.symbol.clone()))?;
46
47        let profile = self.get_account_profile().await?;
48        let account_number = profile.account_number.ok_or(RhoodError::NotAuthenticated)?;
49
50        let payload = CreateRecurringPayload {
51            account_number: account_number.clone(),
52            amount: MoneyAmount {
53                amount: format!("{:.2}", create_recurring_request.amount),
54                currency_code: "USD".to_string(),
55            },
56            frequency: create_recurring_request.frequency.to_string(),
57            start_date: create_recurring_request.start_date.clone(),
58            investment_asset: CreateRecurringAssetPayload {
59                asset_id: instrument_id,
60                asset_symbol: create_recurring_request.symbol.to_uppercase(),
61                asset_type: "equity".to_string(),
62            },
63            source_of_funds: create_recurring_request.source_of_funds.to_string(),
64            ref_id: uuid::Uuid::new_v4().to_string(),
65            is_backup_ach_enabled: false,
66        };
67
68        let url = format!(
69            "{}?account_number={}",
70            self.bonfire_url(paths::RECURRING_SCHEDULES),
71            account_number
72        );
73        self.post_json(&url, &payload).await
74    }
75
76    /// Updates an existing recurring investment schedule.
77    ///
78    /// Can change amount, frequency, state (pause/resume), or start date.
79    /// Requires writable mode.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
84    /// Also returns an error on HTTP or deserialization failures.
85    pub async fn update_recurring_investment(
86        &self,
87        schedule_id: &str,
88        req: &UpdateRecurringRequest,
89    ) -> Result<RecurringInvestment> {
90        self.require_writable()?;
91
92        let payload = UpdateRecurringPayload {
93            amount: req.amount.map(|amount| MoneyAmount {
94                amount: format!("{amount:.2}"),
95                currency_code: "USD".to_string(),
96            }),
97            frequency: req.frequency.map(|frequency| frequency.to_string()),
98            state: req.state.map(|state| state.to_string()),
99            start_date: req.start_date.clone(),
100        };
101
102        let url = format!(
103            "{}{schedule_id}/",
104            self.bonfire_url(paths::RECURRING_SCHEDULES)
105        );
106        self.patch_json(&url, &payload).await
107    }
108
109    /// Cancels a recurring investment schedule by setting its state to "deleted".
110    ///
111    /// Requires writable mode.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
116    /// Also returns an error on HTTP or deserialization failures.
117    pub async fn cancel_recurring_investment(
118        &self,
119        schedule_id: &str,
120    ) -> Result<RecurringInvestment> {
121        let cancel_req = UpdateRecurringRequest {
122            amount: None,
123            frequency: None,
124            state: Some(RecurringState::Deleted),
125            start_date: None,
126        };
127        self.update_recurring_investment(schedule_id, &cancel_req)
128            .await
129    }
130
131    /// Looks up the next scheduled investment date for a given frequency
132    /// and start date.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the HTTP request fails or the response cannot be
137    /// deserialized.
138    pub async fn get_next_investment_date(
139        &self,
140        frequency: RecurringFrequency,
141        start_date: &str,
142    ) -> Result<NextInvestmentDate> {
143        let url = format!(
144            "{}equity/next_investment_date/",
145            self.bonfire_url(paths::RECURRING_SCHEDULES)
146        );
147        let frequency_string = frequency.to_string();
148        self.get_with_params(
149            &url,
150            &[
151                ("frequency", frequency_string.as_str()),
152                ("start_date", start_date),
153            ],
154        )
155        .await
156    }
157}
158
159// Network-exercising tests for the endpoint methods. They build a client
160// pointed at a wiremock server and use the crate-internal `inject_test_auth`
161// (compiled under `cfg(test)`) to get past `require_auth`. These run on every
162// `cargo test` / `cargo nextest run` and count toward coverage.
163#[cfg(test)]
164#[expect(
165    clippy::assertions_on_result_states,
166    reason = "this endpoint test intentionally asserts the propagated error state without unwrapping"
167)]
168mod endpoint_tests {
169    use crate::client::RobinhoodClient;
170    use crate::config::RhoodConfig;
171    use crate::models::recurring::{
172        CreateRecurringRequest, RecurringFrequency, RecurringInvestment, RecurringSource,
173        UpdateRecurringRequest,
174    };
175    use crate::{Result, RhoodError};
176    use secrecy::SecretString;
177    use wiremock::matchers::{body_string_contains, method, path, query_param};
178    use wiremock::{Mock, MockServer, ResponseTemplate};
179
180    /// Builds a client whose every base URL points at `base_url`, with the
181    /// given read-only setting and an injected authenticated session.
182    async fn client_for_server(
183        base_url: &str,
184        read_only: bool,
185    ) -> (tempfile::TempDir, RobinhoodClient) {
186        let dir = tempfile::tempdir().unwrap();
187        let mut config = RhoodConfig::default();
188        config.auth.token_cache_path = dir
189            .path()
190            .join("nonexistent-token.json")
191            .to_str()
192            .unwrap()
193            .to_string();
194        config.read_only = read_only;
195        config.api.base_url = base_url.to_string();
196        config.api.phoenix_url = base_url.to_string();
197        config.api.bonfire_url = base_url.to_string();
198        let client = RobinhoodClient::with_config(config).unwrap();
199        client
200            .inject_test_auth(
201                SecretString::from("access-token"),
202                "Bearer".to_string(),
203                SecretString::from("refresh-token"),
204            )
205            .await;
206        (dir, client)
207    }
208
209    /// A read-only client never reaches the network; its base URL is unused.
210    async fn read_only_client() -> (tempfile::TempDir, RobinhoodClient) {
211        client_for_server("https://unused.invalid", true).await
212    }
213
214    fn sample_create_request() -> CreateRecurringRequest {
215        CreateRecurringRequest {
216            symbol: "tsla".to_string(),
217            amount: 10.0,
218            frequency: RecurringFrequency::Weekly,
219            start_date: "2026-04-07".to_string(),
220            source_of_funds: RecurringSource::BuyingPower,
221        }
222    }
223
224    #[tokio::test]
225    async fn get_recurring_investments_returns_all_schedules() {
226        let server = MockServer::start().await;
227        Mock::given(method("GET"))
228            .and(path("/recurring_schedules/"))
229            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
230                "results": [
231                    {"id": "sched-001", "frequency": "weekly", "state": "active"},
232                    {"id": "sched-002", "frequency": "monthly", "state": "paused"}
233                ],
234                "next": null,
235                "previous": null
236            })))
237            .mount(&server)
238            .await;
239        let (_dir, client) = client_for_server(&server.uri(), true).await;
240
241        let schedules = client.get_recurring_investments().await.unwrap();
242
243        assert_eq!(schedules.len(), 2);
244        assert_eq!(schedules[0].id.as_deref(), Some("sched-001"));
245        assert_eq!(schedules[1].state.as_deref(), Some("paused"));
246    }
247
248    #[tokio::test]
249    async fn create_recurring_investment_blocked_in_read_only_mode() {
250        let (_dir, client) = read_only_client().await;
251        let err = client
252            .create_recurring_investment(&sample_create_request())
253            .await
254            .unwrap_err();
255        assert!(matches!(err, RhoodError::ReadOnlyMode));
256    }
257
258    #[tokio::test]
259    async fn create_recurring_investment_happy_path_posts_expected_payload() {
260        let server = MockServer::start().await;
261        // Symbol resolution.
262        Mock::given(method("GET"))
263            .and(path("/instruments/"))
264            .and(query_param("symbol", "TSLA"))
265            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
266                "results": [{"id": "inst-tsla", "symbol": "TSLA"}],
267                "next": null
268            })))
269            .mount(&server)
270            .await;
271        // Account discovery.
272        Mock::given(method("GET"))
273            .and(path("/accounts/"))
274            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
275                "results": [{"account_number": "ACC-123"}],
276                "next": null
277            })))
278            .mount(&server)
279            .await;
280        // The actual create call. Assert the derived payload fields.
281        Mock::given(method("POST"))
282            .and(path("/recurring_schedules/"))
283            .and(query_param("account_number", "ACC-123"))
284            .and(body_string_contains("\"amount\":\"10.00\""))
285            .and(body_string_contains("\"frequency\":\"weekly\""))
286            .and(body_string_contains("\"asset_symbol\":\"TSLA\""))
287            .and(body_string_contains("\"asset_type\":\"equity\""))
288            .and(body_string_contains("\"source_of_funds\":\"buying_power\""))
289            .and(body_string_contains("\"is_backup_ach_enabled\":false"))
290            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
291                "id": "sched-new",
292                "account_number": "ACC-123",
293                "frequency": "weekly",
294                "state": "active"
295            })))
296            .mount(&server)
297            .await;
298        let (_dir, client) = client_for_server(&server.uri(), false).await;
299
300        let created = client
301            .create_recurring_investment(&sample_create_request())
302            .await
303            .unwrap();
304
305        assert_eq!(created.id.as_deref(), Some("sched-new"));
306        assert_eq!(created.account_number.as_deref(), Some("ACC-123"));
307    }
308
309    #[tokio::test]
310    async fn create_recurring_investment_unknown_symbol_errors() {
311        let server = MockServer::start().await;
312        Mock::given(method("GET"))
313            .and(path("/instruments/"))
314            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
315                "results": [],
316                "next": null
317            })))
318            .mount(&server)
319            .await;
320        let (_dir, client) = client_for_server(&server.uri(), false).await;
321
322        let err = client
323            .create_recurring_investment(&sample_create_request())
324            .await
325            .unwrap_err();
326
327        assert!(matches!(err, RhoodError::InvalidSymbol(symbol) if symbol == "tsla"));
328    }
329
330    #[tokio::test]
331    async fn create_recurring_investment_instrument_without_id_errors() {
332        let server = MockServer::start().await;
333        Mock::given(method("GET"))
334            .and(path("/instruments/"))
335            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
336                "results": [{"symbol": "TSLA"}],
337                "next": null
338            })))
339            .mount(&server)
340            .await;
341        let (_dir, client) = client_for_server(&server.uri(), false).await;
342
343        let err = client
344            .create_recurring_investment(&sample_create_request())
345            .await
346            .unwrap_err();
347
348        assert!(matches!(err, RhoodError::InvalidSymbol(_)));
349    }
350
351    #[tokio::test]
352    async fn create_recurring_investment_missing_account_number_errors() {
353        let server = MockServer::start().await;
354        Mock::given(method("GET"))
355            .and(path("/instruments/"))
356            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
357                "results": [{"id": "inst-tsla", "symbol": "TSLA"}],
358                "next": null
359            })))
360            .mount(&server)
361            .await;
362        Mock::given(method("GET"))
363            .and(path("/accounts/"))
364            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
365                "results": [{"buying_power": "100.00"}],
366                "next": null
367            })))
368            .mount(&server)
369            .await;
370        let (_dir, client) = client_for_server(&server.uri(), false).await;
371
372        let err = client
373            .create_recurring_investment(&sample_create_request())
374            .await
375            .unwrap_err();
376
377        assert!(matches!(err, RhoodError::NotAuthenticated));
378    }
379
380    #[tokio::test]
381    async fn update_recurring_investment_blocked_in_read_only_mode() {
382        let (_dir, client) = read_only_client().await;
383        let req = UpdateRecurringRequest {
384            amount: Some(15.0),
385            frequency: None,
386            state: None,
387            start_date: None,
388        };
389        let err = client
390            .update_recurring_investment("sched-001", &req)
391            .await
392            .unwrap_err();
393        assert!(matches!(err, RhoodError::ReadOnlyMode));
394    }
395
396    #[tokio::test]
397    async fn update_recurring_investment_patches_only_set_fields() {
398        let server = MockServer::start().await;
399        Mock::given(method("PATCH"))
400            .and(path("/recurring_schedules/sched-001/"))
401            .and(body_string_contains("\"amount\":{\"amount\":\"20.00\""))
402            .and(body_string_contains("\"frequency\":\"biweekly\""))
403            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
404                "id": "sched-001",
405                "frequency": "biweekly",
406                "state": "active"
407            })))
408            .mount(&server)
409            .await;
410        let (_dir, client) = client_for_server(&server.uri(), false).await;
411
412        let req = UpdateRecurringRequest {
413            amount: Some(20.0),
414            frequency: Some(RecurringFrequency::Biweekly),
415            state: None,
416            start_date: None,
417        };
418        let updated = client
419            .update_recurring_investment("sched-001", &req)
420            .await
421            .unwrap();
422
423        assert_eq!(updated.frequency.as_deref(), Some("biweekly"));
424    }
425
426    #[tokio::test]
427    async fn update_recurring_investment_omits_unset_fields() {
428        let server = MockServer::start().await;
429        // An empty update serializes to `{}` because every field skips when None.
430        Mock::given(method("PATCH"))
431            .and(path("/recurring_schedules/sched-001/"))
432            .and(body_string_contains("{}"))
433            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
434                "id": "sched-001"
435            })))
436            .mount(&server)
437            .await;
438        let (_dir, client) = client_for_server(&server.uri(), false).await;
439
440        let req = UpdateRecurringRequest {
441            amount: None,
442            frequency: None,
443            state: None,
444            start_date: None,
445        };
446        let updated = client
447            .update_recurring_investment("sched-001", &req)
448            .await
449            .unwrap();
450
451        assert_eq!(updated.id.as_deref(), Some("sched-001"));
452    }
453
454    #[tokio::test]
455    async fn cancel_recurring_investment_sends_deleted_state() {
456        let server = MockServer::start().await;
457        Mock::given(method("PATCH"))
458            .and(path("/recurring_schedules/sched-001/"))
459            .and(body_string_contains("\"state\":\"deleted\""))
460            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
461                "id": "sched-001",
462                "state": "deleted"
463            })))
464            .mount(&server)
465            .await;
466        let (_dir, client) = client_for_server(&server.uri(), false).await;
467
468        let cancelled = client
469            .cancel_recurring_investment("sched-001")
470            .await
471            .unwrap();
472
473        assert_eq!(cancelled.state.as_deref(), Some("deleted"));
474    }
475
476    #[tokio::test]
477    async fn cancel_recurring_investment_blocked_in_read_only_mode() {
478        let (_dir, client) = read_only_client().await;
479        let err = client
480            .cancel_recurring_investment("sched-001")
481            .await
482            .unwrap_err();
483        assert!(matches!(err, RhoodError::ReadOnlyMode));
484    }
485
486    #[tokio::test]
487    async fn get_next_investment_date_sends_frequency_and_start_date() {
488        let server = MockServer::start().await;
489        Mock::given(method("GET"))
490            .and(path("/recurring_schedules/equity/next_investment_date/"))
491            .and(query_param("frequency", "monthly"))
492            .and(query_param("start_date", "2026-06-01"))
493            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
494                "frequency": "monthly",
495                "next_investment_date": "2026-06-01",
496                "start_date": "2026-06-01"
497            })))
498            .mount(&server)
499            .await;
500        let (_dir, client) = client_for_server(&server.uri(), true).await;
501
502        let next = client
503            .get_next_investment_date(RecurringFrequency::Monthly, "2026-06-01")
504            .await
505            .unwrap();
506
507        assert_eq!(next.next_investment_date.as_deref(), Some("2026-06-01"));
508        assert_eq!(next.frequency.as_deref(), Some("monthly"));
509    }
510
511    #[tokio::test]
512    async fn get_recurring_investments_propagates_server_error() {
513        let server = MockServer::start().await;
514        Mock::given(method("GET"))
515            .and(path("/recurring_schedules/"))
516            .respond_with(ResponseTemplate::new(500))
517            .mount(&server)
518            .await;
519        let (_dir, client) = client_for_server(&server.uri(), true).await;
520
521        let result: Result<Vec<RecurringInvestment>> = client.get_recurring_investments().await;
522
523        assert!(result.is_err());
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use crate::models::recurring::{MoneyAmount, RecurringInvestment};
530
531    #[test]
532    fn recurring_investment_deserializes_full() {
533        let json = r#"{
534            "id": "sched-001",
535            "account_number": "ABC123",
536            "amount": {"amount": "10.00", "currency_code": "USD"},
537            "frequency": "weekly",
538            "start_date": "2026-04-07",
539            "state": "active",
540            "investment_asset": {
541                "asset_id": "inst-001",
542                "asset_symbol": "TSLA",
543                "asset_type": "equity"
544            },
545            "created_at": "2026-04-01T00:00:00Z",
546            "updated_at": "2026-04-01T00:00:00Z"
547        }"#;
548        let recurring: RecurringInvestment = serde_json::from_str(json).unwrap();
549        assert_eq!(recurring.id.as_deref(), Some("sched-001"));
550        assert_eq!(recurring.frequency.as_deref(), Some("weekly"));
551        assert_eq!(recurring.state.as_deref(), Some("active"));
552        let amount = recurring.amount.unwrap();
553        assert_eq!(amount.amount, "10.00");
554        assert_eq!(amount.currency_code, "USD");
555        let asset = recurring.investment_asset.unwrap();
556        assert_eq!(asset.asset_symbol.as_deref(), Some("TSLA"));
557    }
558
559    #[test]
560    fn money_amount_serializes_round_trip() {
561        let money = MoneyAmount {
562            amount: "25.50".to_string(),
563            currency_code: "USD".to_string(),
564        };
565        let json = serde_json::to_string(&money).unwrap();
566        let round_tripped: MoneyAmount = serde_json::from_str(&json).unwrap();
567        assert_eq!(round_tripped.amount, "25.50");
568        assert_eq!(round_tripped.currency_code, "USD");
569    }
570}