Files
kvitt/server/crates/kvitt-import/src/plan.rs
T
jacobh2 c942d73c66 Server overhaul: clear clippy::pedantic wins
Taken from a -W clippy::pedantic scan, no allow added: map_or/map_or_else, method references instead of closures,
clone_from, write! instead of push_str(format!), borrowed Option<&str>, push_of without a needless Option, the
envelope's entity moved instead of cloned, the scheduler's plan() no longer a needless async fn (its doc now says
what it does on failure), sort_unstable, digit separators and inline format args in tests. Not taken: missing
# Errors/# Panics docs, must_use, doc backticks, assert!(x.is_empty()) in tests, too_many_lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:43:54 +02:00

407 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, i64>),
/// 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<PlannedRow>,
pub problems: Vec<RowProblem>,
/// Unmapped category name (as written in the file, trimmed) → row count.
pub unmapped: BTreeMap<String, usize>,
/// 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<String, usize>,
/// The Splitwise totals per user id (summed over the totals rows, converted to SEK); `None` without a totals row.
pub expected_totals: Option<BTreeMap<String, i64>>,
/// 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<String, Rate>,
pub categories: &'a CategoryTable,
}
/// `import_key` (SPEC §10.8): lowercase hex SHA-256 of
/// `splitwise|v1|<account>|<Date>|<Description>|<Category>|<Cost>|<Currency>|<nets in header order>|<n>`.
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<String, Rate>) -> Option<Option<Rate>> {
if currency == SEK {
Some(None)
} else {
fx.get(currency).map(|rate| Some(*rate))
}
}
fn convert(value: i64, rate: Option<Rate>) -> Result<i64, String> {
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<BTreeMap<String, i64>> {
if file.totals.is_empty() {
return None;
}
let mut sums: BTreeMap<String, i64> =
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<Rate>,
ctx: &PlanContext<'_>,
unmapped: &mut BTreeMap<String, usize>,
) -> Result<PlannedBody, String> {
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::<Result<Vec<i64>, 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 = if let Some(key) = ctx.categories.lookup(category) {
key.to_owned()
} else {
*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<String, Rate>) -> Result<PlannedBody, String> {
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);
}
}