use serde::Serialize;
#[derive(Debug, Clone)]
pub struct SubscriptionAuditItem {
pub merchant: String,
pub stream_amount: f64,
pub frequency: String,
pub is_approximate: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct SubscriptionEntry {
pub merchant: String,
pub monthly_amount: f64,
pub annualized_amount: f64,
pub cadence: String,
pub approximate: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct AuditResult {
pub subscriptions: Vec<SubscriptionEntry>,
pub total_monthly: f64,
pub total_annual: f64,
}
fn cadence_to_monthly_factor(frequency: &str) -> f64 {
match frequency.trim().to_lowercase().as_str() {
"monthly" => 1.0,
"yearly" | "annually" => 1.0 / 12.0,
"weekly" => 52.0 / 12.0,
"biweekly" | "every_two_weeks" => 26.0 / 12.0,
"quarterly" | "every_three_months" => 1.0 / 3.0,
"semiannual" | "twice_a_year" => 1.0 / 6.0,
_ => 1.0,
}
}
pub fn compute_subscription_audit(items: &[SubscriptionAuditItem]) -> AuditResult {
let mut subscriptions: Vec<SubscriptionEntry> = items
.iter()
.filter(|item| item.stream_amount < 0.0) .map(|item| {
let factor = cadence_to_monthly_factor(&item.frequency);
let monthly_amount = item.stream_amount.abs() * factor;
let annualized_amount = monthly_amount * 12.0;
SubscriptionEntry {
merchant: item.merchant.clone(),
monthly_amount,
annualized_amount,
cadence: item.frequency.clone(),
approximate: item.is_approximate,
}
})
.collect();
subscriptions.sort_by(|a, b| {
b.annualized_amount
.partial_cmp(&a.annualized_amount)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.merchant.cmp(&b.merchant))
.then_with(|| a.cadence.cmp(&b.cadence))
});
let total_monthly: f64 = subscriptions.iter().map(|s| s.monthly_amount).sum();
let total_annual: f64 = subscriptions.iter().map(|s| s.annualized_amount).sum();
AuditResult {
subscriptions,
total_monthly,
total_annual,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn monthly(merchant: &str, amount: f64) -> SubscriptionAuditItem {
SubscriptionAuditItem {
merchant: merchant.to_string(),
stream_amount: -amount.abs(), frequency: "monthly".to_string(),
is_approximate: false,
}
}
fn item_with_cadence(
merchant: &str,
amount: f64,
frequency: &str,
is_approximate: bool,
) -> SubscriptionAuditItem {
SubscriptionAuditItem {
merchant: merchant.to_string(),
stream_amount: -amount.abs(),
frequency: frequency.to_string(),
is_approximate,
}
}
fn income(merchant: &str, amount: f64) -> SubscriptionAuditItem {
SubscriptionAuditItem {
merchant: merchant.to_string(),
stream_amount: amount.abs(), frequency: "monthly".to_string(),
is_approximate: false,
}
}
#[test]
fn monthly_subscription_monthly_amount_equals_stream_magnitude() {
let items = vec![monthly("StreamBundle", 50.0)];
let result = compute_subscription_audit(&items);
assert_eq!(result.subscriptions.len(), 1);
assert!(
(result.subscriptions[0].monthly_amount - 50.0).abs() < 0.01,
"expected monthly_amount 50.00, got {}",
result.subscriptions[0].monthly_amount
);
}
#[test]
fn monthly_subscription_annualized_is_monthly_times_12() {
let items = vec![monthly("StreamBundle", 50.0)];
let result = compute_subscription_audit(&items);
assert!(
(result.subscriptions[0].annualized_amount - 600.0).abs() < 0.01,
"expected annualized_amount 600.00, got {}",
result.subscriptions[0].annualized_amount
);
}
#[test]
fn yearly_subscription_monthly_amount_is_annual_divided_by_12() {
let items = vec![item_with_cadence("NewsCo", 120.0, "yearly", false)];
let result = compute_subscription_audit(&items);
assert_eq!(result.subscriptions.len(), 1);
assert!(
(result.subscriptions[0].monthly_amount - 10.0).abs() < 0.01,
"expected monthly_amount 10.00 for yearly 120, got {}",
result.subscriptions[0].monthly_amount
);
assert!(
(result.subscriptions[0].annualized_amount - 120.0).abs() < 0.01,
"expected annualized_amount 120.00 for yearly 120, got {}",
result.subscriptions[0].annualized_amount
);
}
#[test]
fn weekly_subscription_monthly_amount_normalizes_to_52_over_12() {
let items = vec![item_with_cadence("WeeklyService", 10.0, "weekly", false)];
let result = compute_subscription_audit(&items);
let expected_monthly = 10.0 * 52.0 / 12.0;
assert!(
(result.subscriptions[0].monthly_amount - expected_monthly).abs() < 0.01,
"expected monthly_amount {:.4}, got {}",
expected_monthly,
result.subscriptions[0].monthly_amount
);
}
#[test]
fn quarterly_subscription_monthly_amount_is_quarterly_divided_by_3() {
let items = vec![item_with_cadence("QuarterlyMag", 90.0, "quarterly", false)];
let result = compute_subscription_audit(&items);
assert!(
(result.subscriptions[0].monthly_amount - 30.0).abs() < 0.01,
"expected monthly_amount 30.00 for quarterly 90, got {}",
result.subscriptions[0].monthly_amount
);
}
#[test]
fn subscriptions_ranked_by_annualized_cost_descending() {
let items = vec![
item_with_cadence("NewsCo", 120.0, "yearly", false),
monthly("StreamBundle", 50.0),
];
let result = compute_subscription_audit(&items);
assert_eq!(result.subscriptions.len(), 2);
assert_eq!(
result.subscriptions[0].merchant, "StreamBundle",
"StreamBundle (600/year) must be ranked above NewsCo (120/year)"
);
assert_eq!(result.subscriptions[1].merchant, "NewsCo");
}
#[test]
fn totals_sum_all_monthly_and_annual_costs() {
let items = vec![
monthly("StreamBundle", 50.0), item_with_cadence("NewsCo", 120.0, "yearly", false), ];
let result = compute_subscription_audit(&items);
assert!(
(result.total_monthly - 60.0).abs() < 0.01,
"expected total_monthly 60.00, got {}",
result.total_monthly
);
assert!(
(result.total_annual - 720.0).abs() < 0.01,
"expected total_annual 720.00, got {}",
result.total_annual
);
}
#[test]
fn income_streams_excluded_from_audit() {
let items = vec![income("Employer", 5000.0), monthly("StreamBundle", 50.0)];
let result = compute_subscription_audit(&items);
assert_eq!(
result.subscriptions.len(),
1,
"income stream must not appear in subscriptions"
);
assert_eq!(result.subscriptions[0].merchant, "StreamBundle");
}
#[test]
fn approximate_streams_included_and_flagged() {
let items = vec![item_with_cadence("ElectricUtil", 120.0, "monthly", true)];
let result = compute_subscription_audit(&items);
assert_eq!(result.subscriptions.len(), 1);
assert!(
result.subscriptions[0].approximate,
"approximate stream must be flagged approximate=true"
);
assert_eq!(result.subscriptions[0].merchant, "ElectricUtil");
}
#[test]
fn empty_items_produce_zero_totals() {
let result = compute_subscription_audit(&[]);
assert!(result.subscriptions.is_empty());
assert_eq!(result.total_monthly, 0.0);
assert_eq!(result.total_annual, 0.0);
}
#[test]
fn unknown_cadence_treated_as_monthly() {
let items = vec![item_with_cadence(
"UnknownService",
25.0,
"fortnightly",
false,
)];
let result = compute_subscription_audit(&items);
assert!(
(result.subscriptions[0].monthly_amount - 25.0).abs() < 0.01,
"unknown cadence must default to monthly factor (1.0), got {}",
result.subscriptions[0].monthly_amount
);
}
#[test]
fn cadence_preserved_in_subscription_entry() {
let items = vec![item_with_cadence("NewsCo", 120.0, "yearly", false)];
let result = compute_subscription_audit(&items);
assert_eq!(result.subscriptions[0].cadence, "yearly");
}
#[test]
fn biweekly_subscription_monthly_amount_normalizes_to_26_over_12() {
let items = vec![item_with_cadence(
"BiweeklyService",
100.0,
"biweekly",
false,
)];
let result = compute_subscription_audit(&items);
let expected_monthly = 100.0 * 26.0 / 12.0;
assert!(
(result.subscriptions[0].monthly_amount - expected_monthly).abs() < 0.01,
"expected monthly_amount {:.4}, got {}",
expected_monthly,
result.subscriptions[0].monthly_amount
);
}
}