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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user