use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RecurringFrequency {
Weekly,
Biweekly,
Monthly,
}
impl fmt::Display for RecurringFrequency {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Weekly => formatter.write_str("weekly"),
Self::Biweekly => formatter.write_str("biweekly"),
Self::Monthly => formatter.write_str("monthly"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RecurringSource {
#[default]
BuyingPower,
Ach,
}
impl fmt::Display for RecurringSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BuyingPower => formatter.write_str("buying_power"),
Self::Ach => formatter.write_str("ach"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RecurringState {
Active,
Paused,
#[cfg_attr(feature = "clap", clap(skip))]
Deleted,
}
impl fmt::Display for RecurringState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Active => formatter.write_str("active"),
Self::Paused => formatter.write_str("paused"),
Self::Deleted => formatter.write_str("deleted"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MoneyAmount {
pub amount: String,
pub currency_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvestmentAsset {
pub asset_id: Option<String>,
pub asset_symbol: Option<String>,
pub asset_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurringInvestment {
pub id: Option<String>,
pub account_number: Option<String>,
pub amount: Option<MoneyAmount>,
pub frequency: Option<String>,
pub start_date: Option<String>,
pub state: Option<String>,
pub investment_asset: Option<InvestmentAsset>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CreateRecurringRequest {
pub symbol: String,
pub amount: f64,
pub frequency: RecurringFrequency,
pub start_date: String,
pub source_of_funds: RecurringSource,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct CreateRecurringPayload {
pub account_number: String,
pub amount: MoneyAmount,
pub frequency: String,
pub start_date: String,
pub investment_asset: CreateRecurringAssetPayload,
pub source_of_funds: String,
pub ref_id: String,
pub is_backup_ach_enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct CreateRecurringAssetPayload {
pub asset_id: String,
pub asset_symbol: String,
pub asset_type: String,
}
#[derive(Debug, Clone)]
pub struct UpdateRecurringRequest {
pub amount: Option<f64>,
pub frequency: Option<RecurringFrequency>,
pub state: Option<RecurringState>,
pub start_date: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct UpdateRecurringPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<MoneyAmount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextInvestmentDate {
pub frequency: Option<String>,
pub next_investment_date: Option<String>,
pub start_date: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recurring_frequency_serializes_to_wire_form() {
assert_eq!(
serde_json::to_string(&RecurringFrequency::Weekly).unwrap(),
"\"weekly\""
);
assert_eq!(
serde_json::to_string(&RecurringFrequency::Biweekly).unwrap(),
"\"biweekly\""
);
assert_eq!(
serde_json::to_string(&RecurringFrequency::Monthly).unwrap(),
"\"monthly\""
);
}
#[test]
fn recurring_frequency_roundtrips_all_variants() {
for variant in [
RecurringFrequency::Weekly,
RecurringFrequency::Biweekly,
RecurringFrequency::Monthly,
] {
let wire = serde_json::to_string(&variant).unwrap();
let back: RecurringFrequency = serde_json::from_str(&wire).unwrap();
assert_eq!(back, variant);
}
}
#[test]
fn recurring_frequency_display_matches_wire() {
assert_eq!(RecurringFrequency::Weekly.to_string(), "weekly");
assert_eq!(RecurringFrequency::Biweekly.to_string(), "biweekly");
assert_eq!(RecurringFrequency::Monthly.to_string(), "monthly");
}
#[test]
fn recurring_source_serializes_to_wire_form() {
assert_eq!(
serde_json::to_string(&RecurringSource::BuyingPower).unwrap(),
"\"buying_power\""
);
assert_eq!(
serde_json::to_string(&RecurringSource::Ach).unwrap(),
"\"ach\""
);
}
#[test]
fn recurring_source_default_is_buying_power() {
assert_eq!(RecurringSource::default(), RecurringSource::BuyingPower);
}
#[test]
fn recurring_source_display_matches_wire() {
assert_eq!(RecurringSource::BuyingPower.to_string(), "buying_power");
assert_eq!(RecurringSource::Ach.to_string(), "ach");
}
#[test]
fn recurring_state_serializes_to_wire_form() {
assert_eq!(
serde_json::to_string(&RecurringState::Active).unwrap(),
"\"active\""
);
assert_eq!(
serde_json::to_string(&RecurringState::Paused).unwrap(),
"\"paused\""
);
assert_eq!(
serde_json::to_string(&RecurringState::Deleted).unwrap(),
"\"deleted\""
);
}
#[test]
fn recurring_state_roundtrips_all_variants() {
for variant in [
RecurringState::Active,
RecurringState::Paused,
RecurringState::Deleted,
] {
let wire = serde_json::to_string(&variant).unwrap();
let back: RecurringState = serde_json::from_str(&wire).unwrap();
assert_eq!(back, variant);
}
}
#[test]
fn recurring_state_display_matches_wire() {
assert_eq!(RecurringState::Active.to_string(), "active");
assert_eq!(RecurringState::Paused.to_string(), "paused");
assert_eq!(RecurringState::Deleted.to_string(), "deleted");
}
#[test]
fn next_investment_date_parses_real_payload() {
use crate::models::recurring::NextInvestmentDate;
let json = r#"{"frequency":"weekly","next_investment_date":"2026-06-01","start_date":"2026-06-01"}"#;
let parsed: NextInvestmentDate = serde_json::from_str(json).unwrap();
assert_eq!(parsed.next_investment_date.as_deref(), Some("2026-06-01"));
assert_eq!(parsed.frequency.as_deref(), Some("weekly"));
assert_eq!(parsed.start_date.as_deref(), Some("2026-06-01"));
}
}