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 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 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 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 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 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#[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 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 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 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 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 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 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}