c942d73c66
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
407 lines
13 KiB
Rust
407 lines
13 KiB
Rust
//! 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);
|
||
}
|
||
}
|