//! From parsed rows to the rows Kvitt will store (SPEC §10.8): split derivation from the member nets, payments as //! settlements, `--fx` conversion, the import key, description truncation and the category mapping. Pure: no I/O. use std::collections::BTreeMap; use kvitt_server::auth::sha256_hex; use crate::amount::{Rate, parse_ore}; use crate::categories::{CategoryTable, FALLBACK_KEY, is_payment}; use crate::parse::{DataRow, ParsedFile, RowProblem}; /// The home currency. pub const SEK: &str = "SEK"; /// Expense description limit in code points (SPEC §9.6); longer ones are truncated, the full text goes to notes. pub const DESCRIPTION_MAX: usize = 100; /// Settlement note limit in code points (SPEC §9.6). pub const NOTE_MAX: usize = 200; /// How an imported expense is split. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlannedSplit { /// Mode `exact`: user id → share in öre. Exact(BTreeMap), /// Mode `full` on this user (both nets 0: the owner). Full(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlannedExpense { pub date: String, pub description: String, pub notes: String, pub category_key: String, pub amount_ore: i64, pub payer_user_id: String, pub split: PlannedSplit, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlannedSettlement { pub date: String, pub note: String, pub from_user_id: String, pub to_user_id: String, pub amount_ore: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlannedBody { Expense(PlannedExpense), Settlement(PlannedSettlement), } /// One row to create unless its key already exists. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlannedRow { pub line: u64, pub import_key: String, pub body: PlannedBody, } /// What a file turns into. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Plan { pub rows: Vec, pub problems: Vec, /// Unmapped category name (as written in the file, trimmed) → row count. pub unmapped: BTreeMap, /// Data rows whose currency is not SEK. pub non_sek_rows: usize, /// Non-SEK currencies without a `--fx` rate → row count (data and totals rows). Non-empty aborts the import. pub missing_fx: BTreeMap, /// The Splitwise totals per user id (summed over the totals rows, converted to SEK); `None` without a totals row. pub expected_totals: Option>, /// Whether any value was converted with `--fx`. pub converted: bool, } /// Inputs the plan needs besides the file. pub struct PlanContext<'a> { /// The account name as stored (trimmed), part of the import key. pub account_name: &'a str, /// User id per member column, in header order. pub member_user_ids: &'a [String], pub owner_user_id: &'a str, pub fx: &'a BTreeMap, pub categories: &'a CategoryTable, } /// `import_key` (SPEC §10.8): lowercase hex SHA-256 of /// `splitwise|v1||||||||`. pub fn import_key(account_name: &str, row: &DataRow) -> String { let mut parts: Vec<&str> = vec![ "splitwise", "v1", account_name, &row.date, &row.description, &row.category, &row.cost, &row.currency, ]; parts.extend(row.nets.iter().map(String::as_str)); let occurrence = row.occurrence.to_string(); parts.push(&occurrence); sha256_hex(&parts.join("|")) } /// The first `max` code points of `text`. fn truncate(text: &str, max: usize) -> String { text.chars().take(max).collect() } /// A converter for one currency: identity for SEK, the `--fx` rate otherwise, `None` when no rate is given. fn converter(currency: &str, fx: &BTreeMap) -> Option> { if currency == SEK { Some(None) } else { fx.get(currency).map(|rate| Some(*rate)) } } fn convert(value: i64, rate: Option) -> Result { match rate { None => Ok(value), Some(rate) => rate.convert(value), } } /// Builds the plan for a parsed file. pub fn build(file: &ParsedFile, ctx: &PlanContext<'_>) -> Plan { let mut plan = Plan { problems: file.problems.clone(), ..Plan::default() }; for row in &file.rows { let currency = row.currency.trim(); if currency != SEK { plan.non_sek_rows += 1; } let Some(rate) = converter(currency, ctx.fx) else { *plan.missing_fx.entry(currency.to_owned()).or_insert(0) += 1; continue; }; if rate.is_some() { plan.converted = true; } match plan_row(row, rate, ctx, &mut plan.unmapped) { Ok(body) => plan.rows.push(PlannedRow { line: row.line, import_key: import_key(ctx.account_name, row), body, }), Err(reason) => plan.problems.push(RowProblem { line: row.line, reason, }), } } plan.expected_totals = totals(file, ctx, &mut plan); plan.problems.sort_by_key(|p| p.line); plan } /// Sums the totals rows per user (converted to SEK). fn totals( file: &ParsedFile, ctx: &PlanContext<'_>, plan: &mut Plan, ) -> Option> { if file.totals.is_empty() { return None; } let mut sums: BTreeMap = ctx.member_user_ids.iter().map(|u| (u.clone(), 0)).collect(); let mut seen = std::collections::BTreeSet::new(); for total in &file.totals { if !seen.insert(total.currency.clone()) { plan.problems.push(RowProblem { line: total.line, reason: format!("a second totals row for {}", total.currency), }); continue; } let Some(rate) = converter(&total.currency, ctx.fx) else { *plan.missing_fx.entry(total.currency.clone()).or_insert(0) += 1; continue; }; for (user, net) in ctx.member_user_ids.iter().zip(&total.nets) { match parse_ore(net).and_then(|v| convert(v, rate)) { Ok(value) => { if let Some(sum) = sums.get_mut(user) { *sum += value; } } Err(reason) => plan.problems.push(RowProblem { line: total.line, reason: format!("totals row: {reason}"), }), } } } Some(sums) } fn plan_row( row: &DataRow, rate: Option, ctx: &PlanContext<'_>, unmapped: &mut BTreeMap, ) -> Result { let cost = parse_ore(&row.cost).map_err(|e| format!("cost: {e}"))?; let nets = row .nets .iter() .map(|n| parse_ore(n).map_err(|e| format!("member net: {e}"))) .collect::, String>>()?; let [net_a, net_b] = nets[..] else { return Err("expected two member nets".into()); }; if net_a.checked_add(net_b) != Some(0) { return Err("the member nets do not sum to 0".into()); } let (user_a, user_b) = match ctx.member_user_ids { [a, b] => (a.clone(), b.clone()), _ => return Err("expected two members".into()), }; // The member with positive net and that net, if any. let positive = if net_a > 0 { Some((user_a, user_b, net_a)) } else if net_b > 0 { Some((user_b, user_a, net_b)) } else { None }; let description = row.description.trim(); if is_payment(&row.category) { let Some((from, to, net)) = positive else { return Err("a payment with both nets 0".into()); }; return Ok(PlannedBody::Settlement(PlannedSettlement { date: row.date.trim().to_owned(), note: truncate(description, NOTE_MAX), from_user_id: from, to_user_id: to, amount_ore: convert(net, rate)?, })); } let category = row.category.trim(); let category_key = match ctx.categories.lookup(category) { Some(key) => key.to_owned(), None => { *unmapped.entry(category.to_owned()).or_insert(0) += 1; FALLBACK_KEY.to_owned() } }; if description.is_empty() { return Err("empty description".into()); } let (short, notes) = if description.chars().count() > DESCRIPTION_MAX { ( truncate(description, DESCRIPTION_MAX), description.to_owned(), ) } else { (description.to_owned(), String::new()) }; let amount = convert(cost, rate)?; let (payer, split) = match positive { Some((payer, other, net)) => { let share_other = convert(net, rate)?; let share_payer = amount - share_other; if share_payer < 0 { return Err( "the payer's share would be negative (net larger than the cost)".into(), ); } let shares = BTreeMap::from([(payer.clone(), share_payer), (other, share_other)]); (payer, PlannedSplit::Exact(shares)) } None => ( ctx.owner_user_id.to_owned(), PlannedSplit::Full(ctx.owner_user_id.to_owned()), ), }; Ok(PlannedBody::Expense(PlannedExpense { date: row.date.trim().to_owned(), description: short, notes, category_key, amount_ore: amount, payer_user_id: payer, split, })) } #[cfg(test)] mod tests { use super::*; fn row(category: &str, cost: &str, nets: [&str; 2]) -> DataRow { DataRow { line: 2, date: "2025-03-01".into(), description: "Thing".into(), category: category.into(), cost: cost.into(), currency: "SEK".into(), nets: nets.iter().map(|n| (*n).to_owned()).collect(), occurrence: 0, } } fn plan_one(r: DataRow, fx: &BTreeMap) -> Result { let table = CategoryTable::committed().unwrap(); let members = ["ua".to_owned(), "ub".to_owned()]; let ctx = PlanContext { account_name: "Acc", member_user_ids: &members, owner_user_id: "ub", fx, categories: &table, }; let rate = converter(r.currency.trim(), fx).unwrap(); plan_row(&r, rate, &ctx, &mut BTreeMap::new()) } #[test] fn exact_split_from_nets() { let none = BTreeMap::new(); let PlannedBody::Expense(e) = plan_one(row("Groceries", "100.00", ["-30.00", "30.00"]), &none).unwrap() else { panic!() }; assert_eq!(e.payer_user_id, "ub"); assert_eq!( e.split, PlannedSplit::Exact(BTreeMap::from([("ub".into(), 7000), ("ua".into(), 3000)])) ); assert_eq!(e.category_key, "groceries"); } #[test] fn zero_nets_are_full_on_owner() { let PlannedBody::Expense(e) = plan_one(row("Rent", "10.00", ["0.00", "0.00"]), &BTreeMap::new()).unwrap() else { panic!() }; assert_eq!(e.payer_user_id, "ub"); assert_eq!(e.split, PlannedSplit::Full("ub".into())); } #[test] fn invalid_rows() { let none = BTreeMap::new(); assert!(plan_one(row("Rent", "10.00", ["5.00", "-4.00"]), &none).is_err()); assert!(plan_one(row("Rent", "10.00", ["15.00", "-15.00"]), &none).is_err()); assert!(plan_one(row("Rent", "1.234", ["0.00", "0.00"]), &none).is_err()); assert!(plan_one(row("Payment", "10.00", ["0.00", "0.00"]), &none).is_err()); } #[test] fn payment_from_positive_member() { let PlannedBody::Settlement(s) = plan_one( row("Betalning", "25.00", ["25.00", "-25.00"]), &BTreeMap::new(), ) .unwrap() else { panic!() }; assert_eq!( (s.from_user_id.as_str(), s.to_user_id.as_str(), s.amount_ore), ("ua", "ub", 2500) ); } #[test] fn fx_recomputes_payer_share() { let fx = BTreeMap::from([("EUR".to_owned(), Rate::parse("0.5").unwrap())]); let mut r = row("Groceries", "0.03", ["0.01", "-0.01"]); r.currency = "EUR".into(); let PlannedBody::Expense(e) = plan_one(r, &fx).unwrap() else { panic!() }; // C' = rhu(1.5) = 2, other' = rhu(0.5) = 1, payer = 2 − 1. assert_eq!(e.amount_ore, 2); assert_eq!( e.split, PlannedSplit::Exact(BTreeMap::from([("ua".into(), 1), ("ub".into(), 1)])) ); } #[test] fn import_key_formula() { let mut r = row("Groceries", "10.00", ["5.00", "-5.00"]); r.occurrence = 1; assert_eq!( import_key("Acc", &r), sha256_hex("splitwise|v1|Acc|2025-03-01|Thing|Groceries|10.00|SEK|5.00|-5.00|1") ); assert_eq!(import_key("Acc", &r).len(), 64); } }