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
This commit is contained in:
2026-10-18 07:45:32 +02:00
parent d9dd5848fe
commit 50339aef6d
21 changed files with 3097 additions and 13 deletions
+407
View File
@@ -0,0 +1,407 @@
//! 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);
}
}