use chrono::{DateTime, Months, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{A2AError, A2AResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BillingInterval {
Weekly,
Biweekly,
Monthly,
Quarterly,
Annual,
}
impl std::fmt::Display for BillingInterval {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Weekly => write!(f, "weekly"),
Self::Biweekly => write!(f, "biweekly"),
Self::Monthly => write!(f, "monthly"),
Self::Quarterly => write!(f, "quarterly"),
Self::Annual => write!(f, "annual"),
}
}
}
impl BillingInterval {
pub fn from_str_checked(s: &str) -> A2AResult<Self> {
match s {
"weekly" => Ok(Self::Weekly),
"biweekly" => Ok(Self::Biweekly),
"monthly" => Ok(Self::Monthly),
"quarterly" => Ok(Self::Quarterly),
"annual" => Ok(Self::Annual),
other => Err(A2AError::InvalidBillingInterval(other.to_string())),
}
}
pub const ALL: &'static [Self] =
&[Self::Weekly, Self::Biweekly, Self::Monthly, Self::Quarterly, Self::Annual];
}
pub fn compute_next_billing_date(
from: DateTime<Utc>,
interval: BillingInterval,
) -> A2AResult<DateTime<Utc>> {
let result = match interval {
BillingInterval::Weekly => from + chrono::Duration::weeks(1),
BillingInterval::Biweekly => from + chrono::Duration::weeks(2),
BillingInterval::Monthly => from
.checked_add_months(Months::new(1))
.ok_or_else(|| A2AError::Internal("date overflow adding 1 month".into()))?,
BillingInterval::Quarterly => from
.checked_add_months(Months::new(3))
.ok_or_else(|| A2AError::Internal("date overflow adding 3 months".into()))?,
BillingInterval::Annual => from
.checked_add_months(Months::new(12))
.ok_or_else(|| A2AError::Internal("date overflow adding 12 months".into()))?,
};
Ok(result)
}
#[must_use]
pub fn compute_trial_end(from: DateTime<Utc>, trial_days: u32) -> DateTime<Utc> {
from + chrono::Duration::days(i64::from(trial_days))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, TimeZone};
fn dt(y: i32, m: u32, d: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
}
#[test]
fn weekly_adds_7_days() {
let start = dt(2026, 1, 1);
let next = compute_next_billing_date(start, BillingInterval::Weekly).unwrap();
assert_eq!(next.day(), 8);
assert_eq!(next.month(), 1);
}
#[test]
fn biweekly_adds_14_days() {
let start = dt(2026, 1, 1);
let next = compute_next_billing_date(start, BillingInterval::Biweekly).unwrap();
assert_eq!(next.day(), 15);
assert_eq!(next.month(), 1);
}
#[test]
fn monthly_adds_one_month() {
let start = dt(2026, 1, 15);
let next = compute_next_billing_date(start, BillingInterval::Monthly).unwrap();
assert_eq!(next.month(), 2);
assert_eq!(next.day(), 15);
}
#[test]
fn monthly_end_of_month_jan_to_feb() {
let start = dt(2026, 1, 31);
let next = compute_next_billing_date(start, BillingInterval::Monthly).unwrap();
assert_eq!(next.month(), 2);
assert_eq!(next.day(), 28);
}
#[test]
fn quarterly_adds_three_months() {
let start = dt(2026, 1, 15);
let next = compute_next_billing_date(start, BillingInterval::Quarterly).unwrap();
assert_eq!(next.month(), 4);
assert_eq!(next.day(), 15);
}
#[test]
fn annual_adds_one_year() {
let start = dt(2026, 3, 10);
let next = compute_next_billing_date(start, BillingInterval::Annual).unwrap();
assert_eq!(next.year(), 2027);
assert_eq!(next.month(), 3);
assert_eq!(next.day(), 10);
}
#[test]
fn annual_leap_year_feb_29() {
let start = dt(2028, 2, 29);
let next = compute_next_billing_date(start, BillingInterval::Annual).unwrap();
assert_eq!(next.year(), 2029);
assert_eq!(next.month(), 2);
assert_eq!(next.day(), 28);
}
#[test]
fn weekly_crosses_month_boundary() {
let start = dt(2026, 1, 28);
let next = compute_next_billing_date(start, BillingInterval::Weekly).unwrap();
assert_eq!(next.month(), 2);
assert_eq!(next.day(), 4);
}
#[test]
fn quarterly_crosses_year_boundary() {
let start = dt(2026, 11, 15);
let next = compute_next_billing_date(start, BillingInterval::Quarterly).unwrap();
assert_eq!(next.year(), 2027);
assert_eq!(next.month(), 2);
}
#[test]
fn billing_interval_from_str_valid() {
assert_eq!(BillingInterval::from_str_checked("weekly").unwrap(), BillingInterval::Weekly);
assert_eq!(BillingInterval::from_str_checked("monthly").unwrap(), BillingInterval::Monthly);
assert_eq!(BillingInterval::from_str_checked("annual").unwrap(), BillingInterval::Annual);
}
#[test]
fn billing_interval_from_str_invalid() {
let err = BillingInterval::from_str_checked("daily").unwrap_err();
assert!(matches!(err, A2AError::InvalidBillingInterval(_)));
}
#[test]
fn billing_interval_display() {
assert_eq!(BillingInterval::Weekly.to_string(), "weekly");
assert_eq!(BillingInterval::Biweekly.to_string(), "biweekly");
assert_eq!(BillingInterval::Monthly.to_string(), "monthly");
assert_eq!(BillingInterval::Quarterly.to_string(), "quarterly");
assert_eq!(BillingInterval::Annual.to_string(), "annual");
}
#[test]
fn billing_interval_all_has_five_entries() {
assert_eq!(BillingInterval::ALL.len(), 5);
}
#[test]
fn trial_end_14_days() {
let start = dt(2026, 1, 1);
let end = compute_trial_end(start, 14);
assert_eq!(end.day(), 15);
assert_eq!(end.month(), 1);
}
#[test]
fn trial_end_zero_days() {
let start = dt(2026, 1, 1);
let end = compute_trial_end(start, 0);
assert_eq!(end, start);
}
#[test]
fn trial_end_crosses_month() {
let start = dt(2026, 1, 25);
let end = compute_trial_end(start, 14);
assert_eq!(end.month(), 2);
assert_eq!(end.day(), 8);
}
}