1use serde::{Deserialize, Serialize};
4use std::fmt;
5
6#[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 Weekly,
14 Biweekly,
16 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#[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 #[default]
38 BuyingPower,
39 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#[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 Active,
64 Paused,
66 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MoneyAmount {
84 pub amount: String,
86 pub currency_code: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct InvestmentAsset {
93 pub asset_id: Option<String>,
95 pub asset_symbol: Option<String>,
97 pub asset_type: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct RecurringInvestment {
104 pub id: Option<String>,
106 pub account_number: Option<String>,
108 pub amount: Option<MoneyAmount>,
110 pub frequency: Option<String>,
112 pub start_date: Option<String>,
114 pub state: Option<String>,
116 pub investment_asset: Option<InvestmentAsset>,
118 pub created_at: Option<String>,
120 pub updated_at: Option<String>,
122}
123
124#[derive(Debug, Clone)]
126pub struct CreateRecurringRequest {
127 pub symbol: String,
129 pub amount: f64,
131 pub frequency: RecurringFrequency,
133 pub start_date: String,
135 pub source_of_funds: RecurringSource,
137}
138
139#[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#[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#[derive(Debug, Clone)]
162pub struct UpdateRecurringRequest {
163 pub amount: Option<f64>,
165 pub frequency: Option<RecurringFrequency>,
167 pub state: Option<RecurringState>,
169 pub start_date: Option<String>,
171}
172
173#[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#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct NextInvestmentDate {
189 pub frequency: Option<String>,
191 pub next_investment_date: Option<String>,
193 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}