use serde::Serialize;
#[derive(Debug, Clone)]
pub struct RecurringScanItem {
pub merchant: String,
#[allow(dead_code)]
pub stream_amount: f64,
#[allow(dead_code)]
pub actual_amount: f64,
pub amount_diff: f64,
pub is_approximate: bool,
pub is_past: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct ScanResult {
pub creeping_charges: Vec<CreepingCharge>,
pub upcoming_renewals: Vec<UpcomingRenewal>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct CreepingCharge {
pub merchant: String,
pub amount_change: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct UpcomingRenewal {
pub merchant: String,
}
pub fn compute_scan(items: &[RecurringScanItem]) -> ScanResult {
let creeping_charges = items
.iter()
.filter(|item| !item.is_approximate && item.amount_diff.abs() > f64::EPSILON)
.map(|item| CreepingCharge {
merchant: item.merchant.clone(),
amount_change: item.amount_diff,
})
.collect();
let upcoming_renewals = items
.iter()
.filter(|item| !item.is_past)
.map(|item| UpcomingRenewal {
merchant: item.merchant.clone(),
})
.collect();
ScanResult {
creeping_charges,
upcoming_renewals,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(
merchant: &str,
stream_amount: f64,
actual_amount: f64,
is_approximate: bool,
is_past: bool,
) -> RecurringScanItem {
RecurringScanItem {
merchant: merchant.to_string(),
stream_amount: -stream_amount.abs(),
actual_amount: -actual_amount.abs(),
amount_diff: actual_amount - stream_amount,
is_approximate,
is_past,
}
}
#[test]
fn price_increase_is_flagged_as_creeping() {
let items = vec![item("StreamingCo", 9.99, 13.99, false, false)];
let result = compute_scan(&items);
let names: Vec<&str> = result
.creeping_charges
.iter()
.map(|c| c.merchant.as_str())
.collect();
assert!(
names.contains(&"StreamingCo"),
"expected StreamingCo in creeping_charges"
);
}
#[test]
fn price_increase_amount_change_is_positive() {
let items = vec![item("StreamingCo", 9.99, 13.99, false, false)];
let result = compute_scan(&items);
let entry = result
.creeping_charges
.iter()
.find(|c| c.merchant == "StreamingCo")
.unwrap();
assert!(
(entry.amount_change - 4.0).abs() < 0.01,
"expected +4.00, got {}",
entry.amount_change
);
}
#[test]
fn price_decrease_is_flagged_as_creeping() {
let items = vec![item("MusicService", 10.99, 7.99, false, false)];
let result = compute_scan(&items);
let names: Vec<&str> = result
.creeping_charges
.iter()
.map(|c| c.merchant.as_str())
.collect();
assert!(
names.contains(&"MusicService"),
"expected MusicService in creeping_charges"
);
}
#[test]
fn price_decrease_amount_change_magnitude_is_correct() {
let items = vec![item("MusicService", 10.99, 7.99, false, false)];
let result = compute_scan(&items);
let entry = result
.creeping_charges
.iter()
.find(|c| c.merchant == "MusicService")
.unwrap();
assert!(
(entry.amount_change.abs() - 3.0).abs() < 0.01,
"expected |amount_change| 3.00, got {}",
entry.amount_change
);
}
#[test]
fn approximate_charge_is_not_flagged_as_creeping() {
let items = vec![item("ElectricUtil", 120.0, 134.50, true, false)];
let result = compute_scan(&items);
let names: Vec<&str> = result
.creeping_charges
.iter()
.map(|c| c.merchant.as_str())
.collect();
assert!(
!names.contains(&"ElectricUtil"),
"ElectricUtil must NOT be in creeping_charges"
);
}
#[test]
fn stable_charge_is_not_flagged_as_creeping() {
let items = vec![
item("CloudStorage", 2.99, 2.99, false, false),
item("NewsFeed", 14.99, 14.99, false, false),
];
let result = compute_scan(&items);
assert!(
result.creeping_charges.is_empty(),
"stable charges must not be flagged: {:?}",
result.creeping_charges
);
}
#[test]
fn stable_subscription_not_flagged_when_creeping_one_also_present() {
let items = vec![
item("StreamingCo", 9.99, 13.99, false, false),
item("GymMembership", 50.0, 50.0, false, false),
];
let result = compute_scan(&items);
let names: Vec<&str> = result
.creeping_charges
.iter()
.map(|c| c.merchant.as_str())
.collect();
assert!(
names.contains(&"StreamingCo"),
"StreamingCo must be flagged"
);
assert!(
!names.contains(&"GymMembership"),
"GymMembership must NOT be flagged"
);
}
#[test]
fn upcoming_renewals_excludes_past_items() {
let items = vec![
item("RentPayment", 1500.0, 1500.0, false, true), item("AnnualBackup", 99.0, 99.0, false, false), item("StreamingCo", 13.99, 13.99, false, false), ];
let result = compute_scan(&items);
let upcoming: Vec<&str> = result
.upcoming_renewals
.iter()
.map(|u| u.merchant.as_str())
.collect();
assert!(
upcoming.contains(&"AnnualBackup"),
"AnnualBackup must be upcoming"
);
assert!(
upcoming.contains(&"StreamingCo"),
"StreamingCo must be upcoming"
);
assert!(
!upcoming.contains(&"RentPayment"),
"RentPayment must NOT be upcoming (is_past=true)"
);
}
#[test]
fn empty_items_produce_empty_scan() {
let result = compute_scan(&[]);
assert!(
result.creeping_charges.is_empty(),
"expected no creeping charges"
);
assert!(
result.upcoming_renewals.is_empty(),
"expected no upcoming renewals"
);
}
}