formanator 3.0.0

Submit Forma <https://joinforma.com> benefit claims from the command line, with support for AI-powered receipt analysis via OpenAI or GitHub Models
Documentation
//! Helpers for parsing claim CSV files and converting user-facing claim data
//! into the [`CreateClaimOptions`] expected by the Forma API client.

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};

/// CSV / API friendly representation of a single claim before it has been
/// resolved to API IDs.
#[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>,
}

/// Raw row used by the CSV parser; we keep it close to the upstream JS
/// implementation's column names.
#[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())
}

/// Convert a [`ClaimInput`] to the API-ready [`CreateClaimOptions`], looking up
/// the benefit/category IDs against the user's profile.
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(),
    })
}

/// Read claims from a CSV file. The CSV must have exactly the headers produced
/// by `generate-template-csv`, in any order.
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"));
        // The second path should have leading whitespace trimmed.
        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() {
        // Order intentionally shuffled relative to EXPECTED_HEADERS.
        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}");
    }
}