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
+18 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kvitt-import"
description = "Splitwise CSV importer for Kvitt (SPEC §10.8). Stub until milestone M9."
description = "Splitwise CSV importer for Kvitt (SPEC §10.8)."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -9,6 +9,23 @@ publish.workspace = true
[lints]
workspace = true
[lib]
path = "src/lib.rs"
[[bin]]
name = "kvitt-import"
path = "src/main.rs"
[dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] }
clap = { version = "4.6.7", features = ["derive"] }
csv = "1.4.0"
kvitt-domain = { version = "0.1.0", path = "../kvitt-domain" }
kvitt-server = { version = "0.1.0", path = "../kvitt-server" }
serde_json = "1.0.154"
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.53.3", features = ["rt"] }
uuid = { version = "1.28.0", features = ["v7"] }
[dev-dependencies]
tokio = { version = "1.53.3", features = ["rt", "macros"] }
@@ -0,0 +1,74 @@
splitwise_name,language,default_key
Groceries,en,groceries
Food and drink - Other,en,groceries
Liquor,en,groceries
Dining out,en,restaurants
Electronics,en,home
Furniture,en,home
Household supplies,en,home
Maintenance,en,home
Mortgage,en,home
Rent,en,home
Services,en,home
Pets,en,home
Home - Other,en,home
Electricity,en,utilities
Heat/gas,en,utilities
Water,en,utilities
Trash,en,utilities
TV/Phone/Internet,en,utilities
Cleaning,en,utilities
Utilities - Other,en,utilities
Bicycle,en,transport
Bus/train,en,transport
Car,en,transport
Gas/fuel,en,transport
Parking,en,transport
Taxi,en,transport
Transportation - Other,en,transport
Hotel,en,travel
Plane,en,travel
Games,en,entertainment
Movies,en,entertainment
Music,en,entertainment
Sports,en,entertainment
Entertainment - Other,en,entertainment
Medical expenses,en,health
Clothing,en,shopping
Gifts,en,shopping
General,en,other
Childcare,en,other
Education,en,other
Insurance,en,other
Taxes,en,other
Life - Other,en,other
Other,en,other
Livsmedel,sv,groceries
Mat och dryck - Övrigt,sv,groceries
Alkohol,sv,groceries
Restaurangbesök,sv,restaurants
Elektronik,sv,home
Möbler,sv,home
Förbrukningsvaror,sv,home
Skötsel/underhåll,sv,home
Avbetalning/Amortering,sv,home
Hyra,sv,home
Hem - Övrigt,sv,home
TV/telefon/internet,sv,utilities
Buss/tåg,sv,transport
Bil,sv,transport
Bensin/bränsle,sv,transport
Parkering,sv,transport
Taxi,sv,transport
Transport - Övrigt,sv,transport
Hotell,sv,travel
Musik,sv,entertainment
Filmer,sv,entertainment
Sport,sv,entertainment
Underhållning - Övrigt,sv,entertainment
Sjukvård/medicin,sv,health
Presenter,sv,shopping
Allmänt,sv,other
Barnomsorg,sv,other
Försäkringar,sv,other
Skatter,sv,other
1 splitwise_name language default_key
2 Groceries en groceries
3 Food and drink - Other en groceries
4 Liquor en groceries
5 Dining out en restaurants
6 Electronics en home
7 Furniture en home
8 Household supplies en home
9 Maintenance en home
10 Mortgage en home
11 Rent en home
12 Services en home
13 Pets en home
14 Home - Other en home
15 Electricity en utilities
16 Heat/gas en utilities
17 Water en utilities
18 Trash en utilities
19 TV/Phone/Internet en utilities
20 Cleaning en utilities
21 Utilities - Other en utilities
22 Bicycle en transport
23 Bus/train en transport
24 Car en transport
25 Gas/fuel en transport
26 Parking en transport
27 Taxi en transport
28 Transportation - Other en transport
29 Hotel en travel
30 Plane en travel
31 Games en entertainment
32 Movies en entertainment
33 Music en entertainment
34 Sports en entertainment
35 Entertainment - Other en entertainment
36 Medical expenses en health
37 Clothing en shopping
38 Gifts en shopping
39 General en other
40 Childcare en other
41 Education en other
42 Insurance en other
43 Taxes en other
44 Life - Other en other
45 Other en other
46 Livsmedel sv groceries
47 Mat och dryck - Övrigt sv groceries
48 Alkohol sv groceries
49 Restaurangbesök sv restaurants
50 Elektronik sv home
51 Möbler sv home
52 Förbrukningsvaror sv home
53 Skötsel/underhåll sv home
54 Avbetalning/Amortering sv home
55 Hyra sv home
56 Hem - Övrigt sv home
57 TV/telefon/internet sv utilities
58 Buss/tåg sv transport
59 Bil sv transport
60 Bensin/bränsle sv transport
61 Parkering sv transport
62 Taxi sv transport
63 Transport - Övrigt sv transport
64 Hotell sv travel
65 Musik sv entertainment
66 Filmer sv entertainment
67 Sport sv entertainment
68 Underhållning - Övrigt sv entertainment
69 Sjukvård/medicin sv health
70 Presenter sv shopping
71 Allmänt sv other
72 Barnomsorg sv other
73 Försäkringar sv other
74 Skatter sv other
+156
View File
@@ -0,0 +1,156 @@
//! Exact decimal parsing (SPEC §10.8: amounts are decimal numbers with `.` as separator, converted to öre exactly by
//! string parsing) and the `--fx` conversion `round_half_up(amount_ore × rate)`.
/// Parses `-?digits(.d{1,2})?` (surrounding whitespace ignored) into öre. Never uses floating point.
pub fn parse_ore(text: &str) -> Result<i64, String> {
let trimmed = text.trim();
let (negative, body) = match trimmed.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, trimmed),
};
let (whole, fraction) = match body.split_once('.') {
Some((whole, fraction)) => (whole, fraction),
None => (body, ""),
};
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if !digits(whole) || (body.contains('.') && !digits(fraction)) || fraction.len() > 2 {
return Err(format!(
"{trimmed:?} is not a decimal amount with at most two decimals"
));
}
let overflow = || format!("{trimmed:?} is too large");
let whole: i64 = whole.parse().map_err(|_| overflow())?;
let fraction: i64 = match fraction.len() {
0 => 0,
1 => fraction.parse::<i64>().map_err(|_| overflow())? * 10,
_ => fraction.parse().map_err(|_| overflow())?,
};
let ore = whole
.checked_mul(100)
.and_then(|v| v.checked_add(fraction))
.ok_or_else(overflow)?;
Ok(if negative { -ore } else { ore })
}
/// Formats öre as `-1234.50` (for the summary).
pub fn format_ore(ore: i64) -> String {
let sign = if ore < 0 { "-" } else { "" };
let abs = ore.unsigned_abs();
format!("{sign}{}.{:02}", abs / 100, abs % 100)
}
/// A `--fx` rate: SEK per unit of the foreign currency, an exact positive decimal `num / den`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rate {
num: i128,
den: i128,
}
/// Most decimals a rate may have.
const RATE_MAX_DECIMALS: usize = 9;
impl Rate {
/// Parses `digits(.digits)?`, greater than zero, at most nine decimals.
pub fn parse(text: &str) -> Result<Self, String> {
let trimmed = text.trim();
let (whole, fraction) = trimmed.split_once('.').unwrap_or((trimmed, ""));
let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
if whole.is_empty()
|| !digits(whole)
|| !digits(fraction)
|| (trimmed.contains('.') && fraction.is_empty())
|| fraction.len() > RATE_MAX_DECIMALS
|| whole.len() > 12
{
return Err(format!(
"{trimmed:?} is not a positive decimal rate (SEK per unit)"
));
}
let joined = format!("{whole}{fraction}");
let num: i128 = joined
.parse()
.map_err(|_| format!("{trimmed:?} is not a rate"))?;
if num == 0 {
return Err(format!("{trimmed:?}: the rate must be greater than zero"));
}
let den = 10_i128.pow(u32::try_from(fraction.len()).unwrap_or(0));
Ok(Self { num, den })
}
/// `round_half_up(ore × rate)` (SPEC §10.8). Negative values are converted by magnitude with the sign kept, so
/// a value and its negation convert to negations of each other.
pub fn convert(self, ore: i64) -> Result<i64, String> {
let magnitude = i128::from(ore.unsigned_abs());
let scaled = (magnitude * self.num * 2 + self.den) / (2 * self.den);
let value =
i64::try_from(scaled).map_err(|_| format!("{ore} öre converts out of range"))?;
Ok(if ore < 0 { -value } else { value })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_exactly() {
assert_eq!(parse_ore("12.50"), Ok(1250));
assert_eq!(parse_ore("12.5"), Ok(1250));
assert_eq!(parse_ore("12"), Ok(1200));
assert_eq!(parse_ore("0.05"), Ok(5));
assert_eq!(parse_ore("-3.10"), Ok(-310));
assert_eq!(parse_ore(" 7.00 "), Ok(700));
assert_eq!(parse_ore("0.00"), Ok(0));
// Values float parsing would get wrong.
assert_eq!(parse_ore("0.29"), Ok(29));
assert_eq!(parse_ore("1234567.89"), Ok(123_456_789));
}
#[test]
fn rejects_other_forms() {
for bad in [
"", " ", "1.234", "1,50", "abc", "1.", ".5", "+1", "--1", "1e3", "1.2.3",
] {
assert!(parse_ore(bad).is_err(), "{bad:?}");
}
assert!(parse_ore("99999999999999999999").is_err());
}
#[test]
fn formats() {
assert_eq!(format_ore(123_450), "1234.50");
assert_eq!(format_ore(-5), "-0.05");
assert_eq!(format_ore(0), "0.00");
}
#[test]
fn rate_rounds_half_up() {
let rate = Rate::parse("11.5").unwrap();
assert_eq!(rate.convert(1000), Ok(11500));
// 1 öre × 0.5 = 0.5 → 1 (half up); 3 × 0.5 = 1.5 → 2.
let half = Rate::parse("0.5").unwrap();
assert_eq!(half.convert(1), Ok(1));
assert_eq!(half.convert(3), Ok(2));
assert_eq!(half.convert(-3), Ok(-2));
// 0.4 → 0.
assert_eq!(Rate::parse("0.4").unwrap().convert(1), Ok(0));
assert_eq!(Rate::parse("10").unwrap().convert(123), Ok(1230));
}
#[test]
fn rate_rejects_bad_values() {
for bad in [
"",
"0",
"0.000",
"-1",
"1,5",
"abc",
"1.",
".5",
"1.0000000001",
] {
assert!(Rate::parse(bad).is_err(), "{bad:?}");
}
}
}
@@ -0,0 +1,157 @@
//! The Splitwise category mapping (SPEC §10.8, D-003): `data/splitwise_categories.csv`, columns
//! `splitwise_name,language,default_key`, English and Swedish names, matched case-insensitively and exactly. It is
//! the only mapping; a name it does not hold maps to `other` and is counted as unmapped.
use std::collections::BTreeMap;
/// The committed table, embedded at build time from the crate's data file.
pub const TABLE_CSV: &str = include_str!("../data/splitwise_categories.csv");
/// The Kvitt default category keys (SPEC §14.1 `categories.default_key`).
pub const DEFAULT_KEYS: [&str; 10] = [
"groceries",
"restaurants",
"home",
"utilities",
"transport",
"travel",
"entertainment",
"health",
"shopping",
"other",
];
/// The key unmapped names fall back to.
pub const FALLBACK_KEY: &str = "other";
/// One line of the table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub splitwise_name: String,
/// `en` or `sv`.
pub language: String,
pub default_key: String,
}
/// The loaded table.
#[derive(Debug, Clone)]
pub struct CategoryTable {
pub entries: Vec<Entry>,
by_name: BTreeMap<String, String>,
}
/// The case-insensitive form of a category name.
fn fold(name: &str) -> String {
name.trim().to_lowercase()
}
impl CategoryTable {
/// Parses and checks a table: the exact header, languages `en`/`sv`, known keys, no (name, language) twice and
/// no name mapped to two keys.
pub fn parse(text: &str) -> Result<Self, String> {
let mut reader = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(text.as_bytes());
let header = reader
.headers()
.map_err(|e| format!("category table: {e}"))?
.clone();
if header.iter().collect::<Vec<_>>() != ["splitwise_name", "language", "default_key"] {
return Err(
"category table: header must be splitwise_name,language,default_key".into(),
);
}
let mut entries = Vec::new();
let mut by_name: BTreeMap<String, String> = BTreeMap::new();
let mut seen = std::collections::BTreeSet::new();
for record in reader.records() {
let record = record.map_err(|e| format!("category table: {e}"))?;
let (Some(name), Some(language), Some(key), None) =
(record.get(0), record.get(1), record.get(2), record.get(3))
else {
return Err(format!(
"category table: line {} must have three fields",
record.position().map_or(0, |p| p.line())
));
};
if name.trim().is_empty() || name.trim() != name {
return Err(format!("category table: bad name {name:?}"));
}
if language != "en" && language != "sv" {
return Err(format!("category table: bad language {language:?}"));
}
if !DEFAULT_KEYS.contains(&key) {
return Err(format!("category table: unknown default_key {key:?}"));
}
if !seen.insert((fold(name), language.to_owned())) {
return Err(format!(
"category table: {name:?} ({language}) appears twice"
));
}
if let Some(previous) = by_name.insert(fold(name), key.to_owned())
&& previous != key
{
return Err(format!(
"category table: {name:?} maps to both {previous} and {key}"
));
}
entries.push(Entry {
splitwise_name: name.to_owned(),
language: language.to_owned(),
default_key: key.to_owned(),
});
}
Ok(Self { entries, by_name })
}
/// The committed table.
pub fn committed() -> Result<Self, String> {
Self::parse(TABLE_CSV)
}
/// The default key of a Splitwise category name (case-insensitive exact match), `None` when unmapped.
pub fn lookup(&self, splitwise_name: &str) -> Option<&str> {
self.by_name.get(&fold(splitwise_name)).map(String::as_str)
}
}
/// Whether a `Category` value marks a payment row (`Payment` / `Betalning`, SPEC §10.8).
pub fn is_payment(category: &str) -> bool {
let folded = fold(category);
folded == "payment" || folded == "betalning"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn committed_table_loads() {
let table = CategoryTable::committed().unwrap();
assert_eq!(table.lookup("livsmedel"), Some("groceries"));
assert_eq!(table.lookup("HEM - ÖVRIGT"), Some("home"));
assert_eq!(table.lookup(" Dining out "), Some("restaurants"));
assert_eq!(table.lookup("Taxi"), Some("transport"));
assert_eq!(table.lookup("Other"), Some("other"));
assert_eq!(table.lookup("Something new"), None);
assert_eq!(table.lookup("Payment"), None);
}
#[test]
fn rejects_bad_tables() {
let header = "splitwise_name,language,default_key\n";
assert!(CategoryTable::parse("name,lang,key\nA,en,home\n").is_err());
assert!(CategoryTable::parse(&format!("{header}A,de,home\n")).is_err());
assert!(CategoryTable::parse(&format!("{header}A,en,food\n")).is_err());
assert!(CategoryTable::parse(&format!("{header}A,en,home\na,en,home\n")).is_err());
assert!(CategoryTable::parse(&format!("{header}A,en,home\nA,sv,travel\n")).is_err());
assert!(CategoryTable::parse(&format!("{header}A,en,home\nA,sv,home\n")).is_ok());
}
#[test]
fn payments() {
assert!(is_payment("Payment"));
assert!(is_payment("betalning"));
assert!(!is_payment("Payments"));
}
}
+117
View File
@@ -0,0 +1,117 @@
//! The command line of SPEC §10.8.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use kvitt_server::audit::SqlAuditWriter;
use crate::amount::Rate;
use crate::{EXIT_ERROR, EXIT_USAGE, ImportError, Options, UserArg, split_assignment};
#[derive(Debug, Parser)]
#[command(
name = "kvitt-import",
version,
about = "Import one Splitwise group export (CSV) into a Kvitt account (SPEC §10.8).",
long_about = "Import one Splitwise group export (CSV, English or Swedish header) into a Kvitt account \
(SPEC §10.8). Run with the server stopped, or against a copy of the database. Re-running is safe: rows already \
imported (same import key) are skipped. Every created row gets an audit entry with source `import`; nothing is \
pushed. The import commits only when every row is valid and the account balance equals the file's totals row \
(`Total balance` / `Totalsumma`); otherwise nothing is written.",
after_help = "Exit status: 0 imported (or dry run reconciled), 1 database or file error, 2 bad arguments or \
users, 3 the file cannot be imported (header, invalid rows, non-SEK rows without --fx), 4 the balance does not \
reconcile with the totals row. Nothing is written unless the status is 0 and --dry-run is absent."
)]
pub struct Cli {
/// The Kvitt SQLite database (must exist and be migrated).
#[arg(long, value_name = "PATH")]
pub db: PathBuf,
/// The Splitwise export of one group.
#[arg(long, value_name = "CSV")]
pub file: PathBuf,
/// The Kvitt account to import into; created with a 50/50 default ratio if no account has this name.
#[arg(long, value_name = "NAME")]
pub account: String,
/// Maps a member column (the full Splitwise name in the header) to a Kvitt user id or display name. Give it
/// twice, once per household user.
#[arg(long = "user", value_name = "SPLITWISE NAME=USER", required = true)]
pub users: Vec<String>,
/// Converts rows in another currency at this many SEK per unit, e.g. EUR=11.52. Without it non-SEK rows
/// abort the import.
#[arg(long = "fx", value_name = "CUR=SEK PER UNIT")]
pub fx: Vec<String>,
/// Print the summary (and the reconciliation) without writing anything.
#[arg(long)]
pub dry_run: bool,
}
impl Cli {
/// The run's options, or a usage message.
pub fn options(&self) -> Result<Options, ImportError> {
let users = self
.users
.iter()
.map(|u| {
split_assignment(u).map(|(splitwise_name, kvitt_user)| UserArg {
splitwise_name,
kvitt_user,
})
})
.collect::<Result<Vec<_>, _>>()
.map_err(ImportError::Usage)?;
let mut fx = BTreeMap::new();
for text in &self.fx {
let (currency, rate) = split_assignment(text).map_err(ImportError::Usage)?;
let rate = Rate::parse(&rate).map_err(ImportError::Usage)?;
if currency == crate::plan::SEK {
return Err(ImportError::Usage("--fx for SEK makes no sense".into()));
}
if fx.insert(currency.clone(), rate).is_some() {
return Err(ImportError::Usage(format!("--fx {currency} given twice")));
}
}
Ok(Options {
db: self.db.clone(),
file: self.file.clone(),
account: self.account.clone(),
users,
fx,
dry_run: self.dry_run,
})
}
}
/// Parses the command line, runs the import on a single-threaded runtime and prints the summary.
pub fn main() -> ExitCode {
let cli = Cli::parse();
let options = match cli.options() {
Ok(options) => options,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(EXIT_USAGE);
}
};
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(e) => {
eprintln!("cannot start the runtime: {e}");
return ExitCode::from(EXIT_ERROR);
}
};
let result = runtime.block_on(crate::run(&options, chrono::Utc::now(), &SqlAuditWriter));
match result {
Ok(report) => {
println!("{report}");
ExitCode::from(report.exit_code())
}
Err(e) => {
eprintln!("{e}");
ExitCode::from(e.exit_code())
}
}
}
+470
View File
@@ -0,0 +1,470 @@
//! Writing a plan into the database (SPEC §10.8): one `BEGIN IMMEDIATE` transaction, the account found or created,
//! rows whose `import_key` exists skipped, every other row validated with the server's own push checks
//! (`kvitt_server::sync::check`), inserted with a new `server_version`, and audited through [`AuditWriter`] with
//! `source` `import`. Nothing goes through a push queue: the importer has none.
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use kvitt_domain::{BalanceExpense, BalanceSettlement, compute_balances};
use kvitt_server::api::{accounts::name_key, rows};
use kvitt_server::audit::{AuditEvent, AuditWriter};
use kvitt_server::db as server_db;
use kvitt_server::error::AppError;
use kvitt_server::sync::check::{self, Fail};
use kvitt_server::sync::rows as sync_rows;
use kvitt_server::time::format_ts;
use kvitt_server::wire::{
Allocation, AuditAction, AuditSource, EntityType, PushedExpense, PushedSettlement, Split,
SplitMode, User,
};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::parse::RowProblem;
use crate::plan::{PlannedBody, PlannedExpense, PlannedRow, PlannedSettlement, PlannedSplit};
/// Default ratio of a created account: 50/50 (SPEC §10.8).
pub const DEFAULT_SHARE_BP: i64 = 5000;
/// What writing produced (before the caller commits or rolls back).
#[derive(Debug, Default)]
pub struct Written {
pub account_id: String,
pub account_created: bool,
pub expenses_created: usize,
pub settlements_created: usize,
pub skipped_duplicates: usize,
/// Rows the server's checks rejected; non-empty means the caller must roll back.
pub rejected: Vec<RowProblem>,
}
/// The household context of a run.
pub struct Household<'a> {
pub users: &'a [User],
pub owner: Uuid,
}
fn uuid(text: &str) -> Result<Uuid, AppError> {
rows::uuid(text)
}
/// Finds the non-deleted account with this name (case-insensitive, SPEC §14.1 `name_key`), else creates it with
/// the default 50/50 ratio, created by the owner.
async fn find_or_create_account(
conn: &mut SqliteConnection,
name: &str,
household: &Household<'_>,
now_ts: &str,
) -> Result<(String, bool), AppError> {
let key = name_key(name);
let existing: Option<String> =
sqlx::query_scalar("SELECT id FROM accounts WHERE name_key = ? AND deleted_at IS NULL")
.bind(&key)
.fetch_optional(&mut *conn)
.await?;
if let Some(id) = existing {
return Ok((id, false));
}
let id = Uuid::now_v7().to_string();
let version = server_db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \
deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)",
)
.bind(&id)
.bind(name)
.bind(&key)
.bind(household.owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for user in household.users {
sqlx::query(
"INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)",
)
.bind(&id)
.bind(user.id.to_string())
.bind(DEFAULT_SHARE_BP)
.execute(&mut *conn)
.await?;
}
Ok((id, true))
}
/// The id of each non-deleted default category, by key.
async fn default_categories(
conn: &mut SqliteConnection,
) -> Result<BTreeMap<String, String>, AppError> {
let pairs: Vec<(String, String)> = sqlx::query_as(
"SELECT default_key, id FROM categories WHERE default_key IS NOT NULL AND deleted_at IS NULL",
)
.fetch_all(&mut *conn)
.await?;
Ok(pairs.into_iter().collect())
}
async fn key_exists(conn: &mut SqliteConnection, key: &str) -> Result<bool, AppError> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM expenses WHERE import_key = ?1) \
OR EXISTS (SELECT 1 FROM settlements WHERE import_key = ?1)",
)
.bind(key)
.fetch_one(&mut *conn)
.await?;
Ok(exists)
}
fn pushed_expense(
e: &PlannedExpense,
users: &[User],
account_id: Uuid,
category_id: Uuid,
now_ts: &str,
) -> Result<PushedExpense, AppError> {
let (split_mode, splits) = match &e.split {
PlannedSplit::Exact(shares) => (
SplitMode::Exact,
shares
.iter()
.map(|(user, share)| {
Ok(Split {
user_id: uuid(user)?,
share_ore: *share,
share_bp: None,
})
})
.collect::<Result<Vec<_>, AppError>>()?,
),
PlannedSplit::Full(bearer) => (SplitMode::Full, full_splits(e.amount_ore, bearer, users)?),
};
Ok(PushedExpense {
id: Uuid::now_v7(),
account_id,
category_id,
amount_ore: e.amount_ore,
description: e.description.clone(),
notes: e.notes.clone(),
expense_date: e.date.clone(),
payer_user_id: uuid(&e.payer_user_id)?,
split_mode,
splits,
created_at: now_ts.to_owned(),
updated_at: now_ts.to_owned(),
deleted_at: None,
})
}
/// The splits of mode `full` (SPEC §9.1: bearer 10000 bp and the whole amount, the other user 0).
fn full_splits(amount_ore: i64, bearer: &str, users: &[User]) -> Result<Vec<Split>, AppError> {
users
.iter()
.map(|u| {
let bears = u.id.to_string() == bearer;
Ok(Split {
user_id: u.id,
share_ore: if bears { amount_ore } else { 0 },
share_bp: Some(if bears { 10_000 } else { 0 }),
})
})
.collect()
}
fn pushed_settlement(
s: &PlannedSettlement,
account_id: Uuid,
now_ts: &str,
) -> Result<PushedSettlement, AppError> {
Ok(PushedSettlement {
id: Uuid::now_v7(),
from_user_id: uuid(&s.from_user_id)?,
to_user_id: uuid(&s.to_user_id)?,
amount_ore: s.amount_ore,
settlement_date: s.date.clone(),
note: s.note.clone(),
allocations: vec![Allocation {
account_id,
amount_ore: s.amount_ore,
}],
created_at: now_ts.to_owned(),
updated_at: now_ts.to_owned(),
deleted_at: None,
})
}
async fn insert_expense(
conn: &mut SqliteConnection,
p: &PushedExpense,
import_key: &str,
owner: Uuid,
now_ts: &str,
) -> Result<(), AppError> {
let version = server_db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.account_id.to_string())
.bind(p.category_id.to_string())
.bind(p.amount_ore)
.bind(&p.description)
.bind(&p.notes)
.bind(&p.expense_date)
.bind(p.payer_user_id.to_string())
.bind(rows::token_of(&p.split_mode)?)
.bind(import_key)
.bind(owner.to_string())
.bind(owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for split in &p.splits {
sqlx::query(
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
)
.bind(p.id.to_string())
.bind(split.user_id.to_string())
.bind(split.share_ore)
.bind(split.share_bp)
.execute(&mut *conn)
.await?;
}
Ok(())
}
async fn insert_settlement(
conn: &mut SqliteConnection,
p: &PushedSettlement,
import_key: &str,
owner: Uuid,
now_ts: &str,
) -> Result<(), AppError> {
let version = server_db::next_server_version(conn).await?;
sqlx::query(
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.from_user_id.to_string())
.bind(p.to_user_id.to_string())
.bind(p.amount_ore)
.bind(&p.settlement_date)
.bind(&p.note)
.bind(import_key)
.bind(owner.to_string())
.bind(owner.to_string())
.bind(now_ts)
.bind(now_ts)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
for allocation in &p.allocations {
sqlx::query(
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
)
.bind(p.id.to_string())
.bind(allocation.account_id.to_string())
.bind(allocation.amount_ore)
.execute(&mut *conn)
.await?;
}
Ok(())
}
async fn audit_create(
conn: &mut SqliteConnection,
audit: &dyn AuditWriter,
entity_type: EntityType,
entity_id: Uuid,
after: serde_json::Value,
owner: Uuid,
now_ts: &str,
) -> Result<(), AppError> {
audit
.write(
conn,
AuditEvent {
entity_type,
entity_id,
action: AuditAction::Create,
source: AuditSource::Import,
actor_user_id: owner,
actor_device_id: None,
client_at: now_ts.to_owned(),
server_at: now_ts.to_owned(),
before: None,
after,
},
)
.await?;
Ok(())
}
/// Turns a server check failure into a row problem, or passes a real error through.
fn rejected(line: u64, fail: Fail) -> Result<RowProblem, AppError> {
match fail {
Fail::Reject(r) => Ok(RowProblem {
line,
reason: format!("{}: {}", rows::token_of(&r.reason)?, r.message),
}),
Fail::Internal(e) => Err(e),
}
}
/// Writes the planned rows on `conn` (inside the caller's `BEGIN IMMEDIATE` transaction).
pub async fn write(
conn: &mut SqliteConnection,
account_name: &str,
planned: &[PlannedRow],
household: &Household<'_>,
audit: &dyn AuditWriter,
now: DateTime<Utc>,
) -> Result<Written, AppError> {
let now_ts = format_ts(now);
let (account_id, account_created) =
find_or_create_account(conn, account_name, household, &now_ts).await?;
let account_uuid = uuid(&account_id)?;
let categories = default_categories(conn).await?;
let owner = household.owner;
let mut written = Written {
account_id,
account_created,
..Written::default()
};
for row in planned {
if key_exists(conn, &row.import_key).await? {
written.skipped_duplicates += 1;
continue;
}
match &row.body {
PlannedBody::Expense(e) => {
let category_id = categories.get(&e.category_key).ok_or_else(|| {
AppError::internal(format!("default category {} is missing", e.category_key))
})?;
let pushed = pushed_expense(
e,
household.users,
account_uuid,
uuid(category_id)?,
&now_ts,
)?;
let checked = match check::expense(conn, &pushed, household.users, now).await {
Ok(checked) => checked,
Err(fail) => {
written.rejected.push(rejected(row.line, fail)?);
continue;
}
};
insert_expense(conn, &checked, &row.import_key, owner, &now_ts).await?;
let stored = rows::expense(conn, checked.id)
.await?
.ok_or_else(|| AppError::internal("imported expense vanished"))?;
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;
audit_create(
conn,
audit,
EntityType::Expense,
checked.id,
after,
owner,
&now_ts,
)
.await?;
written.expenses_created += 1;
}
PlannedBody::Settlement(s) => {
let pushed = pushed_settlement(s, account_uuid, &now_ts)?;
let checked = match check::settlement(conn, &pushed, household.users, now).await {
Ok(checked) => checked,
Err(fail) => {
written.rejected.push(rejected(row.line, fail)?);
continue;
}
};
insert_settlement(conn, &checked, &row.import_key, owner, &now_ts).await?;
let stored = sync_rows::settlement(conn, checked.id)
.await?
.ok_or_else(|| AppError::internal("imported settlement vanished"))?;
let after = serde_json::to_value(&stored).map_err(AppError::internal)?;
audit_create(
conn,
audit,
EntityType::Settlement,
checked.id,
after,
owner,
&now_ts,
)
.await?;
written.settlements_created += 1;
}
}
}
Ok(written)
}
/// The account's balance per user over its non-deleted expenses and settlement allocations (SPEC §9.3), computed
/// with `kvitt_domain::compute_balances`.
pub async fn account_balance(
conn: &mut SqliteConnection,
account_id: &str,
user_ids: &[String],
) -> Result<BTreeMap<String, i64>, AppError> {
let heads: Vec<(String, i64, String)> = sqlx::query_as(
"SELECT id, amount_ore, payer_user_id FROM expenses WHERE account_id = ? AND deleted_at IS NULL",
)
.bind(account_id)
.fetch_all(&mut *conn)
.await?;
let splits: Vec<(String, String, i64)> = sqlx::query_as(
"SELECT s.expense_id, s.user_id, s.share_ore FROM expense_splits s JOIN expenses e ON e.id = s.expense_id \
WHERE e.account_id = ? AND e.deleted_at IS NULL",
)
.bind(account_id)
.fetch_all(&mut *conn)
.await?;
let mut shares: BTreeMap<String, BTreeMap<String, i64>> = BTreeMap::new();
for (expense_id, user_id, share) in splits {
shares.entry(expense_id).or_default().insert(user_id, share);
}
let expenses: Vec<BalanceExpense> = heads
.into_iter()
.map(|(id, amount_ore, payer_user_id)| BalanceExpense {
account_id: account_id.to_owned(),
amount_ore,
payer_user_id,
shares: shares.remove(&id).unwrap_or_default(),
})
.collect();
let allocations: Vec<(String, String, i64)> = sqlx::query_as(
"SELECT s.from_user_id, s.to_user_id, a.amount_ore FROM settlement_allocations a \
JOIN settlements s ON s.id = a.settlement_id WHERE a.account_id = ? AND s.deleted_at IS NULL",
)
.bind(account_id)
.fetch_all(&mut *conn)
.await?;
let settlements: Vec<BalanceSettlement> = allocations
.into_iter()
.map(|(from_user_id, to_user_id, amount)| BalanceSettlement {
from_user_id,
to_user_id,
allocations: BTreeMap::from([(account_id.to_owned(), amount)]),
})
.collect();
let balances = compute_balances(user_ids, &[account_id.to_owned()], &expenses, &settlements);
Ok(balances
.per_account
.get(account_id)
.cloned()
.unwrap_or_default())
}
+515
View File
@@ -0,0 +1,515 @@
//! `kvitt-import`: imports Splitwise CSV exports, one file per group, into a Kvitt account (SPEC §10.8, FR-17).
//!
//! The run: read and check the file ([`parse`]), resolve the `--user` arguments against the household, build the
//! rows ([`plan`]), then in one `BEGIN IMMEDIATE` transaction find or create the account, skip rows whose
//! `import_key` exists, validate the rest with the server's own push checks, insert and audit them ([`db`]), and
//! reconcile the account balance with the file's totals row. The transaction commits only when every row is valid,
//! the balance matches and this is not a dry run; otherwise it rolls back and nothing is written.
// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
pub mod amount;
pub mod categories;
pub mod cli;
pub mod db;
pub mod parse;
pub mod plan;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use kvitt_server::api::{rows, validate};
use kvitt_server::audit::AuditWriter;
use kvitt_server::error::AppError;
use kvitt_server::wire::{Role, User};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::amount::{Rate, format_ore};
use crate::categories::CategoryTable;
use crate::parse::{FileError, Language, RowProblem};
/// Exit status: success.
pub const EXIT_OK: u8 = 0;
/// Exit status: database or file system error.
pub const EXIT_ERROR: u8 = 1;
/// Exit status: bad command line (clap's own usage errors use 2 as well).
pub const EXIT_USAGE: u8 = 2;
/// Exit status: the file cannot be imported (header, invalid rows, non-SEK rows without `--fx`); nothing written.
pub const EXIT_INVALID: u8 = 3;
/// Exit status: the balance does not reconcile with the file's totals row; nothing written.
pub const EXIT_MISMATCH: u8 = 4;
/// One `--user` argument: the Splitwise member name and the Kvitt user (an id, or a display name to resolve).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserArg {
pub splitwise_name: String,
pub kvitt_user: String,
}
/// A whole run's inputs.
#[derive(Debug, Clone)]
pub struct Options {
pub db: PathBuf,
pub file: PathBuf,
pub account: String,
pub users: Vec<UserArg>,
pub fx: BTreeMap<String, Rate>,
pub dry_run: bool,
}
/// Why a run failed.
#[derive(Debug)]
pub enum ImportError {
/// Bad arguments or unresolvable users.
Usage(String),
/// The header is not one of the two variants or names other members.
Header(String),
/// Rows that cannot be imported, with their line numbers.
InvalidRows(Vec<RowProblem>),
/// Non-SEK rows without a `--fx` rate: currency → rows.
MissingFx(BTreeMap<String, usize>),
/// Database or I/O.
Io(String),
}
impl ImportError {
pub fn exit_code(&self) -> u8 {
match self {
Self::Usage(_) => EXIT_USAGE,
Self::Header(_) | Self::InvalidRows(_) | Self::MissingFx(_) => EXIT_INVALID,
Self::Io(_) => EXIT_ERROR,
}
}
}
impl std::fmt::Display for ImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Usage(m) => write!(f, "usage: {m}"),
Self::Header(m) => write!(f, "header: {m}; nothing written"),
Self::InvalidRows(problems) => {
writeln!(f, "{} invalid rows; nothing written:", problems.len())?;
for p in problems {
writeln!(f, " line {}: {}", p.line, p.reason)?;
}
Ok(())
}
Self::MissingFx(currencies) => {
write!(f, "rows not in SEK and no --fx rate; nothing written:")?;
for (currency, count) in currencies {
write!(f, " {currency} ({count} rows)")?;
}
Ok(())
}
Self::Io(m) => write!(f, "error: {m}"),
}
}
}
impl From<AppError> for ImportError {
fn from(e: AppError) -> Self {
Self::Io(format!("database: {} ({})", e.message, e.code))
}
}
impl From<sqlx::Error> for ImportError {
fn from(e: sqlx::Error) -> Self {
Self::Io(format!("database: {e}"))
}
}
/// One member's line of the reconciliation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberBalance {
pub splitwise_name: String,
pub user_id: String,
/// The Splitwise totals row value (converted to SEK öre).
pub splitwise_ore: i64,
/// `net(u)` of the Kvitt account after the import (SPEC §9.3).
pub kvitt_ore: i64,
}
/// The reconciliation of the account balance with the file's totals row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reconciliation {
/// Per member, in header order; empty without a totals row.
pub members: Vec<MemberBalance>,
/// Whether the file had a totals row.
pub has_totals: bool,
pub matched: bool,
/// Whether `--fx` conversion took place (rounding per value can then make an exact match impossible, so a
/// mismatch is reported but does not fail the run).
pub converted: bool,
}
impl Reconciliation {
/// Whether the run may commit: an exact match, or a mismatch caused only by `--fx` rounding.
pub fn accepted(&self) -> bool {
self.has_totals && (self.matched || self.converted)
}
}
/// What a run did (or, in a dry run, would do).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub dry_run: bool,
pub committed: bool,
pub language: Language,
pub account_name: String,
pub account_id: String,
pub account_created: bool,
/// Data rows (expenses and payments); blank and totals rows excluded.
pub rows_read: usize,
pub blank_rows: usize,
pub totals_rows: usize,
pub expenses_created: usize,
pub settlements_created: usize,
pub skipped_duplicates: usize,
/// Unmapped Splitwise category → rows (imported as `other`).
pub unmapped_categories: BTreeMap<String, usize>,
pub non_sek_rows: usize,
pub reconciliation: Reconciliation,
}
impl Report {
pub fn unmapped_rows(&self) -> usize {
self.unmapped_categories.values().sum()
}
/// The process exit status for this report.
pub fn exit_code(&self) -> u8 {
if self.reconciliation.accepted() {
EXIT_OK
} else {
EXIT_MISMATCH
}
}
}
impl std::fmt::Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut out = String::new();
let verb = if self.dry_run {
"would create"
} else {
"created"
};
let _ = writeln!(
out,
"kvitt-import summary{}",
if self.dry_run {
" (dry run, nothing written)"
} else {
""
}
);
let _ = writeln!(out, " header variant: {}", self.language.as_str());
let _ = writeln!(
out,
" account: {} ({}{})",
self.account_name,
self.account_id,
if self.account_created {
", new"
} else {
", existing"
}
);
let _ = writeln!(out, " rows read: {}", self.rows_read);
let _ = writeln!(out, " totals rows: {}", self.totals_rows);
let _ = writeln!(out, " expenses {verb}: {}", self.expenses_created);
let _ = writeln!(out, " settlements {verb}: {}", self.settlements_created);
let _ = writeln!(out, " skipped duplicates: {}", self.skipped_duplicates);
let _ = writeln!(out, " non-SEK rows: {}", self.non_sek_rows);
let _ = writeln!(out, " unknown categories (imported as other):");
if self.unmapped_categories.is_empty() {
let _ = writeln!(out, " none");
}
for (name, count) in &self.unmapped_categories {
let _ = writeln!(out, " {name}: {count} rows");
}
if self.unmapped_rows() > 0 {
let _ = writeln!(
out,
" WARNING: {} rows with an unmapped category",
self.unmapped_rows()
);
}
let r = &self.reconciliation;
let _ = writeln!(
out,
" reconciliation (account balance vs the Splitwise totals row, SEK):"
);
if !r.has_totals {
let _ = writeln!(out, " MISMATCH: the file has no totals row");
}
for m in &r.members {
let _ = writeln!(
out,
" {}: splitwise {}, kvitt {}",
m.splitwise_name,
format_ore(m.splitwise_ore),
format_ore(m.kvitt_ore)
);
}
if r.has_totals {
let verdict = match (r.matched, r.converted) {
(true, _) => "match",
(false, true) => "MISMATCH after --fx rounding (accepted)",
(false, false) => "MISMATCH",
};
let _ = writeln!(out, " result: {verdict}");
}
let status = if self.committed {
"committed"
} else if self.dry_run {
"rolled back (dry run)"
} else {
"rolled back, nothing written"
};
let _ = write!(out, " transaction: {status}");
f.write_str(&out)
}
}
/// Parses `NAME=VALUE` (the value after the last `=`, so names may not contain `=` but nothing else is special).
pub fn split_assignment(text: &str) -> Result<(String, String), String> {
let (name, value) = text
.rsplit_once('=')
.ok_or_else(|| format!("{text:?} is not NAME=VALUE"))?;
let (name, value) = (name.trim(), value.trim());
if name.is_empty() || value.is_empty() {
return Err(format!("{text:?} is not NAME=VALUE"));
}
Ok((name.to_owned(), value.to_owned()))
}
/// Opens an existing database (never creates one) and checks its schema version.
pub async fn open(path: &std::path::Path) -> Result<kvitt_server::db::Db, ImportError> {
if !path.is_file() {
return Err(ImportError::Io(format!(
"no database at {}",
path.display()
)));
}
let options = kvitt_server::db::pool::options(path).create_if_missing(false);
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?;
let version: Option<String> =
sqlx::query_scalar("SELECT value FROM meta WHERE key = 'schema_version'")
.fetch_optional(&pool)
.await
.map_err(|e| ImportError::Io(format!("not a Kvitt database: {e}")))?;
let expected = kvitt_server::db::SCHEMA_VERSION.to_string();
if version.as_deref() != Some(expected.as_str()) {
return Err(ImportError::Io(format!(
"schema version {version:?}, expected {expected}; run `kvitt-server migrate` first"
)));
}
Ok(pool)
}
/// Resolves one `--user` value: a user id, or else a display name that exactly one user has.
fn resolve_user(value: &str, users: &[User]) -> Result<Uuid, ImportError> {
if let Ok(id) = Uuid::parse_str(value) {
return users
.iter()
.find(|u| u.id == id)
.map(|u| u.id)
.ok_or_else(|| ImportError::Usage(format!("no user with id {id}")));
}
let matches: Vec<&User> = users.iter().filter(|u| u.display_name == value).collect();
match matches[..] {
[user] => Ok(user.id),
[] => Err(ImportError::Usage(format!(
"no user with id or display name {value:?}"
))),
_ => Err(ImportError::Usage(format!(
"display name {value:?} is ambiguous; give the user id"
))),
}
}
/// The household, the owner and the user id of each member column (header order).
async fn resolve_household(
conn: &mut SqliteConnection,
options: &Options,
members: &[String],
) -> Result<(Vec<User>, Uuid, Vec<String>), ImportError> {
let users = rows::complete_household(conn)
.await
.map_err(|e| ImportError::Usage(format!("household: {}", e.message)))?;
let owner = users
.iter()
.find(|u| u.role == Role::Admin)
.map(|u| u.id)
.ok_or_else(|| ImportError::Usage("the household has no admin (owner)".into()))?;
let mut by_name: BTreeMap<&str, Uuid> = BTreeMap::new();
for arg in &options.users {
let id = resolve_user(&arg.kvitt_user, &users)?;
by_name.insert(arg.splitwise_name.as_str(), id);
}
let ids: BTreeSet<Uuid> = by_name.values().copied().collect();
let household: BTreeSet<Uuid> = users.iter().map(|u| u.id).collect();
if ids != household {
return Err(ImportError::Usage(
"the two --user arguments must name the two household users, one each".into(),
));
}
let member_ids = members
.iter()
.map(|m| {
by_name
.get(m.as_str())
.map(Uuid::to_string)
.ok_or_else(|| ImportError::Header(format!("member column {m:?} has no --user")))
})
.collect::<Result<Vec<_>, _>>()?;
Ok((users, owner, member_ids))
}
/// Checks the command line against the file header: two `--user` arguments with distinct names, the same set of
/// names as the header's member columns.
fn check_users(options: &Options, members: &[String]) -> Result<(), ImportError> {
let names: BTreeSet<&str> = options
.users
.iter()
.map(|u| u.splitwise_name.as_str())
.collect();
if options.users.len() != 2 || names.len() != 2 {
return Err(ImportError::Usage(
"give exactly two --user arguments with distinct Splitwise names".into(),
));
}
let header: BTreeSet<&str> = members.iter().map(String::as_str).collect();
if names != header {
return Err(ImportError::Header(format!(
"the member columns {members:?} do not match the --user names {:?}",
names.into_iter().collect::<Vec<_>>()
)));
}
Ok(())
}
/// Runs one import. `now` is the import time (`created_at`, `updated_at`, audit times; dates are checked against
/// it). Returns the report whether or not it committed (see [`Report::committed`]); errors write nothing.
pub async fn run(
options: &Options,
now: DateTime<Utc>,
audit: &dyn AuditWriter,
) -> Result<Report, ImportError> {
let account_name = validate::text("account", &options.account, 1, validate::ACCOUNT_NAME_MAX)
.map_err(|e| ImportError::Usage(e.message))?;
let table = CategoryTable::committed().map_err(ImportError::Io)?;
let bytes = std::fs::read(&options.file)
.map_err(|e| ImportError::Io(format!("{}: {e}", options.file.display())))?;
let file = parse::parse(&bytes).map_err(|e| match e {
FileError::Header(m) => ImportError::Header(m),
FileError::Csv(m) => ImportError::InvalidRows(vec![RowProblem { line: 0, reason: m }]),
})?;
check_users(options, &file.header.members)?;
let pool = open(&options.db).await?;
let mut conn = pool.acquire().await?;
let (users, owner, member_ids) =
resolve_household(&mut conn, options, &file.header.members).await?;
// An existing account keeps its stored name, which is also the name in the import key, so a re-run with the
// name in another case still finds its rows.
let existing: Option<String> =
sqlx::query_scalar("SELECT name FROM accounts WHERE name_key = ? AND deleted_at IS NULL")
.bind(kvitt_server::api::accounts::name_key(&account_name))
.fetch_optional(&mut *conn)
.await?;
let account_name = existing.unwrap_or(account_name);
drop(conn);
let owner_text = owner.to_string();
let plan = plan::build(
&file,
&plan::PlanContext {
account_name: &account_name,
member_user_ids: &member_ids,
owner_user_id: &owner_text,
fx: &options.fx,
categories: &table,
},
);
if !plan.missing_fx.is_empty() {
return Err(ImportError::MissingFx(plan.missing_fx));
}
if !plan.problems.is_empty() {
return Err(ImportError::InvalidRows(plan.problems));
}
let mut tx = kvitt_server::db::begin_immediate(&pool).await?;
let household = db::Household {
users: &users,
owner,
};
let written = db::write(&mut tx, &account_name, &plan.rows, &household, audit, now).await?;
if !written.rejected.is_empty() {
tx.rollback().await?;
return Err(ImportError::InvalidRows(written.rejected));
}
let balance =
db::account_balance(&mut tx, &written.account_id, &rows::user_ids(&users)).await?;
let reconciliation = match &plan.expected_totals {
Some(expected) => {
let members: Vec<MemberBalance> = file
.header
.members
.iter()
.zip(&member_ids)
.map(|(name, id)| MemberBalance {
splitwise_name: name.clone(),
user_id: id.clone(),
splitwise_ore: expected.get(id).copied().unwrap_or(0),
kvitt_ore: balance.get(id).copied().unwrap_or(0),
})
.collect();
let matched = members.iter().all(|m| m.splitwise_ore == m.kvitt_ore);
Reconciliation {
members,
has_totals: true,
matched,
converted: plan.converted,
}
}
None => Reconciliation {
members: Vec::new(),
has_totals: false,
matched: false,
converted: plan.converted,
},
};
let commit = !options.dry_run && reconciliation.accepted();
if commit {
tx.commit().await?;
} else {
tx.rollback().await?;
}
pool.close().await;
Ok(Report {
dry_run: options.dry_run,
committed: commit,
language: file.header.language,
account_name,
account_id: written.account_id,
account_created: written.account_created,
rows_read: file.rows.len(),
blank_rows: file.blank_rows,
totals_rows: file.totals.len(),
expenses_created: written.expenses_created,
settlements_created: written.settlements_created,
skipped_duplicates: written.skipped_duplicates,
unmapped_categories: plan.unmapped,
non_sek_rows: plan.non_sek_rows,
reconciliation,
})
}
+2 -12
View File
@@ -1,20 +1,10 @@
//! `kvitt-import`: the Splitwise CSV importer (SPEC §10.8). M9 owns this crate; in M2a it prints its usage and
//! exits 2.
//! `kvitt-import`: the Splitwise CSV importer (SPEC §10.8). The work is in the library (`kvitt_import`).
// No unwrap/expect outside tests (CLAUDE.md server rule); integration tests are separate crates and may use them.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
use std::process::ExitCode;
// The importer reuses the server crate for database access and audit writing and the domain crate for the money
// rules (SPEC §6.3); referenced here so the dependency edges exist from the start.
use kvitt_domain as _;
use kvitt_server as _;
const USAGE: &str =
"usage: kvitt-import <options> (Splitwise CSV importer, SPEC §10.8; not implemented in M2a)";
fn main() -> ExitCode {
eprintln!("{USAGE}");
ExitCode::from(2)
kvitt_import::cli::main()
}
+288
View File
@@ -0,0 +1,288 @@
//! Reading a Splitwise export (SPEC §10.8): CSV quoting, the English or Swedish header with the member columns
//! named by full Splitwise name, and row classification (data rows, the trailing totals row, blank rows).
use std::collections::BTreeMap;
/// The fixed leading columns of the English export.
pub const ENGLISH_COLUMNS: [&str; 5] = ["Date", "Description", "Category", "Cost", "Currency"];
/// The fixed leading columns of the Swedish export.
pub const SWEDISH_COLUMNS: [&str; 5] = ["Datum", "Beskrivning", "Kategori", "Kostnad", "Valuta"];
/// `Description` of the trailing totals row, per language.
pub const TOTAL_DESCRIPTIONS: [&str; 2] = ["Total balance", "Totalsumma"];
/// Member columns per file: the household has exactly two users.
pub const MEMBER_COLUMNS: usize = 2;
/// The export's UI language.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
English,
Swedish,
}
impl Language {
pub fn as_str(self) -> &'static str {
match self {
Self::English => "English",
Self::Swedish => "Swedish",
}
}
}
/// The parsed header.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Header {
pub language: Language,
/// Member names in header order.
pub members: Vec<String>,
}
/// One data row (an expense or a payment), its fields as in the file (after CSV unquoting).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataRow {
/// 1-based line number in the file.
pub line: u64,
pub date: String,
pub description: String,
pub category: String,
pub cost: String,
pub currency: String,
/// Member nets in header order.
pub nets: Vec<String>,
/// 0-based index of this row among identical rows earlier in the file (SPEC §10.8 `<n>`).
pub occurrence: usize,
}
/// A totals row: the member nets of the whole group, per currency.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TotalRow {
pub line: u64,
pub currency: String,
/// Member nets in header order.
pub nets: Vec<String>,
}
/// A row that cannot be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowProblem {
pub line: u64,
pub reason: String,
}
/// A whole parsed file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFile {
pub header: Header,
pub rows: Vec<DataRow>,
pub totals: Vec<TotalRow>,
pub blank_rows: usize,
pub problems: Vec<RowProblem>,
}
/// Why a file cannot be read at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileError {
/// Wrong or missing header (SPEC §10.8: fails the import, writing nothing).
Header(String),
/// Not CSV at all (bad UTF-8, broken quoting).
Csv(String),
}
impl std::fmt::Display for FileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Header(m) | Self::Csv(m) => f.write_str(m),
}
}
}
fn expected_headers() -> String {
format!(
"expected \"{}\" (English) or \"{}\" (Swedish), then exactly {MEMBER_COLUMNS} member columns named by full \
Splitwise name",
ENGLISH_COLUMNS.join(","),
SWEDISH_COLUMNS.join(",")
)
}
/// Checks the header line.
pub fn parse_header(fields: &[String]) -> Result<Header, FileError> {
let fixed: Vec<&str> = fields.iter().take(5).map(String::as_str).collect();
let language = if fixed == ENGLISH_COLUMNS {
Language::English
} else if fixed == SWEDISH_COLUMNS {
Language::Swedish
} else {
return Err(FileError::Header(format!(
"unrecognised header; {}",
expected_headers()
)));
};
let members: Vec<String> = fields.iter().skip(5).map(|m| m.trim().to_owned()).collect();
if members.len() != MEMBER_COLUMNS {
return Err(FileError::Header(format!(
"the header has {} member columns; {}",
members.len(),
expected_headers()
)));
}
if members.iter().any(String::is_empty) {
return Err(FileError::Header("a member column has no name".into()));
}
if members[0] == members[1] {
return Err(FileError::Header(
"the two member columns have the same name".into(),
));
}
Ok(Header { language, members })
}
/// The 1-based line number of the record csv reports at byte `offset`. csv reports the offset where it started
/// reading, before the empty lines it skips, so those are stepped over first.
fn line_at(bytes: &[u8], offset: u64) -> u64 {
let mut end = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(bytes.len());
while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') {
end += 1;
}
let newlines = bytes[..end].iter().filter(|b| **b == b'\n').count();
u64::try_from(newlines)
.unwrap_or(u64::MAX)
.saturating_add(1)
}
/// Parses a whole export. Rows that do not have the header's column count are problems, not errors, so every bad
/// row is reported at once.
pub fn parse(bytes: &[u8]) -> Result<ParsedFile, FileError> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let mut reader = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(bytes);
let mut records = reader.records();
let header_fields: Vec<String> = match records.next() {
Some(Ok(record)) => record.iter().map(str::to_owned).collect(),
Some(Err(e)) => return Err(FileError::Csv(format!("cannot read the header: {e}"))),
None => {
return Err(FileError::Header(format!(
"the file is empty; {}",
expected_headers()
)));
}
};
let header = parse_header(&header_fields)?;
let width = 5 + header.members.len();
let mut rows = Vec::new();
let mut totals = Vec::new();
let mut problems = Vec::new();
let mut blank_rows = 0;
let mut occurrences: BTreeMap<Vec<String>, usize> = BTreeMap::new();
for record in records {
let record = record.map_err(|e| FileError::Csv(format!("CSV error: {e}")))?;
// csv's own line counter does not count the empty lines it skips.
let line = record.position().map_or(0, |p| line_at(bytes, p.byte()));
let fields: Vec<String> = record.iter().map(str::to_owned).collect();
if fields.iter().all(|f| f.trim().is_empty()) {
blank_rows += 1;
continue;
}
if fields.len() != width {
problems.push(RowProblem {
line,
reason: format!("{} fields, the header has {width}", fields.len()),
});
continue;
}
let nets = fields[5..].to_vec();
if TOTAL_DESCRIPTIONS.contains(&fields[1].trim()) && fields[3].trim().is_empty() {
totals.push(TotalRow {
line,
currency: fields[4].trim().to_owned(),
nets,
});
continue;
}
let count = occurrences.entry(fields.clone()).or_insert(0);
let occurrence = *count;
*count += 1;
rows.push(DataRow {
line,
date: fields[0].clone(),
description: fields[1].clone(),
category: fields[2].clone(),
cost: fields[3].clone(),
currency: fields[4].clone(),
nets,
occurrence,
});
}
Ok(ParsedFile {
header,
rows,
totals,
blank_rows,
problems,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(line: &str) -> Vec<String> {
line.split(',').map(str::to_owned).collect()
}
#[test]
fn headers() {
let en = parse_header(&fields("Date,Description,Category,Cost,Currency,A B,C D")).unwrap();
assert_eq!(en.language, Language::English);
assert_eq!(en.members, ["A B", "C D"]);
let sv =
parse_header(&fields("Datum,Beskrivning,Kategori,Kostnad,Valuta,C D,A B")).unwrap();
assert_eq!(sv.language, Language::Swedish);
assert_eq!(sv.members, ["C D", "A B"]);
for bad in [
"Date,Description,Category,Cost",
"Date,Description,Category,Cost,Currency,A B",
"Date,Description,Category,Cost,Currency,A B,C D,E F",
"Datum,Description,Category,Cost,Currency,A B,C D",
"Date,Description,Category,Cost,Valuta,A B,C D",
"Date,Description,Category,Cost,Currency,A B,A B",
"Date,Description,Category,Cost,Currency,A B,",
] {
assert!(
matches!(parse_header(&fields(bad)), Err(FileError::Header(_))),
"{bad}"
);
}
}
#[test]
fn rows_and_occurrences() {
let text = "\u{feff}Date,Description,Category,Cost,Currency,A B,C D\n\n\
2025-01-01,\"Milk, bread\",Groceries,10.00,SEK,5.00,-5.00\n\
2025-01-01,\"Milk, bread\",Groceries,10.00,SEK,5.00,-5.00\n\
,,,,,,\n\
2025-01-02,Short,Groceries,10.00,SEK\n\
\n\
2025-01-03,Total balance, , ,SEK,5.00,-5.00\n";
let parsed = parse(text.as_bytes()).unwrap();
assert_eq!(parsed.rows.len(), 2);
assert_eq!(parsed.rows[0].description, "Milk, bread");
assert_eq!(parsed.rows[0].occurrence, 0);
assert_eq!(parsed.rows[1].occurrence, 1);
assert_eq!(parsed.rows[0].line, 3);
assert_eq!(parsed.totals.len(), 1);
assert_eq!(parsed.totals[0].nets, ["5.00", "-5.00"]);
assert_eq!(parsed.problems.len(), 1);
assert_eq!(parsed.problems[0].line, 6);
assert!(parsed.blank_rows >= 1);
}
#[test]
fn empty_and_broken_files() {
assert!(matches!(parse(b""), Err(FileError::Header(_))));
assert!(matches!(parse(b"a,b\n"), Err(FileError::Header(_))));
}
}
+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);
}
}
@@ -0,0 +1,2 @@
Date,Description,Category,Amount,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
1 Date Description Category Amount Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
+15
View File
@@ -0,0 +1,15 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-01-05,Weekly shop,Groceries,300.00,SEK,150.00,-150.00
2025-01-06,"Paint, brushes and tape",Household supplies,120.50,SEK,-60.25,60.25
2025-01-07,Cinema,Movies,200.00,SEK,0.00,0.00
2025-01-08,Bus card,Bus/train,100.00,SEK,-100.00,100.00
2025-01-09,Mystery box,Widgets,50.00,SEK,25.00,-25.00
2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00
2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00
2025-01-11,An extremely long synthetic description written only to exercise the truncation rule of the importer at one hundred code points,General,10.00,SEK,5.00,-5.00
2025-01-12,Robin paid Alex,Payment,100.00,SEK,-100.00,100.00
2025-01-13,"Rent, January",Rent,1000.00,SEK,500.00,-500.00
2025-01-14,Total balance, , ,SEK,459.75,-459.75
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-01-05 Weekly shop Groceries 300.00 SEK 150.00 -150.00
3 2025-01-06 Paint, brushes and tape Household supplies 120.50 SEK -60.25 60.25
4 2025-01-07 Cinema Movies 200.00 SEK 0.00 0.00
5 2025-01-08 Bus card Bus/train 100.00 SEK -100.00 100.00
6 2025-01-09 Mystery box Widgets 50.00 SEK 25.00 -25.00
7 2025-01-10 Coffee Dining out 40.00 SEK 20.00 -20.00
8 2025-01-10 Coffee Dining out 40.00 SEK 20.00 -20.00
9 2025-01-11 An extremely long synthetic description written only to exercise the truncation rule of the importer at one hundred code points General 10.00 SEK 5.00 -5.00
10 2025-01-12 Robin paid Alex Payment 100.00 SEK -100.00 100.00
11 2025-01-13 Rent, January Rent 1000.00 SEK 500.00 -500.00
12 2025-01-14 Total balance SEK 459.75 -459.75
@@ -0,0 +1,7 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-04-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-13-01,Bad date,Groceries,10.00,SEK,5.00,-5.00
2025-04-03,Total balance, , ,SEK,10.00,-10.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-04-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-13-01 Bad date Groceries 10.00 SEK 5.00 -5.00
4 2025-04-03 Total balance SEK 10.00 -10.00
@@ -0,0 +1,7 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-04-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-04-02,Broken row,Groceries,10.00,SEK,5.00,-4.00
2025-04-03,Total balance, , ,SEK,10.00,-9.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-04-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-04-02 Broken row Groceries 10.00 SEK 5.00 -4.00
4 2025-04-03 Total balance SEK 10.00 -9.00
@@ -0,0 +1,6 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-05-02,Total balance, , ,SEK,7.00,-7.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-05-02 Total balance SEK 7.00 -7.00
@@ -0,0 +1,2 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
+8
View File
@@ -0,0 +1,8 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-03-01,Market,Groceries,100.00,SEK,50.00,-50.00
2025-03-02,Hotel abroad,Hotel,10.00,EUR,5.00,-5.00
2025-03-03,Total balance, , ,SEK,50.00,-50.00
2025-03-03,Total balance, , ,EUR,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-03-01 Market Groceries 100.00 SEK 50.00 -50.00
3 2025-03-02 Hotel abroad Hotel 10.00 EUR 5.00 -5.00
4 2025-03-03 Total balance SEK 50.00 -50.00
5 2025-03-03 Total balance EUR 5.00 -5.00
+10
View File
@@ -0,0 +1,10 @@
Datum,Beskrivning,Kategori,Kostnad,Valuta,Robin Sample,Alex Example
2025-02-01,Matkasse,Livsmedel,200.00,SEK,-100.00,100.00
2025-02-02,"Lampa, golv",Möbler,80.00,SEK,40.00,-40.00
2025-02-03,Okänd sak,Påhittad kategori,10.00,SEK,5.00,-5.00
2025-02-04,Betalning till Alex,Betalning,55.00,SEK,55.00,-55.00
2025-02-05,Biljett,Buss/tåg,30.00,SEK,-15.00,15.00
2025-02-06,Totalsumma, , ,SEK,-15.00,15.00
1 Datum Beskrivning Kategori Kostnad Valuta Robin Sample Alex Example
2 2025-02-01 Matkasse Livsmedel 200.00 SEK -100.00 100.00
3 2025-02-02 Lampa, golv Möbler 80.00 SEK 40.00 -40.00
4 2025-02-03 Okänd sak Påhittad kategori 10.00 SEK 5.00 -5.00
5 2025-02-04 Betalning till Alex Betalning 55.00 SEK 55.00 -55.00
6 2025-02-05 Biljett Buss/tåg 30.00 SEK -15.00 15.00
7 2025-02-06 Totalsumma SEK -15.00 15.00
@@ -0,0 +1,4 @@
Date,Description,Category,Cost,Currency,Alex Example,Sam Other
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-05-02,Total balance, , ,SEK,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Sam Other
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-05-02 Total balance SEK 5.00 -5.00
+804
View File
@@ -0,0 +1,804 @@
//! Integration tests of `kvitt-import` (SPEC §10.8, FR-17 AC-17.1 to AC-17.6) over synthetic fixtures only, against
//! a temporary database built with the server's migrations. Member names, descriptions and amounts are made up.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::{DateTime, Utc};
use kvitt_import::amount::Rate;
use kvitt_import::categories::CategoryTable;
use kvitt_import::{ImportError, Options, Report, UserArg};
use kvitt_server::audit::SqlAuditWriter;
use kvitt_server::auth::sha256_hex;
use kvitt_server::db;
use kvitt_server::time::parse_ts;
use sqlx::SqlitePool;
use uuid::Uuid;
const OWNER: &str = "0192c000-0000-7000-8000-000000000001";
const PARTNER: &str = "0192c000-0000-7000-8000-000000000002";
const OWNER_NAME: &str = "Alex Example";
const PARTNER_NAME: &str = "Robin Sample";
const NOW: &str = "2026-10-17T12:00:00.000Z";
fn now() -> DateTime<Utc> {
parse_ts(NOW).unwrap()
}
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
/// A migrated database with the two synthetic users, removed on drop.
struct TestDb {
dir: PathBuf,
path: PathBuf,
}
impl Drop for TestDb {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
impl TestDb {
async fn new() -> Self {
let dir = std::env::temp_dir().join(format!("kvitt-import-{}", Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("kvitt.sqlite");
let pool = db::connect(&path).await.unwrap();
db::migrate(&pool).await.unwrap();
for (id, role, name, version) in [
(OWNER, "admin", "Alex", 11),
(PARTNER, "member", "Robin", 12),
] {
sqlx::query(
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
VALUES (?, ?, ?, NULL, '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z', ?)",
)
.bind(id)
.bind(role)
.bind(name)
.bind(version)
.execute(&pool)
.await
.unwrap();
}
sqlx::query("UPDATE sync_sequence SET value = 12")
.execute(&pool)
.await
.unwrap();
pool.close().await;
Self { dir, path }
}
async fn pool(&self) -> SqlitePool {
db::connect(&self.path).await.unwrap()
}
async fn scalar(&self, sql: &str) -> i64 {
let pool = self.pool().await;
let value: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(sql.to_owned()))
.fetch_one(&pool)
.await
.unwrap();
pool.close().await;
value
}
/// Row counts of every table the importer may write, plus the sequence value.
async fn snapshot(&self) -> Vec<i64> {
let mut out = Vec::new();
for table in [
"accounts",
"account_default_shares",
"expenses",
"expense_splits",
"settlements",
"settlement_allocations",
"audit_log",
"sync_mutations",
] {
out.push(self.scalar(&format!("SELECT COUNT(*) FROM {table}")).await);
}
out.push(self.scalar("SELECT value FROM sync_sequence").await);
out
}
fn options(&self, file: &str, account: &str) -> Options {
Options {
db: self.path.clone(),
file: fixture(file),
account: account.to_owned(),
users: vec![
UserArg {
splitwise_name: OWNER_NAME.into(),
kvitt_user: OWNER.into(),
},
UserArg {
splitwise_name: PARTNER_NAME.into(),
kvitt_user: PARTNER.into(),
},
],
fx: BTreeMap::new(),
dry_run: false,
}
}
async fn import(&self, options: &Options) -> Result<Report, ImportError> {
kvitt_import::run(options, now(), &SqlAuditWriter).await
}
}
/// `(payer, split_mode, amount, owner share, partner share, owner bp, description, notes, category key, key)` of
/// every expense, by expense date and id.
type ExpenseLine = (
String,
String,
i64,
i64,
i64,
Option<i64>,
String,
String,
String,
String,
);
/// `(entity_type, action, source, actor_user_id, actor_device_id, client_at, server_at, changes)`.
type AuditLine = (
String,
String,
String,
String,
Option<String>,
String,
String,
String,
);
async fn expenses(db: &TestDb) -> Vec<ExpenseLine> {
let pool = db.pool().await;
let rows: Vec<ExpenseLine> = sqlx::query_as(
"SELECT e.payer_user_id, e.split_mode, e.amount_ore, so.share_ore, sp.share_ore, so.share_bp, \
e.description, e.notes, c.default_key, e.import_key FROM expenses e \
JOIN expense_splits so ON so.expense_id = e.id AND so.user_id = ?1 \
JOIN expense_splits sp ON sp.expense_id = e.id AND sp.user_id = ?2 \
JOIN categories c ON c.id = e.category_id ORDER BY e.expense_date, e.id",
)
.bind(OWNER)
.bind(PARTNER)
.fetch_all(&pool)
.await
.unwrap();
pool.close().await;
rows
}
#[tokio::test]
async fn english_export_imports_expenses_settlements_and_audit() {
let db = TestDb::new().await;
let report = db
.import(&db.options("english.csv", "Shared"))
.await
.unwrap();
assert!(report.committed);
assert!(report.account_created);
assert_eq!(report.rows_read, 10);
assert_eq!(report.totals_rows, 1);
assert_eq!(report.expenses_created, 9);
assert_eq!(report.settlements_created, 1);
assert_eq!(report.skipped_duplicates, 0);
assert_eq!(report.non_sek_rows, 0);
assert_eq!(
report.unmapped_categories,
BTreeMap::from([("Widgets".to_owned(), 1)])
);
assert_eq!(report.unmapped_rows(), 1);
assert!(report.reconciliation.matched);
assert_eq!(report.exit_code(), 0);
let summary = report.to_string();
assert!(summary.contains("Widgets: 1 rows"), "{summary}");
assert!(
summary.contains("WARNING: 1 rows with an unmapped category"),
"{summary}"
);
assert!(summary.contains("result: match"), "{summary}");
// The account: created by the owner with the default 50/50 ratio.
assert_eq!(
db.scalar("SELECT COUNT(*) FROM accounts WHERE name = 'Shared' AND created_by_user_id = '0192c000-0000-7000-8000-000000000001'")
.await,
1
);
assert_eq!(
db.scalar("SELECT COUNT(*) FROM account_default_shares WHERE share_bp = 5000")
.await,
2
);
let rows = expenses(&db).await;
assert_eq!(rows.len(), 9);
// Owner paid, exact split from the nets.
assert_eq!(
(
&rows[0].0[..],
&rows[0].1[..],
rows[0].2,
rows[0].3,
rows[0].4,
rows[0].5
),
(OWNER, "exact", 30000, 15000, 15000, None)
);
assert_eq!(rows[0].8, "groceries");
// Quoted description with a comma; partner paid.
assert_eq!(rows[1].6, "Paint, brushes and tape");
assert_eq!(
(&rows[1].0[..], rows[1].3, rows[1].4),
(PARTNER, 6025, 6025)
);
assert_eq!(rows[1].8, "home");
// Both nets 0: full on the owner.
assert_eq!(
(
&rows[2].0[..],
&rows[2].1[..],
rows[2].3,
rows[2].4,
rows[2].5
),
(OWNER, "full", 20000, 0, Some(10_000))
);
assert_eq!(rows[2].8, "entertainment");
// Partner paid all of it for the owner: payer share 0.
assert_eq!((&rows[3].0[..], rows[3].3, rows[3].4), (PARTNER, 10000, 0));
assert_eq!(rows[3].8, "transport");
// Unknown category to other.
assert_eq!(rows[4].8, "other");
// Two identical lines both imported, with different keys.
assert_eq!(rows[5].6, "Coffee");
assert_eq!(rows[6].6, "Coffee");
assert_ne!(rows[5].9, rows[6].9);
// Truncated description: 100 code points, the full text in notes.
assert_eq!(rows[7].6.chars().count(), 100);
assert!(rows[7].7.starts_with(&rows[7].6));
assert_eq!(rows[7].7.chars().count(), 127);
assert_eq!(rows[8].6, "Rent, January");
assert!(
rows.iter()
.all(|r| r.7.is_empty() || r.6.chars().count() == 100)
);
// The import key formula of SPEC §10.8, for the first row and for the second of the two identical lines.
let key = |line: &str, n: usize| {
let fields: Vec<&str> = line.split(',').collect();
sha256_hex(&format!("splitwise|v1|Shared|{}|{n}", fields.join("|")))
};
assert_eq!(
rows[0].9,
key(
"2025-01-05,Weekly shop,Groceries,300.00,SEK,150.00,-150.00",
0
)
);
assert_eq!(
rows[6].9,
key("2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00", 1)
);
// The payment: a settlement from the positive-net member, one allocation to the account.
let pool = db.pool().await;
let settlement: (String, String, i64, String, String, i64, i64) = sqlx::query_as(
"SELECT s.from_user_id, s.to_user_id, s.amount_ore, s.note, s.settlement_date, \
(SELECT COUNT(*) FROM settlement_allocations a WHERE a.settlement_id = s.id), \
(SELECT SUM(a.amount_ore) FROM settlement_allocations a JOIN accounts ac ON ac.id = a.account_id \
WHERE a.settlement_id = s.id AND ac.name = 'Shared') FROM settlements s",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
settlement,
(
PARTNER.to_owned(),
OWNER.to_owned(),
10000,
"Robin paid Alex".to_owned(),
"2025-01-12".to_owned(),
1,
10000
)
);
// Created rows: owner as creator and updater, no device, import time, server_received_at set.
for table in ["expenses", "settlements"] {
let odd: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT COUNT(*) FROM {table} WHERE created_by_user_id <> ?1 OR updated_by_user_id <> ?1 \
OR updated_by_device_id IS NOT NULL OR created_at <> ?2 OR updated_at <> ?2 OR deleted_at IS NOT NULL \
OR import_key IS NULL OR length(import_key) <> 64"
)))
.bind(OWNER)
.bind(NOW)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(odd, 0, "{table}");
}
// One audit create per row, source import, actor the owner, no device, both times the import time, with
// the create diff of SPEC §14.4.7.
let audits: Vec<AuditLine> = sqlx::query_as(
"SELECT entity_type, action, source, actor_user_id, actor_device_id, client_at, server_at, changes \
FROM audit_log ORDER BY server_version",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(audits.len(), 10);
assert_eq!(audits.iter().filter(|a| a.0 == "expense").count(), 9);
assert_eq!(audits.iter().filter(|a| a.0 == "settlement").count(), 1);
for a in &audits {
assert_eq!((&a.1[..], &a.2[..], &a.3[..]), ("create", "import", OWNER));
assert_eq!(a.4, None);
assert_eq!((&a.5[..], &a.6[..]), (NOW, NOW));
let changes: serde_json::Value = serde_json::from_str(&a.7).unwrap();
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["old"].is_null())
);
}
let audited: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log a WHERE a.entity_id IN (SELECT id FROM expenses UNION SELECT id FROM settlements)",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audited, 10);
// New server_versions from the global sequence: account + 9 expenses + 1 settlement + 10 audit entries.
let max_version: i64 = sqlx::query_scalar("SELECT value FROM sync_sequence")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(max_version, 12 + 1 + 9 + 1 + 10);
// No pushes: the importer has no push queue and touches no sync state.
let mutations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_mutations")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(mutations, 0);
pool.close().await;
}
#[tokio::test]
async fn swedish_export_with_reversed_member_columns() {
let db = TestDb::new().await;
let report = db
.import(&db.options("swedish.csv", "Vardagen"))
.await
.unwrap();
assert!(report.committed);
assert_eq!(report.language, kvitt_import::parse::Language::Swedish);
assert_eq!(report.rows_read, 5);
assert_eq!(report.expenses_created, 4);
assert_eq!(report.settlements_created, 1);
assert_eq!(
report.unmapped_categories,
BTreeMap::from([("Påhittad kategori".to_owned(), 1)])
);
assert!(report.reconciliation.matched);
// Members are matched by name: the second column is the owner.
assert_eq!(
report.reconciliation.members[0].splitwise_name,
PARTNER_NAME
);
assert_eq!(report.reconciliation.members[0].kvitt_ore, -1500);
assert_eq!(report.reconciliation.members[1].kvitt_ore, 1500);
let rows = expenses(&db).await;
assert_eq!(
(&rows[0].0[..], rows[0].3, rows[0].4),
(OWNER, 10000, 10000)
);
assert_eq!(rows[0].8, "groceries");
assert_eq!(rows[1].6, "Lampa, golv");
assert_eq!(
(&rows[1].0[..], rows[1].3, rows[1].4),
(PARTNER, 4000, 4000)
);
assert_eq!(rows[1].8, "home");
assert_eq!(rows[2].8, "other");
assert_eq!(rows[3].8, "transport");
let (from, to, amount): (String, String, i64) =
sqlx::query_as("SELECT from_user_id, to_user_id, amount_ore FROM settlements")
.fetch_one(&db.pool().await)
.await
.unwrap();
assert_eq!((&from[..], &to[..], amount), (PARTNER, OWNER, 5500));
}
#[tokio::test]
async fn rerun_creates_nothing_and_existing_account_is_reused() {
let db = TestDb::new().await;
let first = db
.import(&db.options("english.csv", "Shared"))
.await
.unwrap();
assert!(first.committed);
let before = db.snapshot().await;
// Same file, account name in another case: the existing account, every row skipped.
let second = db
.import(&db.options("english.csv", "SHARED"))
.await
.unwrap();
assert!(second.committed);
assert_eq!(second.account_name, "Shared");
assert!(!second.account_created);
assert_eq!(second.account_id, first.account_id);
assert_eq!(second.expenses_created, 0);
assert_eq!(second.settlements_created, 0);
assert_eq!(second.skipped_duplicates, 10);
assert!(second.reconciliation.matched);
assert_eq!(db.snapshot().await, before);
// A second file goes into a second account; the account name is part of the key.
let other = db
.import(&db.options("swedish.csv", "Other group"))
.await
.unwrap();
assert!(other.account_created);
assert_eq!(other.expenses_created, 4);
}
#[tokio::test]
async fn existing_account_matched_case_insensitively() {
let db = TestDb::new().await;
let first = db
.import(&db.options("swedish.csv", "Vardag"))
.await
.unwrap();
let second = db
.import(&db.options("english.csv", " vardag "))
.await
.unwrap();
assert!(!second.account_created);
assert_eq!(second.account_id, first.account_id);
// The balance now holds both files, so it no longer equals the second file's totals: rolled back.
assert!(!second.reconciliation.matched);
assert!(!second.committed);
assert_eq!(second.exit_code(), kvitt_import::EXIT_MISMATCH);
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 4);
}
#[tokio::test]
async fn dry_run_writes_nothing_and_reports_the_same_counts() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let mut options = db.options("english.csv", "Shared");
options.dry_run = true;
let report = db.import(&options).await.unwrap();
assert!(report.dry_run);
assert!(!report.committed);
assert_eq!(report.expenses_created, 9);
assert_eq!(report.settlements_created, 1);
assert_eq!(report.unmapped_rows(), 1);
assert!(report.reconciliation.matched);
assert_eq!(report.exit_code(), 0);
assert!(report.to_string().contains("dry run, nothing written"));
assert_eq!(db.snapshot().await, before);
// After a real import, a dry run reports every row as a duplicate.
options.dry_run = false;
db.import(&options).await.unwrap();
options.dry_run = true;
let again = db.import(&options).await.unwrap();
assert_eq!(again.skipped_duplicates, 10);
assert_eq!(again.expenses_created, 0);
}
#[tokio::test]
async fn invalid_row_aborts_with_nothing_written() {
let db = TestDb::new().await;
let before = db.snapshot().await;
match db.import(&db.options("invalid_nets.csv", "Shared")).await {
Err(ImportError::InvalidRows(problems)) => {
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].line, 4);
assert!(
problems[0].reason.contains("sum to 0"),
"{}",
problems[0].reason
);
}
other => panic!("{other:?}"),
}
// A row the server's checks reject (an impossible date) also aborts the whole file.
match db.import(&db.options("invalid_date.csv", "Shared")).await {
Err(e @ ImportError::InvalidRows(_)) => {
assert_eq!(e.exit_code(), kvitt_import::EXIT_INVALID);
let ImportError::InvalidRows(problems) = e else {
unreachable!()
};
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].line, 4);
assert!(
problems[0].reason.starts_with("validation_failed"),
"{}",
problems[0].reason
);
}
other => panic!("{other:?}"),
}
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn header_failures_write_nothing() {
let db = TestDb::new().await;
let before = db.snapshot().await;
for file in ["bad_header.csv", "wrong_member.csv"] {
match db.import(&db.options(file, "Shared")).await {
Err(e @ ImportError::Header(_)) => {
assert_eq!(e.exit_code(), kvitt_import::EXIT_INVALID)
}
other => panic!("{file}: {other:?}"),
}
}
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn non_sek_aborts_without_fx_and_converts_with_it() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let mut options = db.options("non_sek.csv", "Trip");
match db.import(&options).await {
Err(ImportError::MissingFx(currencies)) => {
// One data row and one totals row in EUR.
assert_eq!(currencies, BTreeMap::from([("EUR".to_owned(), 2)]));
}
other => panic!("{other:?}"),
}
assert_eq!(db.snapshot().await, before);
options
.fx
.insert("EUR".into(), Rate::parse("11.5").unwrap());
let report = db.import(&options).await.unwrap();
assert!(report.committed);
assert_eq!(report.non_sek_rows, 1);
assert_eq!(report.expenses_created, 2);
assert!(report.reconciliation.matched);
let rows = expenses(&db).await;
// 10.00 EUR × 11.5 = 115.00 SEK; the partner's share 5.00 EUR = 57.50 SEK; the payer's share is the rest.
assert_eq!((rows[1].2, rows[1].3, rows[1].4), (11500, 5750, 5750));
}
#[tokio::test]
async fn reconciliation_mismatch_or_missing_totals_rolls_back() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let report = db
.import(&db.options("mismatch.csv", "Shared"))
.await
.unwrap();
assert!(!report.reconciliation.matched);
assert!(!report.committed);
assert_eq!(report.exit_code(), kvitt_import::EXIT_MISMATCH);
let summary = report.to_string();
assert!(summary.contains("splitwise 7.00, kvitt 5.00"), "{summary}");
assert!(summary.contains("result: MISMATCH"), "{summary}");
assert_eq!(db.snapshot().await, before);
let report = db
.import(&db.options("no_totals.csv", "Shared"))
.await
.unwrap();
assert!(!report.reconciliation.has_totals);
assert!(!report.committed);
assert_eq!(report.exit_code(), kvitt_import::EXIT_MISMATCH);
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn users_resolve_by_display_name_and_must_be_the_household() {
let db = TestDb::new().await;
let mut options = db.options("english.csv", "Shared");
options.users[0].kvitt_user = "Alex".into();
options.users[1].kvitt_user = "Robin".into();
let report = db.import(&options).await.unwrap();
assert!(report.committed);
assert_eq!(report.reconciliation.members[0].user_id, OWNER);
let mut both_same = db.options("english.csv", "Other");
both_same.users[1].kvitt_user = OWNER.into();
assert!(matches!(
db.import(&both_same).await,
Err(ImportError::Usage(_))
));
let mut unknown = db.options("english.csv", "Other");
unknown.users[1].kvitt_user = "Nobody".into();
assert!(matches!(
db.import(&unknown).await,
Err(ImportError::Usage(_))
));
let mut one = db.options("english.csv", "Other");
one.users.pop();
assert!(matches!(db.import(&one).await, Err(ImportError::Usage(_))));
}
#[tokio::test]
async fn missing_database_is_not_created() {
let db = TestDb::new().await;
let mut options = db.options("english.csv", "Shared");
options.db = db.dir.join("absent.sqlite");
assert!(matches!(db.import(&options).await, Err(ImportError::Io(_))));
assert!(!options.db.exists());
}
/// Every category name of the SPEC §10.8 table is in the committed data file exactly once, with its key, and the
/// file holds nothing else.
#[test]
fn category_table_matches_the_spec() {
let spec =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../SPEC.md"))
.unwrap();
let section = spec.split("### 10.8 Splitwise import").nth(1).unwrap();
let section = section.split("\n### ").next().unwrap();
let mut expected: Vec<(String, String, String)> = Vec::new();
for line in section.lines() {
let cells: Vec<&str> = line
.trim()
.trim_matches('|')
.split('|')
.map(str::trim)
.collect();
if cells.len() != 3 || !cells[2].starts_with('`') || cells[0].starts_with("any other") {
continue;
}
let key = cells[2].trim_matches('`').to_owned();
for (column, language) in [(cells[0], "en"), (cells[1], "sv")] {
for name in column.split(", ") {
expected.push((name.to_owned(), language.to_owned(), key.clone()));
}
}
}
assert!(
expected.len() > 60,
"parsed {} names from the spec",
expected.len()
);
let table = CategoryTable::committed().unwrap();
let mut actual: Vec<(String, String, String)> = table
.entries
.iter()
.map(|e| {
(
e.splitwise_name.clone(),
e.language.clone(),
e.default_key.clone(),
)
})
.collect();
expected.sort();
actual.sort();
assert_eq!(actual, expected);
}
fn binary() -> Command {
Command::new(env!("CARGO_BIN_EXE_kvitt-import"))
}
fn cli_args(db: &TestDb, file: &str, account: &str) -> Vec<String> {
vec![
"--db".into(),
db.path.to_string_lossy().into_owned(),
"--file".into(),
fixture(file).to_string_lossy().into_owned(),
"--account".into(),
account.into(),
"--user".into(),
format!("{OWNER_NAME}={OWNER}"),
"--user".into(),
format!("{PARTNER_NAME}=Robin"),
]
}
#[test]
fn cli_help_and_usage_errors() {
let help = binary().arg("--help").output().unwrap();
assert!(help.status.success());
let text = String::from_utf8_lossy(&help.stdout);
for option in [
"--db",
"--file",
"--account",
"--user",
"--fx",
"--dry-run",
"Exit status",
] {
assert!(text.contains(option), "{option}: {text}");
}
let missing = binary().args(["--db", "x"]).output().unwrap();
assert_eq!(missing.status.code(), Some(2));
let bad_fx = binary()
.args([
"--db",
"x",
"--file",
"y",
"--account",
"A",
"--user",
"a=b",
"--user",
"c=d",
"--fx",
"EUR",
])
.output()
.unwrap();
assert_eq!(bad_fx.status.code(), Some(2));
}
#[tokio::test]
async fn cli_end_to_end_exit_codes() {
let db = TestDb::new().await;
let mut dry = cli_args(&db, "swedish.csv", "Vardag");
dry.push("--dry-run".into());
let out = binary().args(&dry).output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("dry run"));
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 0);
let out = binary()
.args(cli_args(&db, "swedish.csv", "Vardag"))
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("expenses created: 4"), "{stdout}");
assert!(stdout.contains("result: match"), "{stdout}");
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 4);
assert_eq!(
db.scalar("SELECT COUNT(*) FROM audit_log WHERE source = 'import'")
.await,
5
);
let out = binary()
.args(cli_args(&db, "invalid_nets.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
assert!(String::from_utf8_lossy(&out.stderr).contains("line 4"));
let out = binary()
.args(cli_args(&db, "mismatch.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(4));
let out = binary()
.args(cli_args(&db, "bad_header.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
assert_eq!(db.scalar("SELECT COUNT(*) FROM accounts").await, 1);
}