M12b: category contract v2 on the server (schema 2, fourteen defaults, finer import mapping)

- migrations/0002_categories_v2.sql, byte-identical to contracts/schema/migrations/0002_categories_v2.sql
  (SPEC §14.1.1), with an identity test; SCHEMA_VERSION = 2 (E-1 reports it, the importer's check follows).
- Seed test on a fresh database (the fourteen rows of AC-7.2, server_version 11 to 14) and an upgrade test of a
  v1 database in use (0001 applied through the migrator, then users, a custom, a renamed and an archived default,
  an account, a recurring template, expenses): every earlier row unchanged, the four rows above the old
  sequence, foreign_key_check empty, schema_version 2, a pull from the old cursor returns exactly the four rows.
- DefaultCategoryKey with the fourteen keys (wire enum and contracts/openapi.yaml); icons 33 and colours 14
  (§9.7) in validation.
- The two category examples M12a held back (E-26 response-default, sync/pull-page-categories-v2) and its
  contracts-check patch for them and the enum.
- Importer: splitwise_categories.csv per the §10.8 v0.8 table (D-053), DEFAULT_KEYS of fourteen, synthetic
  fixtures for the finer mapping (Insurance/Försäkringar to insurance among them), a schema-1 refusal test.
- Test harnesses seed users at server_version 15/16 (after the fourteen seeds); pull counts 10 -> 14.

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 08:23:37 +02:00
parent b09f3220a1
commit 6eaccec7ac
21 changed files with 790 additions and 57 deletions
@@ -3,12 +3,12 @@ Groceries,en,groceries
Food and drink - Other,en,groceries
Liquor,en,groceries
Dining out,en,restaurants
Rent,en,housing
Mortgage,en,housing
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
@@ -19,6 +19,7 @@ Trash,en,utilities
TV/Phone/Internet,en,utilities
Cleaning,en,utilities
Utilities - Other,en,utilities
Insurance,en,insurance
Bicycle,en,transport
Bus/train,en,transport
Car,en,transport
@@ -34,30 +35,35 @@ Music,en,entertainment
Sports,en,entertainment
Entertainment - Other,en,entertainment
Medical expenses,en,health
Childcare,en,children
Clothing,en,shopping
Gifts,en,shopping
Taxes,en,fees
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
Hyra,sv,housing
Avbetalning/Amortering,sv,housing
Elektronik,sv,home
Möbler,sv,home
Förbrukningsvaror,sv,home
Skötsel/underhåll,sv,home
Avbetalning/Amortering,sv,home
Hyra,sv,home
Tjänster,sv,home
Husdjur,sv,home
Hem - Övrigt,sv,home
El,sv,utilities
Värme/gas,sv,utilities
Vatten,sv,utilities
Sophämtning,sv,utilities
TV/telefon/internet,sv,utilities
Städning,sv,utilities
Verktyg - Övrigt,sv,utilities
Försäkringar,sv,insurance
Cykel,sv,transport
Buss/tåg,sv,transport
Bil,sv,transport
@@ -68,16 +74,15 @@ Transport - Övrigt,sv,transport
Hotell,sv,travel
Flyg,sv,travel
Spel,sv,entertainment
Musik,sv,entertainment
Filmer,sv,entertainment
Musik,sv,entertainment
Sport,sv,entertainment
Underhållning - Övrigt,sv,entertainment
Sjukvård/medicin,sv,health
Presenter,sv,shopping
Barnomsorg,sv,children
Kläder,sv,shopping
Presenter,sv,shopping
Skatter,sv,fees
Allmänt,sv,other
Barnomsorg,sv,other
Utbildning,sv,other
Försäkringar,sv,other
Skatter,sv,other
Livet - Övrigt,sv,other
1 splitwise_name language default_key
3 Food and drink - Other en groceries
4 Liquor en groceries
5 Dining out en restaurants
6 Rent en housing
7 Mortgage en housing
8 Electronics en home
9 Furniture en home
10 Household supplies en home
11 Maintenance en home
Mortgage en home
Rent en home
12 Services en home
13 Pets en home
14 Home - Other en home
19 TV/Phone/Internet en utilities
20 Cleaning en utilities
21 Utilities - Other en utilities
22 Insurance en insurance
23 Bicycle en transport
24 Bus/train en transport
25 Car en transport
35 Sports en entertainment
36 Entertainment - Other en entertainment
37 Medical expenses en health
38 Childcare en children
39 Clothing en shopping
40 Gifts en shopping
41 Taxes en fees
42 General en other
Childcare en other
43 Education en other
Insurance en other
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 Hyra sv housing
51 Avbetalning/Amortering sv housing
52 Elektronik sv home
53 Möbler sv home
54 Förbrukningsvaror sv home
55 Skötsel/underhåll sv home
Avbetalning/Amortering sv home
Hyra sv home
56 Tjänster sv home
57 Husdjur sv home
58 Hem - Övrigt sv home
59 El sv utilities
60 Värme/gas sv utilities
61 Vatten sv utilities
62 Sophämtning sv utilities
63 TV/telefon/internet sv utilities
64 Städning sv utilities
65 Verktyg - Övrigt sv utilities
66 Försäkringar sv insurance
67 Cykel sv transport
68 Buss/tåg sv transport
69 Bil sv transport
74 Hotell sv travel
75 Flyg sv travel
76 Spel sv entertainment
Musik sv entertainment
77 Filmer sv entertainment
78 Musik sv entertainment
79 Sport sv entertainment
80 Underhållning - Övrigt sv entertainment
81 Sjukvård/medicin sv health
82 Presenter Barnomsorg sv shopping children
83 Kläder sv shopping
84 Presenter sv shopping
85 Skatter sv fees
86 Allmänt sv other
Barnomsorg sv other
87 Utbildning sv other
Försäkringar sv other
Skatter sv other
88 Livet - Övrigt sv other
+38 -2
View File
@@ -7,8 +7,8 @@ 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] = [
/// The Kvitt default category keys (SPEC AC-7.2; the `categories.default_key` CHECK of migration 0002, §14.1.1).
pub const DEFAULT_KEYS: [&str; 14] = [
"groceries",
"restaurants",
"home",
@@ -19,6 +19,10 @@ pub const DEFAULT_KEYS: [&str; 10] = [
"health",
"shopping",
"other",
"housing",
"insurance",
"children",
"fees",
];
/// The key unmapped names fall back to.
@@ -137,6 +141,38 @@ mod tests {
assert_eq!(table.lookup("Payment"), None);
}
#[test]
fn finer_mapping_of_d053() {
let table = CategoryTable::committed().unwrap();
for (name, key) in [
("Rent", "housing"),
("Mortgage", "housing"),
("Hyra", "housing"),
("Avbetalning/Amortering", "housing"),
("Insurance", "insurance"),
("försäkringar", "insurance"),
("Childcare", "children"),
("Barnomsorg", "children"),
("Taxes", "fees"),
("Skatter", "fees"),
("El", "utilities"),
("Värme/gas", "utilities"),
("Vatten", "utilities"),
("Sophämtning", "utilities"),
("Husdjur", "home"),
("Pets", "home"),
] {
assert_eq!(table.lookup(name), Some(key), "{name}");
}
// Every key the table uses is a default key, and every default key is reachable.
for key in DEFAULT_KEYS {
assert!(
table.entries.iter().any(|e| e.default_key == key),
"{key} has no Splitwise name"
);
}
}
#[test]
fn rejects_bad_tables() {
let header = "splitwise_name,language,default_key\n";
@@ -0,0 +1,8 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-03-01,Home insurance,Insurance,600.00,SEK,300.00,-300.00
2025-03-02,Preschool fee,Childcare,1500.00,SEK,-750.00,750.00
2025-03-03,Car tax,Taxes,400.00,SEK,200.00,-200.00
2025-03-04,Mortgage March,Mortgage,5000.00,SEK,2500.00,-2500.00
2025-03-05,Total balance, , ,SEK,2250.00,-2250.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-03-01 Home insurance Insurance 600.00 SEK 300.00 -300.00
3 2025-03-02 Preschool fee Childcare 1500.00 SEK -750.00 750.00
4 2025-03-03 Car tax Taxes 400.00 SEK 200.00 -200.00
5 2025-03-04 Mortgage March Mortgage 5000.00 SEK 2500.00 -2500.00
6 2025-03-05 Total balance SEK 2250.00 -2250.00
@@ -0,0 +1,11 @@
Datum,Beskrivning,Kategori,Kostnad,Valuta,Robin Sample,Alex Example
2025-04-01,Hemförsäkring,Försäkringar,240.00,SEK,-120.00,120.00
2025-04-02,Förskola,Barnomsorg,1000.00,SEK,500.00,-500.00
2025-04-03,Fastighetsskatt,Skatter,300.00,SEK,-150.00,150.00
2025-04-04,Hyra april,Hyra,8000.00,SEK,4000.00,-4000.00
2025-04-05,Amortering april,Avbetalning/Amortering,2000.00,SEK,-1000.00,1000.00
2025-04-06,Elräkning,El,500.00,SEK,250.00,-250.00
2025-04-07,Hundmat,Husdjur,100.00,SEK,-50.00,50.00
2025-04-08,Totalsumma, , ,SEK,3430.00,-3430.00
1 Datum Beskrivning Kategori Kostnad Valuta Robin Sample Alex Example
2 2025-04-01 Hemförsäkring Försäkringar 240.00 SEK -120.00 120.00
3 2025-04-02 Förskola Barnomsorg 1000.00 SEK 500.00 -500.00
4 2025-04-03 Fastighetsskatt Skatter 300.00 SEK -150.00 150.00
5 2025-04-04 Hyra april Hyra 8000.00 SEK 4000.00 -4000.00
6 2025-04-05 Amortering april Avbetalning/Amortering 2000.00 SEK -1000.00 1000.00
7 2025-04-06 Elräkning El 500.00 SEK 250.00 -250.00
8 2025-04-07 Hundmat Husdjur 100.00 SEK -50.00 50.00
9 2025-04-08 Totalsumma SEK 3430.00 -3430.00
+81 -4
View File
@@ -52,8 +52,8 @@ impl TestDb {
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),
(OWNER, "admin", "Alex", 15),
(PARTNER, "member", "Robin", 16),
] {
sqlx::query(
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
@@ -67,7 +67,8 @@ impl TestDb {
.await
.unwrap();
}
sqlx::query("UPDATE sync_sequence SET value = 12")
// After the fourteen seeded categories (server_version 1 to 14, SPEC §14.1.1) and the two users.
sqlx::query("UPDATE sync_sequence SET value = 16")
.execute(&pool)
.await
.unwrap();
@@ -268,6 +269,8 @@ async fn english_export_imports_expenses_settlements_and_audit() {
assert!(rows[7].7.starts_with(&rows[7].6));
assert_eq!(rows[7].7.chars().count(), 127);
assert_eq!(rows[8].6, "Rent, January");
// Rent is housing since D-053.
assert_eq!(rows[8].8, "housing");
assert!(
rows.iter()
.all(|r| r.7.is_empty() || r.6.chars().count() == 100)
@@ -367,7 +370,7 @@ async fn english_export_imports_expenses_settlements_and_audit() {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(max_version, 12 + 1 + 9 + 1 + 10);
assert_eq!(max_version, 16 + 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)
@@ -643,6 +646,80 @@ async fn missing_database_is_not_created() {
assert!(!options.db.exists());
}
/// The finer mapping of D-053: every new default key is reached from English and Swedish Splitwise names, with
/// nothing unmapped.
#[tokio::test]
async fn finer_mapping_reaches_the_four_new_defaults() {
for (file, keys) in [
(
"english_finer.csv",
&["insurance", "children", "fees", "housing"][..],
),
(
"swedish_finer.csv",
&[
"insurance",
"children",
"fees",
"housing",
"housing",
"utilities",
"home",
][..],
),
] {
let db = TestDb::new().await;
let report = db.import(&db.options(file, "Hemma")).await.unwrap();
assert!(report.committed, "{file}");
assert_eq!(report.unmapped_rows(), 0, "{file}");
assert!(report.reconciliation.matched, "{file}");
assert_eq!(report.expenses_created, keys.len(), "{file}");
let rows = expenses(&db).await;
let actual: Vec<&str> = rows.iter().map(|r| r.8.as_str()).collect();
assert_eq!(actual, keys, "{file}");
}
// A Försäkringar / Insurance row lands in the insurance default (id …012), not in Övrigt.
let db = TestDb::new().await;
db.import(&db.options("swedish_finer.csv", "Hemma"))
.await
.unwrap();
assert_eq!(
db.scalar(
"SELECT COUNT(*) FROM expenses WHERE description = 'Hemförsäkring' \
AND category_id = '01920000-0000-7000-8000-000000000012'"
)
.await,
1
);
}
/// The importer requires the schema version the server's migrations produce (SPEC §10.8, D-054): a database still
/// at schema 1 is refused with exit 1 and nothing written, and imports once migrated.
#[tokio::test]
async fn a_schema_1_database_is_refused_until_migrated() {
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::MIGRATOR.run_to(1, &pool).await.unwrap();
pool.close().await;
let refused = kvitt_import::open(&path).await;
match refused {
Err(ImportError::Io(message)) => {
assert!(message.contains("expected 2"), "{message}");
assert!(message.contains("kvitt-server migrate"), "{message}");
}
Err(other) => panic!("expected a database error, got {other:?}"),
Ok(_) => panic!("a schema 1 database was accepted"),
}
let pool = db::connect(&path).await.unwrap();
db::migrate(&pool).await.unwrap();
pool.close().await;
let opened = kvitt_import::open(&path).await.unwrap();
opened.close().await;
let _ = std::fs::remove_dir_all(&dir);
}
/// 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]