use crate::client::Transaction;
use serde::Serialize;
#[derive(Debug, Default)]
pub struct InspectFilter {
pub category: Option<String>,
pub merchant: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct InspectLineItem {
pub id: String,
pub merchant: String,
pub amount: f64,
pub date: String,
pub category: String,
pub tags: Vec<String>,
pub notes: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct InspectSummary {
pub total_count: usize,
pub net_amount: f64,
pub total_inflow: f64,
pub total_outflow: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct InspectionResult {
pub summary: InspectSummary,
pub transactions: Vec<InspectLineItem>,
}
pub fn blank_to_none(value: Option<String>) -> Option<String> {
value.filter(|s| !s.trim().is_empty())
}
pub fn compute_inspection(
transactions: &[Transaction],
filter: &InspectFilter,
) -> InspectionResult {
let matching: Vec<&Transaction> = transactions
.iter()
.filter(|t| matches_filter(t, filter))
.collect();
let line_items: Vec<InspectLineItem> = matching
.iter()
.map(|t| InspectLineItem {
id: t.id.clone(),
merchant: t.merchant_name.clone(),
amount: t.amount,
date: t.date.clone(),
category: t.category.name.clone(),
tags: t.tags.clone(),
notes: t.notes.clone(),
})
.collect();
let summary = compute_summary(&line_items);
InspectionResult {
summary,
transactions: line_items,
}
}
fn matches_filter(txn: &Transaction, filter: &InspectFilter) -> bool {
if let Some(category_needle) = &filter.category {
let needle = category_needle.trim();
if !needle.is_empty()
&& !txn
.category
.name
.to_lowercase()
.contains(&needle.to_lowercase())
{
return false;
}
}
if let Some(merchant_needle) = &filter.merchant {
let needle = merchant_needle.trim();
if !needle.is_empty()
&& !txn
.merchant_name
.to_lowercase()
.contains(&needle.to_lowercase())
{
return false;
}
}
true
}
fn compute_summary(items: &[InspectLineItem]) -> InspectSummary {
let total_count = items.len();
let mut total_inflow = 0.0_f64;
let mut total_outflow = 0.0_f64;
for item in items {
if item.amount > 0.0 {
total_inflow += item.amount;
} else {
total_outflow += item.amount.abs();
}
}
let net_amount = total_inflow - total_outflow;
InspectSummary {
total_count,
net_amount,
total_inflow,
total_outflow,
}
}
#[cfg(test)]
mod tests {
use super::blank_to_none;
#[test]
fn blank_to_none_coerces_empty_string_to_none() {
assert_eq!(blank_to_none(Some(String::new())), None);
}
#[test]
fn blank_to_none_coerces_whitespace_only_to_none() {
assert_eq!(blank_to_none(Some(" ".to_string())), None);
}
#[test]
fn blank_to_none_preserves_non_blank_value() {
assert_eq!(
blank_to_none(Some("Pets".to_string())),
Some("Pets".to_string())
);
}
use super::*;
use crate::client::{Category, Transaction};
fn make_txn(id: &str, merchant: &str, amount: f64, category: &str, date: &str) -> Transaction {
Transaction {
id: id.to_string(),
amount,
date: date.to_string(),
merchant_name: merchant.to_string(),
category: Category {
name: category.to_string(),
group_type: Some("expense".into()),
},
tags: vec![],
notes: String::new(),
needs_review: false,
}
}
fn make_txn_with_tags(
id: &str,
merchant: &str,
amount: f64,
category: &str,
date: &str,
tags: Vec<&str>,
notes: &str,
) -> Transaction {
Transaction {
id: id.to_string(),
amount,
date: date.to_string(),
merchant_name: merchant.to_string(),
category: Category {
name: category.to_string(),
group_type: Some("expense".into()),
},
tags: tags.into_iter().map(str::to_string).collect(),
notes: notes.to_string(),
needs_review: false,
}
}
fn no_filter() -> InspectFilter {
InspectFilter::default()
}
#[test]
fn result_includes_transaction_ids() {
let txns = vec![
make_txn("txn-001", "Petco", -12000.0, "Pets", "2026-05-10"),
make_txn("txn-002", "CVS Pharmacy", -3300.0, "Medical", "2026-05-12"),
];
let result = compute_inspection(&txns, &no_filter());
let ids: Vec<&str> = result.transactions.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"txn-001"), "expected txn-001 in ids: {ids:?}");
assert!(ids.contains(&"txn-002"), "expected txn-002 in ids: {ids:?}");
}
#[test]
fn summary_count_matches_transaction_count() {
let txns = vec![
make_txn("t1", "Petco", -12000.0, "Pets", "2026-05-10"),
make_txn("t2", "Banfield", -500.0, "Pets", "2026-05-11"),
make_txn("t3", "Petco Refund", 200.0, "Pets", "2026-05-12"),
];
let result = compute_inspection(&txns, &no_filter());
assert_eq!(result.summary.total_count, 3);
}
#[test]
fn net_amount_is_algebraic_sum_of_all_amounts() {
let txns = vec![
make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "Refund", 30.0, "Pets", "2026-05-11"),
];
let result = compute_inspection(&txns, &no_filter());
assert!(
(result.summary.net_amount - (-70.0)).abs() < 0.01,
"expected net=-70.0, got {}",
result.summary.net_amount
);
}
#[test]
fn inflow_outflow_split_separates_charges_from_refunds() {
let txns = vec![
make_txn("t1", "Petco", -12000.0, "Pets", "2026-05-10"),
make_txn("t2", "Petco Refund", 500.0, "Pets", "2026-05-15"),
];
let result = compute_inspection(&txns, &no_filter());
assert!(
(result.summary.total_inflow - 500.0).abs() < 0.01,
"expected inflow=500, got {}",
result.summary.total_inflow
);
assert!(
(result.summary.total_outflow - 12000.0).abs() < 0.01,
"expected outflow=12000, got {}",
result.summary.total_outflow
);
}
#[test]
fn only_outflows_leaves_inflow_zero() {
let txns = vec![
make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "Vet Clinic", -200.0, "Pets", "2026-05-11"),
];
let result = compute_inspection(&txns, &no_filter());
assert_eq!(result.summary.total_inflow, 0.0);
assert!((result.summary.total_outflow - 300.0).abs() < 0.01);
}
#[test]
fn only_inflows_leaves_outflow_zero() {
let txns = vec![make_txn(
"t1",
"Direct Deposit",
5000.0,
"Income",
"2026-05-01",
)];
let result = compute_inspection(&txns, &no_filter());
assert_eq!(result.summary.total_outflow, 0.0);
assert!((result.summary.total_inflow - 5000.0).abs() < 0.01);
}
#[test]
fn category_filter_returns_only_matching_transactions() {
let txns = vec![
make_txn("t1", "Petco", -12000.0, "Pets", "2026-05-10"),
make_txn("t2", "CVS", -3300.0, "Medical", "2026-05-12"),
make_txn("t3", "Banfield", -500.0, "Pets", "2026-05-14"),
];
let filter = InspectFilter {
category: Some("Pets".to_string()),
merchant: None,
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 2);
let categories: Vec<&str> = result
.transactions
.iter()
.map(|t| t.category.as_str())
.collect();
assert!(
categories.iter().all(|c| *c == "Pets"),
"expected only Pets, got: {categories:?}"
);
}
#[test]
fn category_filter_is_case_insensitive() {
let txns = vec![
make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "CVS", -50.0, "Medical", "2026-05-11"),
];
let filter = InspectFilter {
category: Some("pets".to_string()),
merchant: None,
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 1);
assert_eq!(result.transactions[0].id, "t1");
}
#[test]
fn merchant_filter_returns_only_matching_transactions() {
let txns = vec![
make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "Petco", -200.0, "Pets", "2026-05-11"),
make_txn("t3", "Banfield Vet", -500.0, "Pets", "2026-05-12"),
];
let filter = InspectFilter {
category: None,
merchant: Some("Petco".to_string()),
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 2);
}
#[test]
fn merchant_filter_is_case_insensitive_substring() {
let txns = vec![
make_txn("t1", "Petco Store #42", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "CVS Pharmacy", -50.0, "Medical", "2026-05-11"),
];
let filter = InspectFilter {
category: None,
merchant: Some("petco".to_string()),
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 1);
assert_eq!(result.transactions[0].id, "t1");
}
#[test]
fn combined_category_and_merchant_filter_both_must_match() {
let txns = vec![
make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10"),
make_txn("t2", "Petco", -50.0, "General", "2026-05-11"),
make_txn("t3", "Banfield", -200.0, "Pets", "2026-05-12"),
];
let filter = InspectFilter {
category: Some("Pets".to_string()),
merchant: Some("Petco".to_string()),
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 1);
assert_eq!(result.transactions[0].id, "t1");
}
#[test]
fn no_matches_returns_empty_result_with_zero_summary() {
let txns = vec![make_txn("t1", "Petco", -100.0, "Pets", "2026-05-10")];
let filter = InspectFilter {
category: Some("Medical".to_string()),
merchant: None,
};
let result = compute_inspection(&txns, &filter);
assert_eq!(result.summary.total_count, 0);
assert_eq!(result.summary.net_amount, 0.0);
assert_eq!(result.summary.total_inflow, 0.0);
assert_eq!(result.summary.total_outflow, 0.0);
assert!(result.transactions.is_empty());
}
#[test]
fn line_items_carry_tags_and_notes() {
let txns = vec![make_txn_with_tags(
"t1",
"Petco",
-100.0,
"Pets",
"2026-05-10",
vec!["urgent", "reimbursable"],
"Heartworm meds",
)];
let result = compute_inspection(&txns, &no_filter());
let item = &result.transactions[0];
assert_eq!(item.tags, vec!["urgent", "reimbursable"]);
assert_eq!(item.notes, "Heartworm meds");
}
#[test]
fn empty_transaction_list_returns_zero_summary() {
let result = compute_inspection(&[], &no_filter());
assert_eq!(result.summary.total_count, 0);
assert_eq!(result.summary.net_amount, 0.0);
assert_eq!(result.summary.total_inflow, 0.0);
assert_eq!(result.summary.total_outflow, 0.0);
assert!(result.transactions.is_empty());
}
#[test]
fn zero_amount_transaction_contributes_to_neither_inflow_nor_outflow() {
let txns = vec![
make_txn("t1", "Adjustment", 0.0, "General", "2026-05-10"),
make_txn("t2", "Petco", -100.0, "Pets", "2026-05-11"),
];
let result = compute_inspection(&txns, &no_filter());
assert_eq!(result.summary.total_inflow, 0.0);
assert!((result.summary.total_outflow - 100.0).abs() < 0.01);
}
}