Skip to main content

rhood_core/models/
recurring.rs

1//! Recurring investment model types.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// How often a recurring investment should run.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
9#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
10#[serde(rename_all = "snake_case")]
11pub enum RecurringFrequency {
12    /// Every week.
13    Weekly,
14    /// Every two weeks.
15    Biweekly,
16    /// Every month.
17    Monthly,
18}
19
20impl fmt::Display for RecurringFrequency {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Weekly => formatter.write_str("weekly"),
24            Self::Biweekly => formatter.write_str("biweekly"),
25            Self::Monthly => formatter.write_str("monthly"),
26        }
27    }
28}
29
30/// Where the funds for a recurring investment come from.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
32#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
33#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
34#[serde(rename_all = "snake_case")]
35pub enum RecurringSource {
36    /// Use available buying power in the brokerage account.
37    #[default]
38    BuyingPower,
39    /// Pull funds from the linked ACH bank account.
40    Ach,
41}
42
43impl fmt::Display for RecurringSource {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::BuyingPower => formatter.write_str("buying_power"),
47            Self::Ach => formatter.write_str("ach"),
48        }
49    }
50}
51
52/// Lifecycle state of a recurring investment schedule.
53///
54/// `Deleted` is an internal sentinel set by `cancel_recurring_investment`;
55/// it is hidden from the CLI (`#[clap(skip)]`) - callers cancel via the
56/// dedicated `recurring cancel` subcommand rather than `--state deleted`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
59#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
60#[serde(rename_all = "snake_case")]
61pub enum RecurringState {
62    /// Schedule is running.
63    Active,
64    /// Schedule is paused (no investments execute, but the schedule persists).
65    Paused,
66    /// Schedule has been cancelled. Internal use only.
67    #[cfg_attr(feature = "clap", clap(skip))]
68    Deleted,
69}
70
71impl fmt::Display for RecurringState {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Active => formatter.write_str("active"),
75            Self::Paused => formatter.write_str("paused"),
76            Self::Deleted => formatter.write_str("deleted"),
77        }
78    }
79}
80
81/// A money amount with currency code, as used in Robinhood recurring investment payloads.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MoneyAmount {
84    /// Dollar amount as a string (e.g., "10.00").
85    pub amount: String,
86    /// Currency code (e.g., "USD").
87    pub currency_code: String,
88}
89
90/// An investment asset reference within a recurring schedule.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct InvestmentAsset {
93    /// Unique asset identifier.
94    pub asset_id: Option<String>,
95    /// Ticker symbol (e.g., "TSLA").
96    pub asset_symbol: Option<String>,
97    /// Asset type (e.g., "equity", "crypto").
98    pub asset_type: Option<String>,
99}
100
101/// A recurring investment schedule from Robinhood.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct RecurringInvestment {
104    /// Unique schedule identifier.
105    pub id: Option<String>,
106    /// Account number the schedule belongs to.
107    pub account_number: Option<String>,
108    /// Investment amount per recurrence.
109    pub amount: Option<MoneyAmount>,
110    /// Recurrence frequency (e.g., "weekly", "biweekly", "monthly").
111    pub frequency: Option<String>,
112    /// Date when the schedule starts or next runs.
113    pub start_date: Option<String>,
114    /// Schedule state (e.g., "active", "paused", "deleted").
115    pub state: Option<String>,
116    /// The asset being invested in.
117    pub investment_asset: Option<InvestmentAsset>,
118    /// Timestamp when the schedule was created.
119    pub created_at: Option<String>,
120    /// Timestamp when the schedule was last updated.
121    pub updated_at: Option<String>,
122}
123
124/// Request to create a new recurring investment schedule.
125#[derive(Debug, Clone)]
126pub struct CreateRecurringRequest {
127    /// Ticker symbol (e.g., "TSLA").
128    pub symbol: String,
129    /// Dollar amount per recurrence.
130    pub amount: f64,
131    /// Recurrence frequency.
132    pub frequency: RecurringFrequency,
133    /// Start date in YYYY-MM-DD format.
134    pub start_date: String,
135    /// Source of funds.
136    pub source_of_funds: RecurringSource,
137}
138
139/// Payload sent to the Robinhood API to create a recurring investment.
140#[derive(Debug, Clone, Serialize)]
141pub(crate) struct CreateRecurringPayload {
142    pub account_number: String,
143    pub amount: MoneyAmount,
144    pub frequency: String,
145    pub start_date: String,
146    pub investment_asset: CreateRecurringAssetPayload,
147    pub source_of_funds: String,
148    pub ref_id: String,
149    pub is_backup_ach_enabled: bool,
150}
151
152/// Asset reference in the create payload.
153#[derive(Debug, Clone, Serialize)]
154pub(crate) struct CreateRecurringAssetPayload {
155    pub asset_id: String,
156    pub asset_symbol: String,
157    pub asset_type: String,
158}
159
160/// Request to update an existing recurring investment schedule.
161#[derive(Debug, Clone)]
162pub struct UpdateRecurringRequest {
163    /// New dollar amount (optional).
164    pub amount: Option<f64>,
165    /// New frequency (optional).
166    pub frequency: Option<RecurringFrequency>,
167    /// New state (optional; `Deleted` is internal - use the cancel endpoint instead).
168    pub state: Option<RecurringState>,
169    /// New start date (optional).
170    pub start_date: Option<String>,
171}
172
173/// Payload sent to the Robinhood API to update a recurring investment.
174#[derive(Debug, Clone, Serialize)]
175pub(crate) struct UpdateRecurringPayload {
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub amount: Option<MoneyAmount>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub frequency: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub state: Option<String>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub start_date: Option<String>,
184}
185
186/// Response from the next-investment-date lookup endpoint.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct NextInvestmentDate {
189    /// Recurrence frequency echoed back by the API.
190    pub frequency: Option<String>,
191    /// The next scheduled investment date in YYYY-MM-DD format.
192    pub next_investment_date: Option<String>,
193    /// The start date echoed back by the API.
194    pub start_date: Option<String>,
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn recurring_frequency_serializes_to_wire_form() {
203        assert_eq!(
204            serde_json::to_string(&RecurringFrequency::Weekly).unwrap(),
205            "\"weekly\""
206        );
207        assert_eq!(
208            serde_json::to_string(&RecurringFrequency::Biweekly).unwrap(),
209            "\"biweekly\""
210        );
211        assert_eq!(
212            serde_json::to_string(&RecurringFrequency::Monthly).unwrap(),
213            "\"monthly\""
214        );
215    }
216
217    #[test]
218    fn recurring_frequency_roundtrips_all_variants() {
219        for variant in [
220            RecurringFrequency::Weekly,
221            RecurringFrequency::Biweekly,
222            RecurringFrequency::Monthly,
223        ] {
224            let wire = serde_json::to_string(&variant).unwrap();
225            let back: RecurringFrequency = serde_json::from_str(&wire).unwrap();
226            assert_eq!(back, variant);
227        }
228    }
229
230    #[test]
231    fn recurring_frequency_display_matches_wire() {
232        assert_eq!(RecurringFrequency::Weekly.to_string(), "weekly");
233        assert_eq!(RecurringFrequency::Biweekly.to_string(), "biweekly");
234        assert_eq!(RecurringFrequency::Monthly.to_string(), "monthly");
235    }
236
237    #[test]
238    fn recurring_source_serializes_to_wire_form() {
239        assert_eq!(
240            serde_json::to_string(&RecurringSource::BuyingPower).unwrap(),
241            "\"buying_power\""
242        );
243        assert_eq!(
244            serde_json::to_string(&RecurringSource::Ach).unwrap(),
245            "\"ach\""
246        );
247    }
248
249    #[test]
250    fn recurring_source_default_is_buying_power() {
251        assert_eq!(RecurringSource::default(), RecurringSource::BuyingPower);
252    }
253
254    #[test]
255    fn recurring_source_display_matches_wire() {
256        assert_eq!(RecurringSource::BuyingPower.to_string(), "buying_power");
257        assert_eq!(RecurringSource::Ach.to_string(), "ach");
258    }
259
260    #[test]
261    fn recurring_state_serializes_to_wire_form() {
262        assert_eq!(
263            serde_json::to_string(&RecurringState::Active).unwrap(),
264            "\"active\""
265        );
266        assert_eq!(
267            serde_json::to_string(&RecurringState::Paused).unwrap(),
268            "\"paused\""
269        );
270        assert_eq!(
271            serde_json::to_string(&RecurringState::Deleted).unwrap(),
272            "\"deleted\""
273        );
274    }
275
276    #[test]
277    fn recurring_state_roundtrips_all_variants() {
278        for variant in [
279            RecurringState::Active,
280            RecurringState::Paused,
281            RecurringState::Deleted,
282        ] {
283            let wire = serde_json::to_string(&variant).unwrap();
284            let back: RecurringState = serde_json::from_str(&wire).unwrap();
285            assert_eq!(back, variant);
286        }
287    }
288
289    #[test]
290    fn recurring_state_display_matches_wire() {
291        assert_eq!(RecurringState::Active.to_string(), "active");
292        assert_eq!(RecurringState::Paused.to_string(), "paused");
293        assert_eq!(RecurringState::Deleted.to_string(), "deleted");
294    }
295
296    #[test]
297    fn next_investment_date_parses_real_payload() {
298        use crate::models::recurring::NextInvestmentDate;
299        let json = r#"{"frequency":"weekly","next_investment_date":"2026-06-01","start_date":"2026-06-01"}"#;
300        let parsed: NextInvestmentDate = serde_json::from_str(json).unwrap();
301        assert_eq!(parsed.next_investment_date.as_deref(), Some("2026-06-01"));
302        assert_eq!(parsed.frequency.as_deref(), Some("weekly"));
303        assert_eq!(parsed.start_date.as_deref(), Some("2026-06-01"));
304    }
305}