use std::path::PathBuf;
use anyhow::{Context, Result, anyhow, bail};
use regex::Regex;
use serde::Deserialize;
use crate::forma::{CreateClaimOptions, get_categories_for_benefit_name};
#[derive(Debug, Clone, Default)]
pub struct ClaimInput {
pub category: String,
pub benefit: String,
pub amount: String,
pub merchant: String,
pub purchase_date: String,
pub description: String,
pub receipt_path: Vec<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct ClaimRow {
category: String,
benefit: String,
amount: String,
merchant: String,
#[serde(rename = "purchaseDate")]
purchase_date: String,
description: String,
#[serde(rename = "receiptPath")]
receipt_path: String,
}
const EXPECTED_HEADERS: &[&str] = &[
"category",
"benefit",
"amount",
"merchant",
"purchaseDate",
"description",
"receiptPath",
];
pub fn is_valid_purchase_date(date: &str) -> bool {
static_re_purchase_date().is_match(date)
}
pub fn is_valid_amount(amount: &str) -> bool {
static_re_amount().is_match(amount)
}
fn static_re_purchase_date() -> &'static Regex {
use std::sync::OnceLock;
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap())
}
fn static_re_amount() -> &'static Regex {
use std::sync::OnceLock;
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\d+(\.\d{2})?$").unwrap())
}
pub fn claim_input_to_create_options(
claim: &ClaimInput,
access_token: &str,
) -> Result<CreateClaimOptions> {
let categories = get_categories_for_benefit_name(access_token, &claim.benefit)?;
let matching = categories
.iter()
.find(|c| {
c.subcategory_alias.as_deref() == Some(claim.category.as_str())
|| c.subcategory_name == claim.category
})
.ok_or_else(|| {
anyhow!(
"No category '{}' found for benefit '{}'.",
claim.category,
claim.benefit
)
})?;
if !is_valid_purchase_date(&claim.purchase_date) {
bail!("Purchase date must be in YYYY-MM-DD format.");
}
if !is_valid_amount(&claim.amount) {
bail!(
"Amount must be a whole number or a number with exactly two decimal places (e.g. `10` or `10.99`)."
);
}
for path in &claim.receipt_path {
if !path.exists() {
bail!("Receipt path '{}' does not exist.", path.display());
}
}
Ok(CreateClaimOptions {
amount: claim.amount.clone(),
merchant: claim.merchant.clone(),
purchase_date: claim.purchase_date.clone(),
description: claim.description.clone(),
receipt_path: claim.receipt_path.clone(),
access_token: access_token.to_string(),
benefit_id: matching.benefit_id.clone(),
category_id: matching.category_id.clone(),
subcategory_value: matching.subcategory_value.clone(),
subcategory_alias: matching.subcategory_alias.clone(),
})
}
pub fn read_claims_from_csv(input_path: &std::path::Path) -> Result<Vec<ClaimInput>> {
let mut reader = csv::Reader::from_path(input_path)
.with_context(|| format!("Failed to open CSV file at {}", input_path.display()))?;
{
let headers = reader.headers().context("Failed to read CSV headers")?;
let headers_vec: Vec<&str> = headers.iter().collect();
if headers_vec.len() != EXPECTED_HEADERS.len()
|| !headers_vec.iter().all(|h| EXPECTED_HEADERS.contains(h))
{
bail!(
"Invalid CSV headers. Please use a template CSV generated by the `generate-template-csv` command."
);
}
}
let mut out = Vec::new();
for row in reader.deserialize() {
let row: ClaimRow = row.context("Failed to parse CSV row")?;
let receipt_paths: Vec<PathBuf> = row
.receipt_path
.split(',')
.map(|s| PathBuf::from(s.trim()))
.filter(|p| !p.as_os_str().is_empty())
.collect();
out.push(ClaimInput {
category: row.category,
benefit: row.benefit,
amount: row.amount,
merchant: row.merchant,
purchase_date: row.purchase_date,
description: row.description,
receipt_path: receipt_paths,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn validates_purchase_dates() {
assert!(is_valid_purchase_date("2024-01-02"));
assert!(is_valid_purchase_date("9999-12-31"));
assert!(!is_valid_purchase_date("2024-1-2"));
assert!(!is_valid_purchase_date("not a date"));
assert!(!is_valid_purchase_date(""));
assert!(!is_valid_purchase_date("2024/01/02"));
}
#[test]
fn validates_amounts() {
assert!(is_valid_amount("10"));
assert!(is_valid_amount("10.99"));
assert!(is_valid_amount("0"));
assert!(is_valid_amount("100000"));
assert!(!is_valid_amount("10.9"));
assert!(!is_valid_amount("10.999"));
assert!(!is_valid_amount("abc"));
assert!(!is_valid_amount(""));
assert!(!is_valid_amount(".50"));
assert!(!is_valid_amount("-10"));
}
fn write_csv(contents: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new()
.suffix(".csv")
.tempfile()
.expect("tempfile");
f.write_all(contents.as_bytes()).expect("write csv");
f
}
#[test]
fn read_claims_from_csv_parses_a_complete_row() {
let csv = "benefit,category,merchant,amount,description,purchaseDate,receiptPath\n\
Lifestyle,Gym,FitClub,25.99,Monthly,2024-01-02,/tmp/r.jpg\n";
let f = write_csv(csv);
let rows = read_claims_from_csv(f.path()).expect("parse");
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.benefit, "Lifestyle");
assert_eq!(r.category, "Gym");
assert_eq!(r.merchant, "FitClub");
assert_eq!(r.amount, "25.99");
assert_eq!(r.description, "Monthly");
assert_eq!(r.purchase_date, "2024-01-02");
assert_eq!(r.receipt_path.len(), 1);
assert_eq!(r.receipt_path[0], PathBuf::from("/tmp/r.jpg"));
}
#[test]
fn read_claims_from_csv_handles_multiple_receipt_paths() {
let csv = "benefit,category,merchant,amount,description,purchaseDate,receiptPath\n\
Lifestyle,Gym,FitClub,25.99,Monthly,2024-01-02,\"/tmp/a.jpg, /tmp/b.jpg\"\n";
let f = write_csv(csv);
let rows = read_claims_from_csv(f.path()).expect("parse");
assert_eq!(rows[0].receipt_path.len(), 2);
assert_eq!(rows[0].receipt_path[0], PathBuf::from("/tmp/a.jpg"));
assert_eq!(rows[0].receipt_path[1], PathBuf::from("/tmp/b.jpg"));
}
#[test]
fn read_claims_from_csv_filters_empty_receipt_path_segments() {
let csv = "benefit,category,merchant,amount,description,purchaseDate,receiptPath\n\
Lifestyle,Gym,FitClub,25.99,Monthly,2024-01-02,\n";
let f = write_csv(csv);
let rows = read_claims_from_csv(f.path()).expect("parse");
assert!(rows[0].receipt_path.is_empty());
}
#[test]
fn read_claims_from_csv_accepts_headers_in_any_order() {
let csv = "receiptPath,purchaseDate,description,amount,merchant,category,benefit\n\
/tmp/r.jpg,2024-01-02,Monthly,25.99,FitClub,Gym,Lifestyle\n";
let f = write_csv(csv);
let rows = read_claims_from_csv(f.path()).expect("parse");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].benefit, "Lifestyle");
}
#[test]
fn read_claims_from_csv_rejects_unexpected_headers() {
let csv = "benefit,category,merchant,amount,description,purchaseDate\n";
let f = write_csv(csv);
let err = read_claims_from_csv(f.path()).expect_err("should fail");
assert!(format!("{err}").contains("Invalid CSV headers"));
}
#[test]
fn read_claims_from_csv_returns_empty_for_template_only() {
let csv = "benefit,category,merchant,amount,description,purchaseDate,receiptPath\n";
let f = write_csv(csv);
let rows = read_claims_from_csv(f.path()).expect("parse");
assert!(rows.is_empty());
}
#[test]
fn read_claims_from_csv_errors_on_missing_file() {
let path = std::path::PathBuf::from("/nonexistent/path/does/not/exist.csv");
let err = read_claims_from_csv(&path).expect_err("should fail");
let msg = format!("{err:#}");
assert!(msg.contains("Failed to open CSV file"), "{msg}");
}
}