use chrono::{DateTime, Duration, Months, Utc};
use thiserror::Error;
use crate::{BillingPeriod, BillingPeriodError, SubscriptionPeriodRule};
pub fn gateway_state_is_approved(value: &str) -> bool {
matches!(
normalized_gateway_state_value(value).as_str(),
"approved"
| "complete"
| "completed"
| "captured"
| "success"
| "successful"
| "pendingsettlement"
)
}
pub fn gateway_response_is_approved(value: Option<&str>) -> bool {
let Some(normalized) = value.map(normalized_gateway_state_value) else {
return false;
};
matches!(normalized.as_str(), "1" | "100") || gateway_state_is_approved(&normalized)
}
fn normalized_gateway_state_value(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.chars()
.filter(|character| {
!character.is_ascii_whitespace() && *character != '_' && *character != '-'
})
.collect()
}
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum BillingPeriodPolicyError {
#[error("billing period overflowed the supported date range")]
Overflow,
#[error(transparent)]
InvalidPeriod(#[from] BillingPeriodError),
}
pub fn next_billing_period(
start_at: DateTime<Utc>,
rule: SubscriptionPeriodRule,
) -> Result<BillingPeriod, BillingPeriodPolicyError> {
let next_end = match rule {
SubscriptionPeriodRule::FixedDays(days) => start_at
.checked_add_signed(Duration::days(i64::from(days.get())))
.ok_or(BillingPeriodPolicyError::Overflow)?,
SubscriptionPeriodRule::CalendarMonths(months) => start_at
.checked_add_months(Months::new(u32::from(months.get())))
.ok_or(BillingPeriodPolicyError::Overflow)?,
};
BillingPeriod::new(start_at, next_end).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Timelike};
use super::*;
#[test]
fn approved_state_matching_preserves_processor_spellings() {
for value in [
"approved",
"complete",
"completed",
"captured",
"success",
"successful",
"pending settlement",
"pending_settlement",
"pending-settlement",
"Pending Settlement",
] {
assert!(gateway_state_is_approved(value), "missed {value}");
}
assert!(gateway_response_is_approved(Some("1")));
assert!(gateway_response_is_approved(Some("100")));
assert!(!gateway_response_is_approved(Some("200")));
assert!(!gateway_response_is_approved(None));
}
#[test]
fn monthly_policy_repeats_clamping_from_the_previous_boundary() {
let january = Utc
.with_ymd_and_hms(2025, 1, 31, 12, 34, 56)
.unwrap()
.with_nanosecond(789_000_000)
.unwrap();
let monthly = SubscriptionPeriodRule::calendar_months(1).unwrap();
let february = next_billing_period(january, monthly).unwrap();
assert_eq!(
*february.end_at(),
Utc.with_ymd_and_hms(2025, 2, 28, 12, 34, 56)
.unwrap()
.with_nanosecond(789_000_000)
.unwrap()
);
let march = next_billing_period(*february.end_at(), monthly).unwrap();
assert_eq!(
*march.end_at(),
Utc.with_ymd_and_hms(2025, 3, 28, 12, 34, 56)
.unwrap()
.with_nanosecond(789_000_000)
.unwrap()
);
}
#[test]
fn fixed_day_policy_adds_exact_utc_days() {
let start = Utc.with_ymd_and_hms(2026, 3, 27, 12, 0, 0).unwrap();
let period =
next_billing_period(start, SubscriptionPeriodRule::fixed_days(7).unwrap()).unwrap();
assert_eq!(*period.end_at(), start + Duration::days(7));
}
#[test]
fn multi_month_policy_clamps_from_each_previous_boundary() {
let january = Utc.with_ymd_and_hms(2025, 1, 31, 0, 0, 0).unwrap();
let two_months =
next_billing_period(january, SubscriptionPeriodRule::calendar_months(2).unwrap())
.unwrap();
assert_eq!(
*two_months.end_at(),
Utc.with_ymd_and_hms(2025, 3, 31, 0, 0, 0).unwrap()
);
let next = next_billing_period(
*two_months.end_at(),
SubscriptionPeriodRule::calendar_months(2).unwrap(),
)
.unwrap();
assert_eq!(
*next.end_at(),
Utc.with_ymd_and_hms(2025, 5, 31, 0, 0, 0).unwrap()
);
}
#[test]
fn billing_period_overflow_is_typed() {
let start = DateTime::<Utc>::MAX_UTC;
assert_eq!(
next_billing_period(start, SubscriptionPeriodRule::fixed_days(1).unwrap()),
Err(BillingPeriodPolicyError::Overflow)
);
}
#[test]
fn leap_year_clamping_is_equally_repeated() {
let january = Utc.with_ymd_and_hms(2024, 1, 31, 0, 0, 0).unwrap();
let monthly = SubscriptionPeriodRule::calendar_months(1).unwrap();
let february = next_billing_period(january, monthly).unwrap();
assert_eq!(
*february.end_at(),
Utc.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap()
);
let march = next_billing_period(*february.end_at(), monthly).unwrap();
assert_eq!(
*march.end_at(),
Utc.with_ymd_and_hms(2024, 3, 29, 0, 0, 0).unwrap()
);
}
}