From aa765bbb48920c24c8c3a3d24e675ee93e92f24d Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 08:10:57 +0200 Subject: [PATCH] Contracts for 14 default categories and the category suggestion (UX-3, UX-8) - contracts/schema/migrations/0002_categories_v2.sql: server migration 0002 rebuilds categories with the 14-key default_key CHECK, inserts housing, insurance, children and fees with server_versions taken from the global sequence, sets schema_version 2. server-v1.sql unchanged. - contracts/category-keywords.csv: the keyword table of the suggestion (148 entries, from docs/ux). - contracts/test-vectors/suggest_category.json: 65 Kotlin-only cases of CS-4 to CS-6. - contracts/examples/E-1-health: schema_version 2. - tools/contracts-check.py: reference suggestCategory, keyword-table checks, 0002 load checks on a fresh and an in-use database, plain icon/color check, new icon and colour tokens. - android/domain: suggestCategory (CS-4 to CS-6, code points, platform NFC and categories through UnicodeText), vector runner for suggest_category, eight vector files. - server/crates/kvitt-domain/tests/vectors.rs: suggest_category is Kotlin-only. The OpenAPI DefaultCategoryKey extension and the two new category examples are held back until the server's wire types know the new keys (M12b). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/domain/build.gradle.kts | 2 +- .../kvitt/domain/CategorySuggestion.kt | 127 + .../hagstedt/kvitt/domain/JvmUnicodeText.kt | 12 + .../hagstedt/kvitt/domain/VectorFilesTest.kt | 2 +- .../se/hagstedt/kvitt/domain/VectorTest.kt | 15 + .../se/hagstedt/kvitt/domain/Vectors.kt | 4 +- contracts/category-keywords.csv | 149 + contracts/examples/E-1-health/response.json | 2 +- .../schema/migrations/0002_categories_v2.sql | 64 + contracts/test-vectors/suggest_category.json | 7513 +++++++++++++++++ server/crates/kvitt-domain/tests/vectors.rs | 6 +- tools/contracts-check.py | 412 +- 12 files changed, 8295 insertions(+), 13 deletions(-) create mode 100644 android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/JvmUnicodeText.kt create mode 100644 contracts/category-keywords.csv create mode 100644 contracts/schema/migrations/0002_categories_v2.sql create mode 100644 contracts/test-vectors/suggest_category.json diff --git a/android/domain/build.gradle.kts b/android/domain/build.gradle.kts index 29d51f5..9d21598 100644 --- a/android/domain/build.gradle.kts +++ b/android/domain/build.gradle.kts @@ -9,7 +9,7 @@ dependencies { } // The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources. -// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present. +// A missing file is not tolerated: VectorFilesTest fails unless exactly the eight files of §14.5.5 are present. val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors") val copyTestVectors = tasks.register("copyTestVectors") { description = "Copies contracts/test-vectors/*.json into the :domain test resources." diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt new file mode 100644 index 0000000..c528289 --- /dev/null +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt @@ -0,0 +1,127 @@ +package se.hagstedt.kvitt.domain + +import kotlinx.datetime.LocalDate +import se.hagstedt.kvitt.model.CategoryId + +// Category suggestion from the description (UX-3, category-suggestion.md CS-4 to CS-6). Pure; the vectors are +// contracts/test-vectors/suggest_category.json (Kotlin only). +// +// The input records live here and not in :core:model: default keys are open strings because the +// :core:model DefaultCategoryKey enum does not yet carry the four keys of schema v2 (housing, insurance, +// children, fees), and the keyword table (contracts/category-keywords.csv) is data, not a UI contract. + +/** One non-deleted expense on the phone (every account, either user). */ +data class SuggestionHistoryEntry( + val description: String, + val categoryId: CategoryId, + val expenseDate: LocalDate, + /** Fixed RFC 3339 form (`YYYY-MM-DDTHH:MM:SS.mmmZ`), so string order is time order. */ + val createdAt: String, +) + +enum class KeywordMatch { TOKEN, PREFIX, PHRASE } + +/** One row of the keyword table, `keyword` already normalised (CS-6). */ +data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String) + +/** A category that is neither archived nor deleted; `defaultKey` null for a user-created one. */ +data class ActiveCategory(val id: CategoryId, val defaultKey: String?) + +/** + * The Unicode services CS-4 needs that kotlin.* does not offer in a pure module: NFC and the general category of a + * code point. The app binds the platform's (java.text.Normalizer, Character); tests bind the same. + */ +interface UnicodeText { + fun nfc(text: String): String + + /** General category Lu, Ll, Lt, Lm or Lo. */ + fun isLetter(codePoint: Int): Boolean + + /** General category Nd. */ + fun isDigit(codePoint: Int): Boolean +} + +/** + * CS-4: NFC, lowercase without locale rules, delete `'`, `’` and `:`, every other code point that is not a letter, + * a decimal digit or `&` becomes a space, then whitespace is collapsed and trimmed. Iterates code points. + */ +fun normalizeDescription(text: String, unicode: UnicodeText): String { + val lowered = unicode.nfc(text).lowercase() + val out = StringBuilder(lowered.length) + var i = 0 + while (i < lowered.length) { + val c = lowered[i] + val pair = c.isHighSurrogate() && i + 1 < lowered.length && lowered[i + 1].isLowSurrogate() + val width = if (pair) 2 else 1 + val codePoint = if (pair) 0x10000 + ((c.code - 0xD800) shl 10) + (lowered[i + 1].code - 0xDC00) else c.code + when { + codePoint == APOSTROPHE || codePoint == RIGHT_SINGLE_QUOTE || codePoint == COLON -> Unit + codePoint == AMPERSAND || unicode.isLetter(codePoint) || unicode.isDigit(codePoint) -> out.append(lowered, i, i + width) + else -> out.append(' ') + } + i += width + } + return out.split(' ').filter { it.isNotEmpty() }.joinToString(" ") +} + +/** + * CS-5: history exact, then history first token, then the keyword table (CS-6), then [fallback] (the AC-8.6 default). + * An empty normalised description gives [fallback]. History counts only categories in [activeCategories]; the most + * often used wins, a tie goes to the category whose latest expense is latest (`expenseDate`, then `createdAt`), and a + * remaining tie to the lower category id (TODO(spec): not stated in category-suggestion.md). + */ +fun suggestCategory( + description: String, + history: List, + keywords: List, + activeCategories: List, + fallback: CategoryId?, + unicode: UnicodeText, +): CategoryId? { + val normalised = normalizeDescription(description, unicode) + if (normalised.isEmpty()) return fallback + val tokens = normalised.split(' ') + val active = activeCategories.map { it.id }.toSet() + val stored = history.map { normalizeDescription(it.description, unicode) to it } + pickFromHistory(stored.filter { it.first == normalised }.map { it.second }, active)?.let { return it } + pickFromHistory(stored.filter { it.first.isNotEmpty() && it.first.substringBefore(' ') == tokens[0] }.map { it.second }, active) + ?.let { return it } + val byKey = activeCategories.mapNotNull { c -> c.defaultKey?.let { it to c.id } }.toMap() + return keywords.withIndex() + .filter { matches(it.value, tokens) } + .sortedWith(compareByDescending> { codePointLength(it.value.keyword) }.thenBy { it.index }) + .firstNotNullOfOrNull { byKey[it.value.defaultKey] } + ?: fallback +} + +private class Tally(val id: CategoryId, val count: Int, val latest: SuggestionHistoryEntry) + +private val RECENCY: Comparator = compareBy({ it.expenseDate }, { it.createdAt }) + +private fun pickFromHistory(rows: List, active: Set): CategoryId? = rows + .filter { it.categoryId in active } + .groupBy { it.categoryId } + .map { (id, group) -> Tally(id, group.size, group.maxWith(RECENCY)) } + .sortedWith( + compareByDescending { it.count } + .thenComparator { a, b -> RECENCY.compare(b.latest, a.latest) } + .thenBy { it.id.value }, + ) + .firstOrNull() + ?.id + +private fun matches(entry: CategoryKeyword, tokens: List): Boolean = when (entry.match) { + KeywordMatch.TOKEN -> entry.keyword in tokens + KeywordMatch.PREFIX -> tokens.any { it.startsWith(entry.keyword) } + KeywordMatch.PHRASE -> { + val phrase = entry.keyword.split(' ') + (0..tokens.size - phrase.size).any { start -> tokens.subList(start, start + phrase.size) == phrase } + } +} + +private fun codePointLength(text: String): Int = text.count { !it.isLowSurrogate() } + +private const val APOSTROPHE = 0x27 +private const val RIGHT_SINGLE_QUOTE = 0x2019 +private const val COLON = 0x3A +private const val AMPERSAND = 0x26 diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/JvmUnicodeText.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/JvmUnicodeText.kt new file mode 100644 index 0000000..e39718b --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/JvmUnicodeText.kt @@ -0,0 +1,12 @@ +package se.hagstedt.kvitt.domain + +import java.text.Normalizer + +/** The platform's Unicode services for the tests (the app binds the same in :data). */ +object JvmUnicodeText : UnicodeText { + override fun nfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC) + + override fun isLetter(codePoint: Int): Boolean = Character.isLetter(codePoint) + + override fun isDigit(codePoint: Int): Boolean = Character.getType(codePoint) == Character.DECIMAL_DIGIT_NUMBER.toInt() +} diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt index 766c1be..934cf46 100644 --- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorFilesTest.kt @@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */ +/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */ class VectorFilesTest { @Test fun everyVectorFileIsPresentAndTested() { diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt index 0f1b768..a92122b 100644 --- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/VectorTest.kt @@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized +import se.hagstedt.kvitt.model.CategoryId import se.hagstedt.kvitt.model.RangePreset import se.hagstedt.kvitt.model.analytics.AnalyticsExpense import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity @@ -48,6 +49,7 @@ class VectorTest(private val case: VectorCase, private val label: String) { "over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input)))) "spend_series" -> series(input) "resolve_range" -> range(input) + "suggest_category" -> suggest(input) else -> error("no runner for $function") } @@ -149,6 +151,19 @@ class VectorTest(private val case: VectorCase, private val label: String) { ) } + private fun suggest(input: JsonObject): JsonElement { + val history = input.objects("history").map { + SuggestionHistoryEntry(it.str("description"), CategoryId(it.str("category_id")), LocalDate.parse(it.str("expense_date")), it.str("created_at")) + } + val keywords = input.objects("keywords").map { + CategoryKeyword(it.str("keyword"), KeywordMatch.valueOf(it.str("match").uppercase()), it.str("default_key")) + } + val active = input.objects("active_categories").map { ActiveCategory(CategoryId(it.str("id")), it.strOrNull("default_key")) } + val fallback = input.strOrNull("fallback")?.let { CategoryId(it) } + val r = suggestCategory(input.str("description"), history, keywords, active, fallback, JvmUnicodeText) + return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull) + } + private fun range(input: JsonObject): JsonElement { val preset = RangePreset.valueOf(input.str("preset").uppercase()) fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) } diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt index 846377f..c7d89de 100644 --- a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/Vectors.kt @@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long -/** The seven vector files of SPEC §14.5.5, keyed by function name. */ +/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */ val VECTOR_FUNCTIONS: List = listOf( "compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts", - "spend_series", "resolve_range", + "spend_series", "resolve_range", "suggest_category", ) data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement) diff --git a/contracts/category-keywords.csv b/contracts/category-keywords.csv new file mode 100644 index 0000000..c73444c --- /dev/null +++ b/contracts/category-keywords.csv @@ -0,0 +1,149 @@ +keyword,match,default_key,source +barn,prefix,children,data +blöjor,token,children,curated +fritids,token,children,curated +förskol,prefix,children,data +leksak,prefix,children,curated +napp,prefix,children,data +resevagn,token,children,data +bio,token,entertainment,data +entre,token,entertainment,data +entré,token,entertainment,data +filmstaden,token,entertainment,curated +karaoke,token,entertainment,data +konsert,prefix,entertainment,data +museet,token,entertainment,curated +museum,token,entertainment,data +netflix,token,entertainment,curated +padel,token,entertainment,curated +spotify,token,entertainment,data +teater,prefix,entertainment,curated +bankavgift,token,fees,curated +kvarskatt,token,fees,curated +ränteåterbäring,token,fees,data +skatt,token,fees,curated +skatteverket,token,fees,curated +alkohol,token,groceries,data +bondens marknad,phrase,groceries,data +bröd,token,groceries,data +bubbel,token,groceries,data +city gross,phrase,groceries,curated +coop,token,groceries,data +frukt,token,groceries,curated +grönsaker,token,groceries,data +handling,prefix,groceries,data +hemköp,token,groceries,data +ica,token,groceries,data +julmat,token,groceries,data +lidl,token,groceries,curated +livs,token,groceries,data +livsmedel,prefix,groceries,curated +mat,token,groceries,data +mjölk,token,groceries,data +netto,token,groceries,data +ost,token,groceries,data +snacks,token,groceries,data +storhandling,token,groceries,data +systembolag,prefix,groceries,data +systemet,token,groceries,data +willys,token,groceries,data +apotek,prefix,health,data +kry,token,health,curated +läkare,token,health,curated +medicin,prefix,health,curated +sjukhus,token,health,curated +tandläkare,token,health,curated +tandvård,token,health,curated +vårdcentral,token,health,curated +bauhaus,token,home,data +byggmax,token,home,curated +clas ohlson,phrase,home,data +elgiganten,token,home,curated +färg,token,home,data +garderob,prefix,home,data +gardin,prefix,home,data +hylla,token,home,data +ikea,token,home,data +jysk,token,home,curated +lamp,prefix,home,data +möbler,token,home,curated +ohlson,token,home,data +rusta,token,home,data +amortering,prefix,housing,curated +avgift,token,housing,data +bolån,token,housing,curated +brf,token,housing,data +hyra,token,housing,data +lgh,token,housing,data +ränta,token,housing,data +barnförsäkring,token,insurance,curated +bilförsäkring,token,insurance,curated +folksam,token,insurance,curated +försäkring,prefix,insurance,data +hemförsäkring,token,insurance,data +länsförsäkringar,token,insurance,curated +trygg hansa,phrase,insurance,curated +bagel,prefix,restaurants,data +bageri,prefix,restaurants,data +bar,token,restaurants,curated +burger king,phrase,restaurants,curated +cafe,token,restaurants,curated +café,token,restaurants,data +dumplings,token,restaurants,data +fika,prefix,restaurants,data +foodora,token,restaurants,curated +glass,token,restaurants,data +hamburgare,token,restaurants,curated +kaffe,token,restaurants,data +kebab,token,restaurants,data +lunch,token,restaurants,data +max,token,restaurants,data +mcdonalds,token,restaurants,curated +middag,token,restaurants,data +pizz,prefix,restaurants,data +ramen,token,restaurants,data +restaurang,prefix,restaurants,curated +seml,prefix,restaurants,data +streetfood,token,restaurants,data +sushi,token,restaurants,data +thai,token,restaurants,data +uber eats,phrase,restaurants,curated +amazon,token,shopping,curated +h&m,token,shopping,curated +kappahl,token,shopping,curated +lindex,token,shopping,curated +present,prefix,shopping,data +skor,token,shopping,curated +stadium,token,shopping,curated +zalando,token,shopping,curated +bensin,token,transport,curated +bil,token,transport,data +buss,prefix,transport,curated +flygbuss,token,transport,curated +färj,prefix,transport,data +laddning,prefix,transport,data +okq8,token,transport,curated +parkering,prefix,transport,data +preem,token,transport,curated +sj,token,transport,curated +sl,token,transport,curated +tanka,token,transport,curated +taxi,token,transport,data +trängselskatt,token,transport,curated +tåg,token,transport,data +uber,token,transport,curated +airbnb,token,travel,data +boende,token,travel,data +booking,token,travel,curated +flyg,token,travel,curated +hotel,token,travel,data +hotell,token,travel,data +sas,token,travel,curated +bredband,token,utilities,curated +elräkning,token,utilities,curated +internet,token,utilities,data +städning,prefix,utilities,curated +tele2,token,utilities,curated +telenor,token,utilities,curated +telia,token,utilities,curated +vattenfall,token,utilities,curated diff --git a/contracts/examples/E-1-health/response.json b/contracts/examples/E-1-health/response.json index 889b7e0..c0f5499 100644 --- a/contracts/examples/E-1-health/response.json +++ b/contracts/examples/E-1-health/response.json @@ -1,6 +1,6 @@ { "status": "ok", "server_version": "0.1.0", - "schema_version": 1, + "schema_version": 2, "db_ok": true } diff --git a/contracts/schema/migrations/0002_categories_v2.sql b/contracts/schema/migrations/0002_categories_v2.sql new file mode 100644 index 0000000..e54bef2 --- /dev/null +++ b/contracts/schema/migrations/0002_categories_v2.sql @@ -0,0 +1,64 @@ +-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new +-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql). +-- +-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows +-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged. +-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9). +-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly +-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets +-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name. +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE categories_v1_copy AS SELECT * FROM categories; +DROP TABLE categories; + +CREATE TABLE categories ( + id TEXT PRIMARY KEY NOT NULL, + default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN + ('groceries', 'restaurants', 'home', 'utilities', 'transport', + 'travel', 'entertainment', 'health', 'shopping', 'other', + 'housing', 'insurance', 'children', 'fees')), + name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40), + icon TEXT NOT NULL, + color TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE, + CHECK (name IS NOT NULL OR default_key IS NOT NULL) +) STRICT; + +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) + SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version + FROM categories_v1_copy; +DROP TABLE categories_v1_copy; + +-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so +-- phones receive the rows on their next pull whatever their cursor. +UPDATE sync_sequence SET value = value + 1 WHERE id = 1; +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) + SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25, + '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value + FROM sync_sequence WHERE id = 1; + +UPDATE sync_sequence SET value = value + 1 WHERE id = 1; +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) + SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45, + '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value + FROM sync_sequence WHERE id = 1; + +UPDATE sync_sequence SET value = value + 1 WHERE id = 1; +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) + SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85, + '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value + FROM sync_sequence WHERE id = 1; + +UPDATE sync_sequence SET value = value + 1 WHERE id = 1; +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) + SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95, + '2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value + FROM sync_sequence WHERE id = 1; + +UPDATE meta SET value = '2' WHERE key = 'schema_version'; diff --git a/contracts/test-vectors/suggest_category.json b/contracts/test-vectors/suggest_category.json new file mode 100644 index 0000000..d8ba702 --- /dev/null +++ b/contracts/test-vectors/suggest_category.json @@ -0,0 +1,7513 @@ +{ + "format": 1, + "function": "suggest_category", + "cases": [ + { + "name": "empty description gives the fallback, even with history and keywords", + "input": { + "description": "", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "empty description with a null fallback gives null", + "input": { + "description": "", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": null + }, + "expected": { + "ok": null + } + }, + { + "name": "whitespace-only description is empty", + "input": { + "description": " \t \n", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-000000000002" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "punctuation-only description is empty and does not match a history row that also normalises to empty", + "input": { + "description": "!!!", + "history": [ + { + "description": "...", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "history exact: the category used most often wins over the most recent", + "input": { + "description": "Ica Maxi", + "history": [ + { + "description": "ICA Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-09-08", + "created_at": "2026-09-08T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "history exact: a tie on count goes to the most recent expense_date", + "input": { + "description": "Ica Maxi", + "history": [ + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: a tie compares the latest expense of each tied category", + "input": { + "description": "Ica Maxi", + "history": [ + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-01-10", + "created_at": "2026-01-10T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-02-10", + "created_at": "2026-02-10T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-02-20", + "created_at": "2026-02-20T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-03-10", + "created_at": "2026-03-10T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "history exact: on the same expense_date the later created_at wins", + "input": { + "description": "Ica Maxi", + "history": [ + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T08:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T09:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: expense_date decides before created_at (a back-dated expense entered later is older)", + "input": { + "description": "Ica Maxi", + "history": [ + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-02", + "created_at": "2026-10-02T08:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-05T08:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "history exact: descriptions are compared after normalisation", + "input": { + "description": "ica maxi", + "history": [ + { + "description": "ICA: Maxi!", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact beats a larger first-token group", + "input": { + "description": "Ica", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-08-01", + "created_at": "2026-08-01T12:00:00.000Z" + }, + { + "description": "Ica Kvantum", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "Ica Kvantum", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-02", + "created_at": "2026-09-02T12:00:00.000Z" + }, + { + "description": "Ica Kvantum", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-03", + "created_at": "2026-09-03T12:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "history exact: a custom category is suggested like any other", + "input": { + "description": "Hundmat", + "history": [ + { + "description": "Hundmat", + "category_id": "0192a007-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + }, + { + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "0192a007-0000-7000-8000-000000000001" + } + }, + { + "name": "history exact: an archived category is left out of the count", + "input": { + "description": "Ikea", + "history": [ + { + "description": "Ikea", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "IKEA", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-02", + "created_at": "2026-09-02T12:00:00.000Z" + }, + { + "description": "Ikea", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-08-01", + "created_at": "2026-08-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + }, + { + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: a deleted category (absent from the active list) is left out", + "input": { + "description": "Hyra", + "history": [ + { + "description": "Hyra", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-25", + "created_at": "2026-09-25T12:00:00.000Z" + }, + { + "description": "Hyra", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-08-25", + "created_at": "2026-08-25T12:00:00.000Z" + }, + { + "description": "Hyra", + "category_id": "01920000-0000-7000-8000-000000000011", + "expense_date": "2026-07-25", + "created_at": "2026-07-25T12:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000011" + } + }, + { + "name": "history exact: digits are kept, so a different number is not an exact match", + "input": { + "description": "Hyra 10", + "history": [ + { + "description": "Hyra 10", + "category_id": "01920000-0000-7000-8000-000000000011", + "expense_date": "2026-10-25", + "created_at": "2026-10-25T12:00:00.000Z" + }, + { + "description": "Hyra 11", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-25", + "created_at": "2026-09-25T12:00:00.000Z" + }, + { + "description": "Hyra 11", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-08-25", + "created_at": "2026-08-25T12:00:00.000Z" + } + ], + "keywords": [], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000011" + } + }, + { + "name": "history exact: whitespace is collapsed and trimmed", + "input": { + "description": " Ica Maxi ", + "history": [ + { + "description": "ICA Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: tab and newline count as whitespace", + "input": { + "description": "Ica\tMaxi\n", + "history": [ + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: a decomposed é in the typed text equals the stored composed é (NFC)", + "input": { + "description": "Cafe\u0301 Ritorno", + "history": [ + { + "description": "Café Ritorno", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "café", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history exact: a colon in the stored description is deleted", + "input": { + "description": "Willys", + "history": [ + { + "description": "WILLY:S", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history first token when there is no exact match", + "input": { + "description": "Ica Kvantum Lund", + "history": [ + { + "description": "ICA Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "Ica Maxi", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-09-08", + "created_at": "2026-09-08T12:00:00.000Z" + }, + { + "description": "Coop", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "coop", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "history first token: most often", + "input": { + "description": "Taxi hem", + "history": [ + { + "description": "Taxi Stockholm", + "category_id": "01920000-0000-7000-8000-000000000006", + "expense_date": "2026-05-01", + "created_at": "2026-05-01T12:00:00.000Z" + }, + { + "description": "taxi kurir", + "category_id": "01920000-0000-7000-8000-000000000005", + "expense_date": "2026-06-01", + "created_at": "2026-06-01T12:00:00.000Z" + }, + { + "description": "Taxi Göteborg", + "category_id": "01920000-0000-7000-8000-000000000006", + "expense_date": "2026-04-01", + "created_at": "2026-04-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000006" + } + }, + { + "name": "history first token: a tie on count goes to the most recent", + "input": { + "description": "Mat ute", + "history": [ + { + "description": "mat hemma", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "mat på stan", + "category_id": "01920000-0000-7000-8000-000000000002", + "expense_date": "2026-09-15", + "created_at": "2026-09-15T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "history first token: only the first token counts, not a later one", + "input": { + "description": "Lunch Ica", + "history": [ + { + "description": "Ica lunch", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "lunch", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "history first token: every exact match archived falls through to the first token", + "input": { + "description": "Ikea", + "history": [ + { + "description": "Ikea", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "Ikea", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-02", + "created_at": "2026-09-02T12:00:00.000Z" + }, + { + "description": "Ikea Kungens Kurva", + "category_id": "01920000-0000-7000-8000-000000000009", + "expense_date": "2026-08-01", + "created_at": "2026-08-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000009" + } + }, + { + "name": "history first token: a stored description that normalises to empty has no first token", + "input": { + "description": "Ica", + "history": [ + { + "description": "??", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "history beats the keyword table", + "input": { + "description": "Willys", + "history": [ + { + "description": "willys", + "category_id": "01920000-0000-7000-8000-000000000003", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "every history match archived falls through to the keywords", + "input": { + "description": "Ikea", + "history": [ + { + "description": "Ikea", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-01", + "created_at": "2026-09-01T12:00:00.000Z" + }, + { + "description": "IKEA Backebol", + "category_id": "0192a007-0000-7000-8000-000000000002", + "expense_date": "2026-09-02", + "created_at": "2026-09-02T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "keyword token", + "input": { + "description": "Netflix", + "history": [], + "keywords": [ + { + "keyword": "netflix", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "spotify", + "match": "token", + "default_key": "entertainment" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000007" + } + }, + { + "name": "keyword token does not match a longer token", + "input": { + "description": "Bilar", + "history": [], + "keywords": [ + { + "keyword": "bil", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "keyword prefix", + "input": { + "description": "Pizzeria Napoli", + "history": [], + "keywords": [ + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "keyword prefix matches only at the start of a token", + "input": { + "description": "Snabbpizza", + "history": [], + "keywords": [ + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "keyword prefix also matches the whole token", + "input": { + "description": "Apotek", + "history": [], + "keywords": [ + { + "keyword": "apotek", + "match": "prefix", + "default_key": "health" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000008" + } + }, + { + "name": "keyword phrase", + "input": { + "description": "Bondens marknad Lund", + "history": [], + "keywords": [ + { + "keyword": "bondens marknad", + "match": "phrase", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "keyword phrase needs the tokens consecutive and in order", + "input": { + "description": "Bondens stora marknad, marknad bondens", + "history": [], + "keywords": [ + { + "keyword": "bondens marknad", + "match": "phrase", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "keyword phrase matches across punctuation", + "input": { + "description": "Burger-King", + "history": [], + "keywords": [ + { + "keyword": "burger king", + "match": "phrase", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "keyword phrase does not match the words run together", + "input": { + "description": "Ubereats", + "history": [], + "keywords": [ + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "longest keyword wins: phrase over token in another category", + "input": { + "description": "Uber Eats", + "history": [], + "keywords": [ + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "longest keyword wins: barnförsäkring over the prefix barn", + "input": { + "description": "Barnförsäkring", + "history": [], + "keywords": [ + { + "keyword": "barn", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "barnförsäkring", + "match": "token", + "default_key": "insurance" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000012" + } + }, + { + "name": "longest keyword wins wherever it is in the description", + "input": { + "description": "Fika på Bauhaus", + "history": [], + "keywords": [ + { + "keyword": "bauhaus", + "match": "token", + "default_key": "home" + }, + { + "keyword": "fika", + "match": "prefix", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "equal length: the entry earlier in the table wins", + "input": { + "description": "Bio och mat", + "history": [], + "keywords": [ + { + "keyword": "bio", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000007" + } + }, + { + "name": "equal length: the table order decides, not the order in the description", + "input": { + "description": "Mat och bio", + "history": [], + "keywords": [ + { + "keyword": "bio", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000007" + } + }, + { + "name": "archived keyword category falls through to the next matching entry", + "input": { + "description": "Uber Eats", + "history": [], + "keywords": [ + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000005" + } + }, + { + "name": "archived keyword category with no other match gives the fallback", + "input": { + "description": "Netflix", + "history": [], + "keywords": [ + { + "keyword": "netflix", + "match": "token", + "default_key": "entertainment" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "renamed default category is still found by its default_key", + "input": { + "description": "Hyra mars", + "history": [], + "keywords": [ + { + "keyword": "hyra", + "match": "token", + "default_key": "housing" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000011" + } + }, + { + "name": "full table, cold start: lunch over ikea", + "input": { + "description": "Lunch på IKEA", + "history": [], + "keywords": [ + { + "keyword": "barn", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "blöjor", + "match": "token", + "default_key": "children" + }, + { + "keyword": "fritids", + "match": "token", + "default_key": "children" + }, + { + "keyword": "förskol", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "leksak", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "napp", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "resevagn", + "match": "token", + "default_key": "children" + }, + { + "keyword": "bio", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entre", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entré", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "filmstaden", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "karaoke", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "konsert", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "museet", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "museum", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "netflix", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "padel", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "spotify", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "teater", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "bankavgift", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "kvarskatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "ränteåterbäring", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatteverket", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "alkohol", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bondens marknad", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "bröd", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bubbel", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "city gross", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "coop", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "frukt", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "grönsaker", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "handling", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "hemköp", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "julmat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "lidl", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livs", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livsmedel", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "mjölk", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "netto", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ost", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "snacks", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "storhandling", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "systembolag", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "systemet", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "apotek", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "kry", + "match": "token", + "default_key": "health" + }, + { + "keyword": "läkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "medicin", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "sjukhus", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandläkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandvård", + "match": "token", + "default_key": "health" + }, + { + "keyword": "vårdcentral", + "match": "token", + "default_key": "health" + }, + { + "keyword": "bauhaus", + "match": "token", + "default_key": "home" + }, + { + "keyword": "byggmax", + "match": "token", + "default_key": "home" + }, + { + "keyword": "clas ohlson", + "match": "phrase", + "default_key": "home" + }, + { + "keyword": "elgiganten", + "match": "token", + "default_key": "home" + }, + { + "keyword": "färg", + "match": "token", + "default_key": "home" + }, + { + "keyword": "garderob", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "gardin", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "hylla", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + }, + { + "keyword": "jysk", + "match": "token", + "default_key": "home" + }, + { + "keyword": "lamp", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "möbler", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ohlson", + "match": "token", + "default_key": "home" + }, + { + "keyword": "rusta", + "match": "token", + "default_key": "home" + }, + { + "keyword": "amortering", + "match": "prefix", + "default_key": "housing" + }, + { + "keyword": "avgift", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "bolån", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "brf", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "hyra", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "lgh", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "ränta", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "barnförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "bilförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "folksam", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "försäkring", + "match": "prefix", + "default_key": "insurance" + }, + { + "keyword": "hemförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "länsförsäkringar", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "trygg hansa", + "match": "phrase", + "default_key": "insurance" + }, + { + "keyword": "bagel", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bageri", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bar", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "burger king", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "cafe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "café", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "dumplings", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "fika", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "foodora", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "glass", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "hamburgare", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kaffe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kebab", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "lunch", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "max", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "mcdonalds", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "middag", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "ramen", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "restaurang", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "seml", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "streetfood", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "sushi", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "thai", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "amazon", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "h&m", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "kappahl", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "lindex", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "present", + "match": "prefix", + "default_key": "shopping" + }, + { + "keyword": "skor", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "stadium", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "zalando", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "bensin", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "bil", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "buss", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "flygbuss", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "färj", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "laddning", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "okq8", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "parkering", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "preem", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sj", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sl", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tanka", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "trängselskatt", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tåg", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "airbnb", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "boende", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "booking", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "flyg", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotel", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotell", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "sas", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "bredband", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "elräkning", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "internet", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "städning", + "match": "prefix", + "default_key": "utilities" + }, + { + "keyword": "tele2", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telenor", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telia", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "vattenfall", + "match": "token", + "default_key": "utilities" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "full table, cold start: systembolag prefix", + "input": { + "description": "Systembolaget", + "history": [], + "keywords": [ + { + "keyword": "barn", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "blöjor", + "match": "token", + "default_key": "children" + }, + { + "keyword": "fritids", + "match": "token", + "default_key": "children" + }, + { + "keyword": "förskol", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "leksak", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "napp", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "resevagn", + "match": "token", + "default_key": "children" + }, + { + "keyword": "bio", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entre", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entré", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "filmstaden", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "karaoke", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "konsert", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "museet", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "museum", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "netflix", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "padel", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "spotify", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "teater", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "bankavgift", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "kvarskatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "ränteåterbäring", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatteverket", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "alkohol", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bondens marknad", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "bröd", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bubbel", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "city gross", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "coop", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "frukt", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "grönsaker", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "handling", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "hemköp", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "julmat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "lidl", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livs", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livsmedel", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "mjölk", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "netto", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ost", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "snacks", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "storhandling", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "systembolag", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "systemet", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "apotek", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "kry", + "match": "token", + "default_key": "health" + }, + { + "keyword": "läkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "medicin", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "sjukhus", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandläkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandvård", + "match": "token", + "default_key": "health" + }, + { + "keyword": "vårdcentral", + "match": "token", + "default_key": "health" + }, + { + "keyword": "bauhaus", + "match": "token", + "default_key": "home" + }, + { + "keyword": "byggmax", + "match": "token", + "default_key": "home" + }, + { + "keyword": "clas ohlson", + "match": "phrase", + "default_key": "home" + }, + { + "keyword": "elgiganten", + "match": "token", + "default_key": "home" + }, + { + "keyword": "färg", + "match": "token", + "default_key": "home" + }, + { + "keyword": "garderob", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "gardin", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "hylla", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + }, + { + "keyword": "jysk", + "match": "token", + "default_key": "home" + }, + { + "keyword": "lamp", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "möbler", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ohlson", + "match": "token", + "default_key": "home" + }, + { + "keyword": "rusta", + "match": "token", + "default_key": "home" + }, + { + "keyword": "amortering", + "match": "prefix", + "default_key": "housing" + }, + { + "keyword": "avgift", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "bolån", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "brf", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "hyra", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "lgh", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "ränta", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "barnförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "bilförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "folksam", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "försäkring", + "match": "prefix", + "default_key": "insurance" + }, + { + "keyword": "hemförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "länsförsäkringar", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "trygg hansa", + "match": "phrase", + "default_key": "insurance" + }, + { + "keyword": "bagel", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bageri", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bar", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "burger king", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "cafe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "café", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "dumplings", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "fika", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "foodora", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "glass", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "hamburgare", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kaffe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kebab", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "lunch", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "max", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "mcdonalds", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "middag", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "ramen", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "restaurang", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "seml", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "streetfood", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "sushi", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "thai", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "amazon", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "h&m", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "kappahl", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "lindex", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "present", + "match": "prefix", + "default_key": "shopping" + }, + { + "keyword": "skor", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "stadium", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "zalando", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "bensin", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "bil", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "buss", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "flygbuss", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "färj", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "laddning", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "okq8", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "parkering", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "preem", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sj", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sl", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tanka", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "trängselskatt", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tåg", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "airbnb", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "boende", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "booking", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "flyg", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotel", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotell", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "sas", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "bredband", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "elräkning", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "internet", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "städning", + "match": "prefix", + "default_key": "utilities" + }, + { + "keyword": "tele2", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telenor", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telia", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "vattenfall", + "match": "token", + "default_key": "utilities" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "full table, cold start: hemförsäkring over trygg hansa", + "input": { + "description": "Trygg-Hansa hemförsäkring", + "history": [], + "keywords": [ + { + "keyword": "barn", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "blöjor", + "match": "token", + "default_key": "children" + }, + { + "keyword": "fritids", + "match": "token", + "default_key": "children" + }, + { + "keyword": "förskol", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "leksak", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "napp", + "match": "prefix", + "default_key": "children" + }, + { + "keyword": "resevagn", + "match": "token", + "default_key": "children" + }, + { + "keyword": "bio", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entre", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "entré", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "filmstaden", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "karaoke", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "konsert", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "museet", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "museum", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "netflix", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "padel", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "spotify", + "match": "token", + "default_key": "entertainment" + }, + { + "keyword": "teater", + "match": "prefix", + "default_key": "entertainment" + }, + { + "keyword": "bankavgift", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "kvarskatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "ränteåterbäring", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatt", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "skatteverket", + "match": "token", + "default_key": "fees" + }, + { + "keyword": "alkohol", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bondens marknad", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "bröd", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "bubbel", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "city gross", + "match": "phrase", + "default_key": "groceries" + }, + { + "keyword": "coop", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "frukt", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "grönsaker", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "handling", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "hemköp", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "julmat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "lidl", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livs", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "livsmedel", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "mat", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "mjölk", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "netto", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "ost", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "snacks", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "storhandling", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "systembolag", + "match": "prefix", + "default_key": "groceries" + }, + { + "keyword": "systemet", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "apotek", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "kry", + "match": "token", + "default_key": "health" + }, + { + "keyword": "läkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "medicin", + "match": "prefix", + "default_key": "health" + }, + { + "keyword": "sjukhus", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandläkare", + "match": "token", + "default_key": "health" + }, + { + "keyword": "tandvård", + "match": "token", + "default_key": "health" + }, + { + "keyword": "vårdcentral", + "match": "token", + "default_key": "health" + }, + { + "keyword": "bauhaus", + "match": "token", + "default_key": "home" + }, + { + "keyword": "byggmax", + "match": "token", + "default_key": "home" + }, + { + "keyword": "clas ohlson", + "match": "phrase", + "default_key": "home" + }, + { + "keyword": "elgiganten", + "match": "token", + "default_key": "home" + }, + { + "keyword": "färg", + "match": "token", + "default_key": "home" + }, + { + "keyword": "garderob", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "gardin", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "hylla", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ikea", + "match": "token", + "default_key": "home" + }, + { + "keyword": "jysk", + "match": "token", + "default_key": "home" + }, + { + "keyword": "lamp", + "match": "prefix", + "default_key": "home" + }, + { + "keyword": "möbler", + "match": "token", + "default_key": "home" + }, + { + "keyword": "ohlson", + "match": "token", + "default_key": "home" + }, + { + "keyword": "rusta", + "match": "token", + "default_key": "home" + }, + { + "keyword": "amortering", + "match": "prefix", + "default_key": "housing" + }, + { + "keyword": "avgift", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "bolån", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "brf", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "hyra", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "lgh", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "ränta", + "match": "token", + "default_key": "housing" + }, + { + "keyword": "barnförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "bilförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "folksam", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "försäkring", + "match": "prefix", + "default_key": "insurance" + }, + { + "keyword": "hemförsäkring", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "länsförsäkringar", + "match": "token", + "default_key": "insurance" + }, + { + "keyword": "trygg hansa", + "match": "phrase", + "default_key": "insurance" + }, + { + "keyword": "bagel", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bageri", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "bar", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "burger king", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "cafe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "café", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "dumplings", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "fika", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "foodora", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "glass", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "hamburgare", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kaffe", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "kebab", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "lunch", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "max", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "mcdonalds", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "middag", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "ramen", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "restaurang", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "seml", + "match": "prefix", + "default_key": "restaurants" + }, + { + "keyword": "streetfood", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "sushi", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "thai", + "match": "token", + "default_key": "restaurants" + }, + { + "keyword": "uber eats", + "match": "phrase", + "default_key": "restaurants" + }, + { + "keyword": "amazon", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "h&m", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "kappahl", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "lindex", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "present", + "match": "prefix", + "default_key": "shopping" + }, + { + "keyword": "skor", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "stadium", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "zalando", + "match": "token", + "default_key": "shopping" + }, + { + "keyword": "bensin", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "bil", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "buss", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "flygbuss", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "färj", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "laddning", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "okq8", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "parkering", + "match": "prefix", + "default_key": "transport" + }, + { + "keyword": "preem", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sj", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "sl", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tanka", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "trängselskatt", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "tåg", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "uber", + "match": "token", + "default_key": "transport" + }, + { + "keyword": "airbnb", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "boende", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "booking", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "flyg", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotel", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "hotell", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "sas", + "match": "token", + "default_key": "travel" + }, + { + "keyword": "bredband", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "elräkning", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "internet", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "städning", + "match": "prefix", + "default_key": "utilities" + }, + { + "keyword": "tele2", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telenor", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "telia", + "match": "token", + "default_key": "utilities" + }, + { + "keyword": "vattenfall", + "match": "token", + "default_key": "utilities" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000012" + } + }, + { + "name": "fallback when nothing matches", + "input": { + "description": "Diverse", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-000000000003" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000003" + } + }, + { + "name": "null fallback when nothing matches", + "input": { + "description": "Diverse", + "history": [ + { + "description": "Ica", + "category_id": "01920000-0000-7000-8000-000000000001", + "expense_date": "2026-10-01", + "created_at": "2026-10-01T12:00:00.000Z" + } + ], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": null + }, + "expected": { + "ok": null + } + }, + { + "name": "normalisation: apostrophe deleted", + "input": { + "description": "McDonald's", + "history": [], + "keywords": [ + { + "keyword": "mcdonalds", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "normalisation: typographic apostrophe deleted", + "input": { + "description": "McDonald\u2019s Centralen", + "history": [], + "keywords": [ + { + "keyword": "mcdonalds", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "normalisation: colon deleted", + "input": { + "description": "Willy:s", + "history": [], + "keywords": [ + { + "keyword": "willys", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "normalisation: comma becomes a space", + "input": { + "description": "Taxi,hem", + "history": [], + "keywords": [ + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000005" + } + }, + { + "name": "normalisation: hyphen splits tokens, maxi is not max", + "input": { + "description": "ICA-Maxi", + "history": [], + "keywords": [ + { + "keyword": "ica", + "match": "token", + "default_key": "groceries" + }, + { + "keyword": "max", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "normalisation: underscore is not a letter", + "input": { + "description": "taxi_hem", + "history": [], + "keywords": [ + { + "keyword": "taxi", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000005" + } + }, + { + "name": "normalisation: emoji become spaces", + "input": { + "description": "Pizza\ud83c\udf55Hut", + "history": [], + "keywords": [ + { + "keyword": "pizz", + "match": "prefix", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "normalisation: ampersand kept inside a token", + "input": { + "description": "H&M", + "history": [], + "keywords": [ + { + "keyword": "h&m", + "match": "token", + "default_key": "shopping" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000009" + } + }, + { + "name": "normalisation: ampersand between spaces is a token of its own", + "input": { + "description": "H & M", + "history": [], + "keywords": [ + { + "keyword": "h&m", + "match": "token", + "default_key": "shopping" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "normalisation: NFC composes a decomposed ö", + "input": { + "description": "Hemko\u0308p", + "history": [], + "keywords": [ + { + "keyword": "hemköp", + "match": "token", + "default_key": "groceries" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000001" + } + }, + { + "name": "normalisation: uppercase", + "input": { + "description": "SUSHI", + "history": [], + "keywords": [ + { + "keyword": "sushi", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000002" + } + }, + { + "name": "normalisation: Swedish uppercase letters lowercase", + "input": { + "description": "TÅG till Malmö", + "history": [], + "keywords": [ + { + "keyword": "tåg", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000005" + } + }, + { + "name": "normalisation: diacritics kept, tag is not tåg", + "input": { + "description": "Tag", + "history": [], + "keywords": [ + { + "keyword": "tåg", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "normalisation: diacritics kept, cafe is not café", + "input": { + "description": "Cafe Ritorno", + "history": [], + "keywords": [ + { + "keyword": "café", + "match": "token", + "default_key": "restaurants" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-00000000000a" + } + }, + { + "name": "normalisation: digits kept", + "input": { + "description": "Tele2 faktura", + "history": [], + "keywords": [ + { + "keyword": "tele2", + "match": "token", + "default_key": "utilities" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000004" + } + }, + { + "name": "normalisation: letters and digits in one token", + "input": { + "description": "OKQ8 Lund", + "history": [], + "keywords": [ + { + "keyword": "okq8", + "match": "token", + "default_key": "transport" + } + ], + "active_categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries" + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants" + }, + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing" + }, + { + "id": "01920000-0000-7000-8000-000000000003", + "default_key": "home" + }, + { + "id": "01920000-0000-7000-8000-000000000004", + "default_key": "utilities" + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance" + }, + { + "id": "01920000-0000-7000-8000-000000000005", + "default_key": "transport" + }, + { + "id": "01920000-0000-7000-8000-000000000006", + "default_key": "travel" + }, + { + "id": "01920000-0000-7000-8000-000000000007", + "default_key": "entertainment" + }, + { + "id": "01920000-0000-7000-8000-000000000008", + "default_key": "health" + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children" + }, + { + "id": "01920000-0000-7000-8000-000000000009", + "default_key": "shopping" + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees" + }, + { + "id": "01920000-0000-7000-8000-00000000000a", + "default_key": "other" + } + ], + "fallback": "01920000-0000-7000-8000-00000000000a" + }, + "expected": { + "ok": "01920000-0000-7000-8000-000000000005" + } + } + ] +} diff --git a/server/crates/kvitt-domain/tests/vectors.rs b/server/crates/kvitt-domain/tests/vectors.rs index f2fe38b..bacd9c5 100644 --- a/server/crates/kvitt-domain/tests/vectors.rs +++ b/server/crates/kvitt-domain/tests/vectors.rs @@ -2,7 +2,7 @@ //! //! The directory is read at test time (path from `CARGO_MANIFEST_DIR`), so a new vector file is picked up without a //! code change; a file for a function this crate does not know fails the test unless it is one of the Kotlin-only -//! analytics functions (SPEC §14.5.4). +//! functions (SPEC §14.5.4 analytics and the category suggestion). use std::collections::BTreeMap; use std::path::PathBuf; @@ -15,8 +15,8 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; -/// Functions that exist only in Kotlin (SPEC §14.5.4); their files are parsed but not run here. -const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range"]; +/// Functions that exist only in Kotlin (SPEC §14.5.4, the category suggestion); their files are parsed but not run here. +const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range", "suggest_category"]; /// Every Rust function of SPEC §14.5.2; each must have a vector file. const RUST_FUNCTIONS: &[&str] = &[ "compute_split", diff --git a/tools/contracts-check.py b/tools/contracts-check.py index 0763001..00f5f70 100755 --- a/tools/contracts-check.py +++ b/tools/contracts-check.py @@ -9,19 +9,27 @@ Checks, one output line each: plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders); * contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean foreign_key_check and the seed rows; - * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2 (text check: - the standard library has no YAML parser). + * contracts/schema/migrations/0002_categories_v2.sql loads on top of server-v1.sql (foreign keys on, one + transaction, as sqlx runs it), on an empty database and on one with rows referring to categories, and leaves the + 14 default categories with server_versions from the global sequence and schema_version 2; + * contracts/category-keywords.csv (the keyword table of the category suggestion) is well-formed: header, match + kinds, default keys, keywords already normalised, unique, sorted; + * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and icon and + color stay plain strings (text check: the standard library has no YAML parser). Exit status 0 when every check passes, 1 otherwise. Usage: python3 tools/contracts-check.py [repository root] """ +import csv import datetime +import io import json import os import re import sqlite3 import sys +import unicodedata ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CONTRACTS = os.path.join(ROOT, "contracts") @@ -283,6 +291,100 @@ def resolve_range(preset, today, earliest_expense, custom_start, custom_end): return {"start": s.isoformat(), "end_inclusive": e.isoformat()} +# Category suggestion (UX-3, category-suggestion.md CS-4 to CS-6; Kotlin only) + +LETTER_CATEGORIES = ("Lu", "Ll", "Lt", "Lm", "Lo") + + +def normalize_description(s): + """CS-4: NFC, lowercase without locale rules, delete ' ’ :, every other character that is not a letter, a decimal + digit or & becomes a space, collapse and trim. Works per code point.""" + s = unicodedata.normalize("NFC", s).lower() + s = s.replace("'", "").replace("\u2019", "").replace(":", "") + kept = [] + for c in s: + cat = unicodedata.category(c) + kept.append(c if (cat in LETTER_CATEGORIES or cat == "Nd" or c == "&") else " ") + return " ".join("".join(kept).split()) + + +def _keyword_matches(entry, tokens): + kw, kind = entry["keyword"], entry["match"] + if kind == "token": + return kw in tokens + if kind == "prefix": + return any(t.startswith(kw) for t in tokens) + if kind == "phrase": + kt = kw.split(" ") + return any(tokens[i:i + len(kt)] == kt for i in range(len(tokens) - len(kt) + 1)) + raise ValueError("match %r" % kind) + + +def _history_pick(rows, active, trace, stage): + """CS-5 steps 1 and 2: most often among active categories, ties by the latest (expense_date, created_at) of each + tied category. A remaining tie (same count, same latest date and created_at) goes to the lower category id: + TODO(spec), category-suggestion.md does not say.""" + stats = {} + for r in rows: + cid = r["category_id"] + if cid not in active: + trace["skipped_inactive"] = True + continue + recency = (r["expense_date"], r["created_at"]) + n, latest = stats.get(cid, (0, None)) + stats[cid] = (n + 1, recency if latest is None or recency > latest else latest) + if not stats: + return None + ranked = sorted(stats.items(), key=lambda kv: kv[0]) + ranked.sort(key=lambda kv: kv[1][1], reverse=True) + ranked.sort(key=lambda kv: kv[1][0], reverse=True) + if len(ranked) > 1 and ranked[0][1][0] == ranked[1][1][0]: + trace["count_tie"] = stage + return ranked[0][0] + + +def suggest_category_traced(description, history, keywords, active_categories, fallback): + trace = {} + active = set(c["id"] for c in active_categories) + by_key = dict((c["default_key"], c["id"]) for c in active_categories if c["default_key"] is not None) + norm = normalize_description(description) + if not norm: + trace["stage"] = "empty" + return fallback, trace + tokens = norm.split(" ") + normalised = [(normalize_description(r["description"]), r) for r in history] + got = _history_pick([r for n, r in normalised if n == norm], active, trace, "history_exact") + if got is not None: + trace["stage"] = "history_exact" + return got, trace + got = _history_pick([r for n, r in normalised if n and n.split(" ")[0] == tokens[0]], active, trace, "history_first_token") + if got is not None: + trace["stage"] = "history_first_token" + return got, trace + matching = [(i, e) for i, e in enumerate(keywords) if _keyword_matches(e, tokens)] + matching.sort(key=lambda ie: (-len(ie[1]["keyword"]), ie[0])) + if len(matching) > 1 and len(matching[0][1]["keyword"]) == len(matching[1][1]["keyword"]): + trace["equal_length_tie"] = True + for n, (_, e) in enumerate(matching): + cid = by_key.get(e["default_key"]) + if cid is not None: + trace["stage"] = "keyword" + trace["match"] = e["match"] + if n > 0: + trace["keyword_fell_through"] = True + return cid, trace + if matching: + trace["keyword_fell_through"] = True + trace["stage"] = "fallback" + return fallback, trace + + +def suggest_category(description, history, keywords, active_categories, fallback): + """CS-4 to CS-6 as the pure Kotlin function suggestCategory (UX-3). `history` holds the non-deleted expenses, + `active_categories` the categories that are neither archived nor deleted, `fallback` the AC-8.6 default.""" + return suggest_category_traced(description, history, keywords, active_categories, fallback)[0] + + # ---------------------------------------------------------------------------------------------------------------------- # Test vectors (§14.5.5) # ---------------------------------------------------------------------------------------------------------------------- @@ -313,7 +415,11 @@ VECTOR_FUNCTIONS = { "resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [], lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"], i["custom_end"])), + "suggest_category": (["description", "history", "keywords", "active_categories", "fallback"], [], + lambda i: suggest_category(i["description"], i["history"], i["keywords"], + i["active_categories"], i["fallback"])), } +SUGGEST_MIN_CASES = 30 DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$") @@ -415,6 +521,28 @@ def check_vector_input(fn, inp): for k in ("earliest_expense", "custom_start", "custom_end"): if inp[k] is not None and not valid_date(inp[k]): return k + elif fn == "suggest_category": + if not isinstance(inp["description"], str): + return "description must be a string" + for h in inp["history"]: + if not (isinstance(h, dict) and set(h) == {"description", "category_id", "expense_date", "created_at"} + and isinstance(h["description"], str) and h["description"] and UUID_RE.match(str(h["category_id"])) + and valid_date(h["expense_date"]) and valid_ts(h["created_at"])): + return "history row %r" % (h,) + for k in inp["keywords"]: + if not (isinstance(k, dict) and set(k) == {"keyword", "match", "default_key"} + and isinstance(k["keyword"], str) and k["match"] in KEYWORD_MATCHES and k["default_key"] in DEFAULT_KEYS): + return "keyword %r" % (k,) + for c in inp["active_categories"]: + if not (isinstance(c, dict) and set(c) == {"id", "default_key"} and UUID_RE.match(str(c["id"])) + and (c["default_key"] is None or c["default_key"] in DEFAULT_KEYS)): + return "active category %r" % (c,) + ids = [c["id"] for c in inp["active_categories"]] + keys = [c["default_key"] for c in inp["active_categories"] if c["default_key"] is not None] + if len(set(ids)) != len(ids) or len(set(keys)) != len(keys): + return "active_categories: duplicate id or default_key" + if inp["fallback"] is not None and not UUID_RE.match(str(inp["fallback"])): + return "fallback must be a category id or null" return None @@ -435,6 +563,8 @@ def check_vector_ok_shape(fn, ok): and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"])) if fn == "resolve_range": return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"} + if fn == "suggest_category": + return ok is None or (isinstance(ok, str) and bool(UUID_RE.match(ok))) return False @@ -510,6 +640,32 @@ def coverage(fn, cases): missing.append("custom swapped") if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"): missing.append("every preset with today 2026-10-17") + elif fn == "suggest_category": + if len(cases) < SUGGEST_MIN_CASES: + missing.append("at least %d cases (has %d)" % (SUGGEST_MIN_CASES, len(cases))) + seen = set() + for c in cases: + i = c["input"] + _, t = suggest_category_traced(i["description"], i["history"], i["keywords"], i["active_categories"], i["fallback"]) + seen.add(t["stage"]) + if i["description"] == "": + seen.add("empty description") + if t["stage"] == "keyword": + seen.add("keyword " + t["match"]) + for k in ("skipped_inactive", "equal_length_tie", "keyword_fell_through"): + if t.get(k): + seen.add(k) + if t.get("count_tie"): + seen.add("tie " + t["count_tie"]) + if c["expected"]["ok"] is None: + seen.add("null result") + if normalize_description(i["description"]) != " ".join(i["description"].split()).lower(): + seen.add("normalisation changes the description") + for want in ("empty description", "empty", "history_exact", "history_first_token", "keyword", "fallback", + "keyword token", "keyword prefix", "keyword phrase", "skipped_inactive", "equal_length_tie", + "keyword_fell_through", "tie history_exact", "null result", "normalisation changes the description"): + if want not in seen: + missing.append(want) return missing @@ -607,7 +763,10 @@ def E(*vals): ROLE = E("admin", "member") INVITE_KIND = E("admin_setup", "partner", "device") -DEFAULT_KEY = E("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other") +DEFAULT_KEYS = ["groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", + "shopping", "other", "housing", "insurance", "children", "fees"] +DEFAULT_KEY = E(*DEFAULT_KEYS) +KEYWORD_MATCHES = ["token", "prefix", "phrase"] SPLIT_MODE = E("default", "ratio", "exact", "full") ENTITY_TYPE = E("expense", "settlement") REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew") @@ -621,8 +780,9 @@ PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settleme ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train", "local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center", "medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard", - "celebration", "build", "chair", "subscriptions", "category"] -COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"] + "celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance"] +COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime", + "slate", "amber"] def S(fields, optional=(), patch=False): @@ -1044,6 +1204,239 @@ def check_schema(): db.close() +V1_DEFAULT_CATEGORIES = [ + ("01920000-0000-7000-8000-000000000001", "groceries", "shopping_cart", "green", 10), + ("01920000-0000-7000-8000-000000000002", "restaurants", "restaurant", "orange", 20), + ("01920000-0000-7000-8000-000000000003", "home", "home", "brown", 30), + ("01920000-0000-7000-8000-000000000004", "utilities", "bolt", "yellow", 40), + ("01920000-0000-7000-8000-000000000005", "transport", "directions_car", "blue", 50), + ("01920000-0000-7000-8000-000000000006", "travel", "flight", "teal", 60), + ("01920000-0000-7000-8000-000000000007", "entertainment", "movie", "purple", 70), + ("01920000-0000-7000-8000-000000000008", "health", "medical_services", "red", 80), + ("01920000-0000-7000-8000-000000000009", "shopping", "shopping_bag", "pink", 90), + ("01920000-0000-7000-8000-00000000000a", "other", "category", "grey", 100), +] +V2_NEW_CATEGORIES = [ + ("01920000-0000-7000-8000-000000000011", "housing", "key", "indigo", 25), + ("01920000-0000-7000-8000-000000000012", "insurance", "shield", "slate", 45), + ("01920000-0000-7000-8000-000000000013", "children", "child_care", "lime", 85), + ("01920000-0000-7000-8000-000000000014", "fees", "account_balance", "amber", 95), +] +MIGRATION_0002 = os.path.join("schema", "migrations", "0002_categories_v2.sql") + + +def _migrate_0002(db, sql): + """Applies 0002 the way sqlx does: one transaction, foreign keys on (set before it starts).""" + db.isolation_level = None + db.execute("BEGIN") + try: + for stmt in _split_sql(sql): + db.execute(stmt) + db.execute("COMMIT") + except sqlite3.Error: + db.execute("ROLLBACK") + raise + + +def _split_sql(sql): + out, buf = [], "" + for line in sql.splitlines(True): + buf += line + if sqlite3.complete_statement(buf): + out.append(buf) + buf = "" + if buf.strip() and not all(l.strip().startswith("--") or not l.strip() for l in buf.splitlines()): + out.append(buf) + return out + + +def check_migration_0002(): + path = os.path.join(CONTRACTS, MIGRATION_0002) + v1 = os.path.join(CONTRACTS, "schema", "server-v1.sql") + if not os.path.exists(path): + report(False, "schema 0002_categories_v2.sql exists", "missing") + return + with open(path, encoding="utf-8") as f: + sql = f.read() + with open(v1, encoding="utf-8") as f: + v1sql = f.read() + if re.search(r"^\s*(BEGIN|COMMIT|END|ROLLBACK)\b", sql, re.M | re.I) or re.search(r"PRAGMA\s+foreign_keys\b", sql, re.I): + report(False, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)", "found one") + else: + report(True, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)") + + def fresh(): + db = sqlite3.connect(":memory:") + db.execute("PRAGMA foreign_keys = ON") + db.executescript(v1sql) + return db + + # 1. empty v1 database + db = fresh() + try: + _migrate_0002(db, sql) + except sqlite3.Error as e: + report(False, "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", str(e)) + return + fk = db.execute("PRAGMA foreign_key_check").fetchall() + integ = db.execute("PRAGMA integrity_check").fetchone()[0] + report(not fk and integ == "ok", "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", + "foreign_key_check %r, integrity %s" % (fk, integ) if (fk or integ != "ok") else "SQLite %s" % sqlite3.sqlite_version) + meta = dict(db.execute("SELECT key, value FROM meta")) + seq = db.execute("SELECT id, value FROM sync_sequence").fetchall() + rows = db.execute("SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, " + "deleted_at, server_version FROM categories ORDER BY sort_order").fetchall() + want = sorted([(i, k, None, ic, co, so, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", None, None, n + 1) + for n, (i, k, ic, co, so) in enumerate(V1_DEFAULT_CATEGORIES)] + + [(i, k, None, ic, co, so, "2026-10-18T00:00:00.000Z", "2026-10-18T00:00:00.000Z", None, None, 11 + n) + for n, (i, k, ic, co, so) in enumerate(V2_NEW_CATEGORIES)], key=lambda r: r[5]) + ok = meta.get("schema_version") == "2" and seq == [(1, 14)] and rows == want + report(ok, "schema 0002 on a fresh database: 14 default categories, new server_versions 11..14, sync_sequence 14, schema_version 2", + "meta %r, sequence %r, categories %r" % (meta, seq, rows) if not ok else "") + keys = [r[1] for r in rows] + report(sorted(keys) == sorted(DEFAULT_KEYS) and all(r[3] in ICONS and r[4] in COLORS for r in rows), + "schema 0002 default keys, icons and colours are the checker's §9.7 / AC-7.2 lists", "keys %r" % keys) + try: + db.execute("INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, server_version) " + "VALUES ('x', 'pets', NULL, 'pets', 'lime', 1, 't', 't', 999)") + report(False, "schema 0002 keeps a default_key CHECK", "an unknown default_key was accepted") + except sqlite3.IntegrityError: + report(True, "schema 0002 keeps a default_key CHECK") + seed_sql = ( + "INSERT INTO users VALUES ('u1', 'admin', 'A', NULL, 't', 't', 11);" + "INSERT INTO accounts VALUES ('a1', 'Vardag', 'vardag', 'u1', 't', 't', NULL, NULL, 12);" + "INSERT INTO categories VALUES ('0192a007-0000-7000-8000-000000000001', NULL, 'Husdjur', 'pets', 'lime', 110, 't', 't', 't', NULL, 13);" + "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, expense_date, payer_user_id, split_mode," + " created_by_user_id, updated_by_user_id, created_at, updated_at, server_received_at, server_version)" + " VALUES ('e1', 'a1', '01920000-0000-7000-8000-000000000001', 100, 'Ica', '2026-10-01', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 14)," + " ('e2', 'a1', '0192a007-0000-7000-8000-000000000001', 200, 'Hundmat', '2026-10-02', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 15);" + "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, payer_user_id, split_mode, day_of_month," + " start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, server_version)" + " VALUES ('r1', 'a1', '01920000-0000-7000-8000-000000000004', 300, 'El', 'u1', 'default', 1, '2026-10', 'u1', 'u1', 't', 't', 16);" + "UPDATE categories SET name = 'Matvaror', server_version = 17 WHERE default_key = 'groceries';" + "UPDATE sync_sequence SET value = 917 WHERE id = 1;") + db.close() + # 2. a v1 database in use: renamed default, custom archived category, expenses and a template referring to categories + db = fresh() + db.executescript(seed_sql) + before = db.execute("SELECT * FROM categories ORDER BY id").fetchall() + try: + _migrate_0002(db, sql) + fk = db.execute("PRAGMA foreign_key_check").fetchall() + after = db.execute("SELECT * FROM categories WHERE id NOT IN (%s) ORDER BY id" % ",".join("'%s'" % c[0] for c in V2_NEW_CATEGORIES)).fetchall() + new = db.execute("SELECT default_key, server_version FROM categories WHERE created_at = '2026-10-18T00:00:00.000Z' ORDER BY server_version").fetchall() + seq = db.execute("SELECT value FROM sync_sequence").fetchone()[0] + refs = db.execute("SELECT count(*) FROM expenses e JOIN categories c ON c.id = e.category_id").fetchone()[0] + defer = db.execute("PRAGMA defer_foreign_keys").fetchone()[0] + ok = (not fk and after == before and new == [("housing", 918), ("insurance", 919), ("children", 920), ("fees", 921)] + and seq == 921 and refs == 2 and defer == 0) + report(ok, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", + "fk %r, kept %s, new %r, sequence %r, refs %r, defer %r" % (fk, after == before, new, seq, refs, defer) if not ok else "") + except sqlite3.Error as e: + report(False, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", str(e)) + db.close() + # 3. the deferred check still catches a dangling reference: drop the referenced row inside the same transaction + db = fresh() + db.executescript(seed_sql) + try: + _migrate_0002(db, sql + "\nDELETE FROM categories WHERE id = '0192a007-0000-7000-8000-000000000001';\n") + report(False, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)", "a dangling expense reference was committed") + except sqlite3.IntegrityError: + report(True, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)") + db.close() + server_copy = os.path.join(ROOT, "server", "crates", "kvitt-server", "migrations", "0002_categories_v2.sql") + if os.path.exists(server_copy): + with open(server_copy, encoding="utf-8") as f: + same = f.read() == sql + report(same, "schema server migrations/0002_categories_v2.sql is byte-identical to the contract", "" if same else "differs") + + +# ---------------------------------------------------------------------------------------------------------------------- +# Keyword table of the category suggestion (UX-3, CS-6) +# ---------------------------------------------------------------------------------------------------------------------- + +KEYWORDS_CSV = "category-keywords.csv" +KEYWORDS_HEADER = ["keyword", "match", "default_key", "source"] + + +def load_keywords(): + path = os.path.join(CONTRACTS, KEYWORDS_CSV) + if not os.path.exists(path): + return None, None + with open(path, "rb") as f: + raw = f.read() + return raw, list(csv.reader(io.StringIO(raw.decode("utf-8"), newline=""))) + + +def check_keywords(): + raw, rows = load_keywords() + if raw is None: + report(False, "keywords %s exists" % KEYWORDS_CSV, "missing") + return [] + problems = [] + if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"): + problems.append("must be UTF-8 without BOM, LF line ends, one final newline") + if not rows or rows[0] != KEYWORDS_HEADER: + problems.append("header must be %s" % ",".join(KEYWORDS_HEADER)) + entries = [] + for n, r in enumerate(rows[1:], start=2): + if len(r) != 4: + problems.append("line %d: %d columns" % (n, len(r))) + continue + kw, kind, key, source = r + if kind not in KEYWORD_MATCHES: + problems.append("line %d: match %r" % (n, kind)) + if key not in DEFAULT_KEYS or key == "other": + problems.append("line %d: default_key %r (one of the 14 keys, never other)" % (n, key)) + if source not in ("data", "curated"): + problems.append("line %d: source %r" % (n, source)) + if not kw or normalize_description(kw) != kw: + problems.append("line %d: keyword %r is not normalised (CS-4 gives %r)" % (n, kw, normalize_description(kw))) + if (kind == "phrase") != (" " in kw): + problems.append("line %d: %r: a phrase has two or more tokens, a token or prefix exactly one" % (n, kw)) + entries.append({"keyword": kw, "match": kind, "default_key": key, "source": source}) + kws = [e["keyword"] for e in entries] + dup = sorted(set(k for k in kws if kws.count(k) > 1)) + if dup: + problems.append("duplicate keywords %s" % dup) + order = [(e["default_key"], e["keyword"]) for e in entries] + if order != sorted(order): + bad = [b for a, b in zip(order, order[1:]) if a > b] + problems.append("not sorted by default_key, then keyword (code points); first out of order %r" % (bad[:1],)) + unused = sorted(set(k for k in DEFAULT_KEYS if k != "other") - set(e["default_key"] for e in entries)) + detail = "%d entries (%s), every default key but other covered%s" % ( + len(entries), ", ".join("%d %s" % (sum(1 for e in entries if e["match"] == m), m) for m in KEYWORD_MATCHES), + "" if not unused else "; keys without entries: %s" % unused) + report(not problems, "keywords %s: header, match kinds, default keys, normalised, unique, sorted" % KEYWORDS_CSV, + "; ".join(problems[:6]) if problems else detail) + return entries + + +def check_vector_keywords(table): + """Every keyword list in suggest_category.json is a subsequence of the table (same entries, table order).""" + path = os.path.join(CONTRACTS, "test-vectors", "suggest_category.json") + if not table or not os.path.exists(path): + return + try: + with open(path, encoding="utf-8") as f: + cases = json.load(f)["cases"] + except (ValueError, KeyError, TypeError): + return + index = dict((e["keyword"], (n, e["match"], e["default_key"])) for n, e in enumerate(table)) + bad = [] + for c in cases: + try: + pos = [index.get(k["keyword"]) for k in c["input"]["keywords"]] + ok = (all(p is not None and (p[1], p[2]) == (k["match"], k["default_key"]) for p, k in zip(pos, c["input"]["keywords"])) + and [p[0] for p in pos] == sorted(set(p[0] for p in pos))) + except (KeyError, TypeError): + ok = False + if not ok: + bad.append(c.get("name")) + report(not bad, "vectors suggest_category.json keyword lists are taken from %s in table order" % KEYWORDS_CSV, + "cases %s" % bad[:5] if bad else "%d cases" % len(cases)) + + # ---------------------------------------------------------------------------------------------------------------------- # OpenAPI (§7, §14.3) — text-level check # ---------------------------------------------------------------------------------------------------------------------- @@ -1117,6 +1510,12 @@ def check_openapi(): m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "") + leaked = [t for t in ("ColorToken", "IconToken") if re.search(r"^ %s:" % t, text, re.M)] + icon_plain = re.search(r"^ icon:\n type: string\n", text, re.M) is not None + color_plain = re.search(r"^ color:\n type: string\n", text, re.M) is not None + report(not leaked and icon_plain and color_plain, + "openapi icon and color stay plain strings (§9.7 tokens, incl. key, shield, account_balance, slate, amber, are checked in code)", + "token enums %s, icon plain %s, color plain %s" % (leaked, icon_plain, color_plain) if (leaked or not icon_plain or not color_plain) else "") schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M)) need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation", "Settlement", "RecurringTemplate", "AuditEntry", "Error"} @@ -1146,8 +1545,11 @@ def main(): else: check_json_parses() counts = check_vectors() + table = check_keywords() + check_vector_keywords(table) check_examples() check_schema() + check_migration_0002() check_openapi() if counts: print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())))