Files
kvitt/server/crates/kvitt-import/src/plan.rs
T
jacobh2 50339aef6d M9: kvitt-import Splitwise importer (SPEC §10.8, FR-17)
CLI per §10.8 with --help, --dry-run and a summary; English and Swedish
headers with member columns matched by name; CSV quoting; payments as
settlements with one allocation; trailing totals row used to reconcile
the account balance (mismatch rolls back, exit 4); non-SEK rows abort
unless --fx; committed category table data/splitwise_categories.csv;
idempotent re-runs through import_key; rows validated with the server's
push checks, audited with source import, no pushes; one BEGIN IMMEDIATE
transaction. Tests over synthetic fixtures only.

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

408 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 = 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<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);
}
}