--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -177,8 +177,8 @@

 @router.get("/transaction-candidates")
-def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", conn: Connection = Depends(get_db)):
-    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir)
+def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
+    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)

 @router.get("/transaction-candidates/summary")


patch

return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""), budget_year=str(payload.get("budget_year") or "2026"))

return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""))


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -259,7 +259,7 @@

 @router.post("/transaction-candidates/batch/preview")
 def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
-    return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""))
+    return preview_batch_confirm_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], account_id=str(payload.get("account_id") or ""), budget_year=str(payload.get("budget_year") or "2026"))

 @router.post("/transaction-candidates/batch")


return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))

return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"))


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -264,7 +264,7 @@

 @router.post("/transaction-candidates/batch")
 def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
-    return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"))
+    return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))

 @router.post("/transaction-candidates/batch-confirm-safe")


@router.post("/transaction-candidates/archive-reference-year")
def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
    payload = payload or {}
    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))

@router.post("/transaction-candidates/batch-confirm-safe")


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -267,6 +267,12 @@
     return batch_mark_candidates(conn, candidate_ids=payload.get("candidate_ids") or [], action=str(payload.get("action") or ""), account_id=payload.get("account_id"), category_id=payload.get("category_id"), preview_id=payload.get("preview_id"), note=payload.get("note"), budget_year=str(payload.get("budget_year") or "2026"))

+@router.post("/transaction-candidates/archive-reference-year")
+def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
+    payload = payload or {}
+    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
+
+
 @router.post("/transaction-candidates/batch-confirm-safe")
 def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
     account_id = str(payload.get("account_id") or "")


def transactions(month: str | None = None, year: str | None = "2026", account_id: str | None = None, category_id: str | None = None, transaction_type: str | None = None, merchant: str | None = None, tag: str | None = None, status: str | None = None, source_type: str | None = None, search: str | None = None, conn: Connection = Depends(get_db)):
    return list_budget_transactions(conn, month=month, year=year, account_id=account_id, category_id=category_id, transaction_type=transaction_type, merchant=merchant, tag=tag, status=status, source_type=source_type, search=search)

def transactions(month: str | None = None, account_id: str | None = None, category_id: str | None = None, transaction_type: str | None = None, merchant: str | None = None, tag: str | None = None, status: str | None = None, source_type: str | None = None, search: str | None = None, conn: Connection = Depends(get_db)):
    return list_budget_transactions(conn, month=month, account_id=account_id, category_id=category_id, transaction_type=transaction_type, merchant=merchant, tag=tag, status=status, source_type=source_type, search=search)


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -395,8 +395,8 @@

 @router.get("/transactions")
-def transactions(month: str | None = None, account_id: str | None = None, category_id: str | None = None, transaction_type: str | None = None, merchant: str | None = None, tag: str | None = None, status: str | None = None, source_type: str | None = None, search: str | None = None, conn: Connection = Depends(get_db)):
-    return list_budget_transactions(conn, month=month, account_id=account_id, category_id=category_id, transaction_type=transaction_type, merchant=merchant, tag=tag, status=status, source_type=source_type, search=search)
+def transactions(month: str | None = None, year: str | None = "2026", account_id: str | None = None, category_id: str | None = None, transaction_type: str | None = None, merchant: str | None = None, tag: str | None = None, status: str | None = None, source_type: str | None = None, search: str | None = None, conn: Connection = Depends(get_db)):
+    return list_budget_transactions(conn, month=month, year=year, account_id=account_id, category_id=category_id, transaction_type=transaction_type, merchant=merchant, tag=tag, status=status, source_type=source_type, search=search)

 @router.post("/transactions/preview")


Explanation:
**Considering UI categories**

I'm thinking about how the potential UI for categories will work. The flatCategories assumption implies that nested bucket children are okay, which is great! Now, I need to focus on defining the frontend API types that will support this structure. I wonder what other considerations I should keep in mind as I flesh this out. It feels like there’s a lot to think through in terms of functionalities and integration, but I’m ready to dive into it!

export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month: string; budget_year: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; months_with_bookings: number; plan_names?: string[] }

export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month: string; budget_year: string; actual_year_to_date: string; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; months_with_bookings: number; plan_names?: string[] }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -25,7 +25,7 @@
   notes?: string | null
 }
 export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
-export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month: string; budget_year: string; actual_year_to_date: string; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; months_with_bookings: number; plan_names?: string[] }
+export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month: string; budget_year: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; months_with_bookings: number; plan_names?: string[] }
 export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null }
 export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
 export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean }

npx not usable: This is not the tsc command you are looking for

/home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts was modified by sibling subagent '20260517_152502_5297cf' but this agent never read it. Read the file before writing to avoid overwriting the sibling's changes.


export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year: string; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
export type BudgetOverview =


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -29,6 +29,7 @@
 export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null }
 export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
 export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean }
+export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year: string; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
 export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }

 export const getBudgetOverview = () => apiGet<BudgetOverview>('/api/budget/overview')


export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -34,6 +34,7 @@

 export const getBudgetOverview = () => apiGet<BudgetOverview>('/api/budget/overview')
 export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
+export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
 export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
 export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
 export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')


export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })

export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string) => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -90,7 +90,7 @@
 export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
 export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
 export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
-export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string) => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id })
+export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
 export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
 export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
 export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)


Explanation:
**Improving UI for Budget Review**

I’m thinking about minimizing the Patch UI, which could lead to a more streamlined experience. For the BudgetImportReview, it sounds useful to add a year selection and budget year filters. This would help clarify the current budget context. I also want to think about displaying the selected count and any errors clearly. These adjustments should contribute to a smoother and more efficient workflow. Let’s move forward with these ideas!

        <select v-model="confirmAccount" class="field"><option value="">Confirm-Konto wählen</option><option v-for="account in accounts" :key="account.budget_account_id" :value="account.budget_account_id">{{ account.name }}</option></select>
        <select v-model="filters.budget_year" class="field"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -20,6 +20,7 @@
       </div>
       <div class="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
         <select v-model="confirmAccount" class="field"><option value="">Confirm-Konto wählen</option><option v-for="account in accounts" :key="account.budget_account_id" :value="account.budget_account_id">{{ account.name }}</option></select>
+        <select v-model="filters.budget_year" class="field"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
         <select v-model="status" class="field"><option value="">offene Kandidaten</option><option>pending</option><option>auto_categorized</option><option>needs_review</option><option>duplicate</option><option>transfer_candidate</option><option>ignored</option></select>
         <select v-model="bulkCategory" class="field"><option value="">Bulk-Kategorie</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select>
         <input v-model="filters.source_type" class="field" placeholder="Quelle" />

No linter for .vue files

/home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue was modified by sibling subagent '20260517_152502_5297cf' but this agent never read it. Read the file before writing to avoid overwriting the sibling's changes.


        Ausgewählt: {{ selectedIds.length }}
        <button class="btn" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory">Auswahl Kategorie zuweisen</button>

      <div class="mt-4 flex flex-wrap gap-2">
        <button class="btn" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory">Auswahl kategorisieren</button>


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -36,8 +36,9 @@
         <select v-model="filters.sort_dir" class="field"><option value="desc">absteigend</option><option value="asc">aufsteigend</option></select>
         <button class="btn-primary" @click="load">Filtern</button>

-

-        <button class="btn" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory">Auswahl kategorisieren</button>
+

+        Ausgewählt: {{ selectedIds.length }}
+        <button class="btn" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory">Auswahl Kategorie zuweisen</button>
         <button class="btn" :disabled="!selectedIds.length" @click="previewBatchConfirm">Batch Confirm Preview</button>
         <button class="btn" :disabled="!selectedIds.length" @click="bulkIgnore">Auswahl ignorieren</button>
         <button class="btn" :disabled="!selectedIds.length" @click="bulkReopen">Auswahl wieder öffnen</button>

No linter for .vue files


Fehler pro Kandidat

{{ err.candidate_id }} · {{ err.error }}


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -50,6 +50,11 @@

Konflikte: {{ batchPreview.conflicts.length }}

Kategorien: {{ Object.entries(batchPreview.categories).map(([k,v]) => `${k}: ${v}`).join(', ') || '—' }}

         <button class="mt-3 btn-primary" @click="confirmBatchPreview">Jetzt ausgewählte bestätigen</button>
+

+
+

+
Fehler pro Kandidat

+
{{ err.candidate_id }} · {{ err.error }}

No linter for .vue files


const batchPreview = ref<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>> } | null>(null)
const batchErrors = ref<Array<{ candidate_id?: unknown; error?: unknown }>>([])


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -90,6 +90,7 @@
 const candidates = ref<BudgetTransactionCandidate[]>([]); const accounts = ref<BudgetAccount[]>([]); const categories = ref<BudgetCategory[]>([]); const selected = ref<string[]>([])
 const summary = ref<BudgetReviewSummary | null>(null); const status = ref(''); const activeTab = ref(''); const bulkCategory = ref(''); const confirmAccount = ref(''); const error = ref(''); const message = ref('')
 const batchPreview = ref<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>> } | null>(null)
+const batchErrors = ref<Array<{ candidate_id?: unknown; error?: unknown }>>([])
 const detail = ref<BudgetTransactionCandidate | null>(null); const splitCandidate = ref<BudgetTransactionCandidate | null>(null); const newCategoryFor = ref<BudgetTransactionCandidate | null>(null)
 const filters = reactive({ source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })
 const newCategory = reactive({ name: '', parent_category_id: '', category_type: 'expense' })

No linter for .vue files


const filters = reactive({ budget_year: '2026', source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })

const filters = reactive({ source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -92,7 +92,7 @@
 const batchPreview = ref<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>> } | null>(null)
 const batchErrors = ref<Array<{ candidate_id?: unknown; error?: unknown }>>([])
 const detail = ref<BudgetTransactionCandidate | null>(null); const splitCandidate = ref<BudgetTransactionCandidate | null>(null); const newCategoryFor = ref<BudgetTransactionCandidate | null>(null)
-const filters = reactive({ source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })
+const filters = reactive({ budget_year: '2026', source_type: '', category_id: '', merchant: '', rule: '', min_confidence: '', date_from: '', date_to: '', amount_min: '', amount_max: '', sort_by: 'created_at', sort_dir: 'desc' })
 const newCategory = reactive({ name: '', parent_category_id: '', category_type: 'expense' })
 const splits = ref([{ category_id: '', amount_original: '', tag: '', note: '' }, { category_id: '', amount_original: '', tag: '', note: '' }])
 const tabs = [{ key: '', label: 'Alle' }, { key: 'auto', label: 'Auto' }, { key: 'review', label: 'Review nötig' }, { key: 'galaxus', label: 'Galaxus/Digitec' }, { key: 'migros_over_50', label: 'Migros > 50' }, { key: 'covered_by_migros', label: 'Covered' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'unclear', label: 'Unklar' }, { key: 'ignored', label: 'Ignoriert' }]

No linter for .vue files


async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchErrors.value=[]; batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value, filters.budget_year); error.value='' }
async function confirmBatchPreview() { if (!batchPreview.value) return; const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'confirm', account_id: confirmAccount.value, preview_id: batchPreview.value.preview_id, budget_year: filters.budget_year }); batchErrors.value = ((result as any).errors || []) as any; message.value='Batch Confirm ausgeführt.'; selected.value=[]; await load() }

async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value); error.value='' }
async function confirmBatchPreview() { if (!batchPreview.value) return; await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'confirm', account_id: confirmAccount.value, preview_id: batchPreview.value.preview_id }); message.value='Batch Confirm ausgeführt.'; selected.value=[]; await load() }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -108,8 +108,8 @@
 async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense' }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }
 async function setCategory(item: BudgetTransactionCandidate, categoryId: string) { if (!categoryId) return; await updateBudgetTransactionCandidateCategory(item.transaction_candidate_id, categoryId, 'Inline category override'); message.value = 'Kategorie aktualisiert.'; await load() }
 async function bulkSetCategory() { await bulkUpdateBudgetTransactionCandidateCategory(selected.value, bulkCategory.value, 'Bulk category assignment'); message.value='Auswahl kategorisiert.'; await load() }
-async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value); error.value='' }
-async function confirmBatchPreview() { if (!batchPreview.value) return; await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'confirm', account_id: confirmAccount.value, preview_id: batchPreview.value.preview_id }); message.value='Batch Confirm ausgeführt.'; selected.value=[]; await load() }
+async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchErrors.value=[]; batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value, filters.budget_year); error.value='' }
+async function confirmBatchPreview() { if (!batchPreview.value) return; const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'confirm', account_id: confirmAccount.value, preview_id: batchPreview.value.preview_id, budget_year: filters.budget_year }); batchErrors.value = ((result as any).errors || []) as any; message.value='Batch Confirm ausgeführt.'; selected.value=[]; await load() }
 async function bulkIgnore() { await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'ignore' }); message.value='Auswahl ignoriert.'; selected.value=[]; await load() }
 async function bulkReopen() { await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'reopen' }); message.value='Auswahl wieder geöffnet.'; selected.value=[]; await load() }
 async function bulkTransfer() { await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'transfer' }); message.value='Auswahl als Transfer markiert.'; selected.value=[]; await load() }

No linter for .vue files


async function bulkSetCategory() { const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'category', category_id: bulkCategory.value, budget_year: filters.budget_year, note: 'Bulk category assignment' }); batchErrors.value = ((result as any).errors || []) as any; message.value='Auswahl kategorisiert.'; await load() }
async function previewBatchConfirm()

async function bulkSetCategory() { await bulkUpdateBudgetTransactionCandidateCategory(selected.value, bulkCategory.value, 'Bulk category assignment'); message.value='Auswahl kategorisiert.'; await load() }
async function previewBatchConfirm()


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -107,7 +107,7 @@
 function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','superseded','transfer_candidate','confirmed','ignored'].includes(item.status) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
 async function confirmItem(item: BudgetTransactionCandidate) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; return } await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense' }); message.value = 'Kandidat bestätigt.'; error.value=''; await load() }
 async function setCategory(item: BudgetTransactionCandidate, categoryId: string) { if (!categoryId) return; await updateBudgetTransactionCandidateCategory(item.transaction_candidate_id, categoryId, 'Inline category override'); message.value = 'Kategorie aktualisiert.'; await load() }
-async function bulkSetCategory() { await bulkUpdateBudgetTransactionCandidateCategory(selected.value, bulkCategory.value, 'Bulk category assignment'); message.value='Auswahl kategorisiert.'; await load() }
+async function bulkSetCategory() { const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'category', category_id: bulkCategory.value, budget_year: filters.budget_year, note: 'Bulk category assignment' }); batchErrors.value = ((result as any).errors || []) as any; message.value='Auswahl kategorisiert.'; await load() }
 async function previewBatchConfirm() { if (!confirmAccount.value) { error.value='Confirm-Konto fehlt.'; return } batchErrors.value=[]; batchPreview.value = await previewBatchBudgetTransactionCandidates(selected.value, confirmAccount.value, filters.budget_year); error.value='' }
 async function confirmBatchPreview() { if (!batchPreview.value) return; const result = await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'confirm', account_id: confirmAccount.value, preview_id: batchPreview.value.preview_id, budget_year: filters.budget_year }); batchErrors.value = ((result as any).errors || []) as any; message.value='Batch Confirm ausgeführt.'; selected.value=[]; await load() }
 async function bulkIgnore() { await batchBudgetTransactionCandidates({ candidate_ids: selected.value, action: 'ignore' }); message.value='Auswahl ignoriert.'; selected.value=[]; await load() }

No linter for .vue files


Explanation:
**Removing unused imports**

I’m looking at the code and noticing the unused import for bulkUpdate, which is likely causing issues with TS's noUnused rule. I think it might be tied to noUnusedLocals. So, it seems like I need to patch this by removing the import altogether. It's always a good practice to clean up any unused parts in code to avoid any confusion or errors later on. Let's do this!

import { batchBudgetTransactionCandidates, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateTransfer, previewBatchBudgetTransactionCandidates, reopenBudgetTransactionCandidate, updateBudgetTransactionCandidateCategory, type BudgetAccount, type BudgetCategory, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'

import { batchBudgetTransactionCandidates, bulkUpdateBudgetTransactionCandidateCategory, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateTransfer, previewBatchBudgetTransactionCandidates, reopenBudgetTransactionCandidate, updateBudgetTransactionCandidateCategory, type BudgetAccount, type BudgetCategory, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -86,7 +86,7 @@
 </template>
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
-import { batchBudgetTransactionCandidates, bulkUpdateBudgetTransactionCandidateCategory, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateTransfer, previewBatchBudgetTransactionCandidates, reopenBudgetTransactionCandidate, updateBudgetTransactionCandidateCategory, type BudgetAccount, type BudgetCategory, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'
+import { batchBudgetTransactionCandidates, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateTransfer, previewBatchBudgetTransactionCandidates, reopenBudgetTransactionCandidate, updateBudgetTransactionCandidateCategory, type BudgetAccount, type BudgetCategory, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'
 const candidates = ref<BudgetTransactionCandidate[]>([]); const accounts = ref<BudgetAccount[]>([]); const categories = ref<BudgetCategory[]>([]); const selected = ref<string[]>([])
 const summary = ref<BudgetReviewSummary | null>(null); const status = ref(''); const activeTab = ref(''); const bulkCategory = ref(''); const confirmAccount = ref(''); const error = ref(''); const message = ref('')
 const batchPreview = ref<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>> } | null>(null)

No linter for .vue files


const filters = reactive({ year: '2026', month: '', account_id: '', category_id: '', tag: '', source_type: '', merchant: '', search: '' })

const filters = reactive({ month: '', account_id: '', category_id: '', tag: '', source_type: '', merchant: '', search: '' })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetTransactionsPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetTransactionsPage.vue
@@ -68,7 +68,7 @@
 const isManual = computed(() => route?.name === 'budget-expenses-manual')
 const accounts = ref<BudgetAccount[]>([]); const categories = ref<BudgetCategory[]>([]); const transactions = ref<BudgetTransaction[]>([]); const mode = ref(false)
 const selectedTx = ref<BudgetTransaction | null>(null); const previewResult = ref<BudgetPreview | null>(null); const message = ref('')
-const filters = reactive({ month: '', account_id: '', category_id: '', tag: '', source_type: '', merchant: '', search: '' })
+const filters = reactive({ year: '2026', month: '', account_id: '', category_id: '', tag: '', source_type: '', merchant: '', search: '' })
 const form = reactive({ account_id: '', amount_original: '', currency_original: 'CHF', transaction_date: new Date().toISOString().slice(0,10), description: '', category_id: '', notes: '' })
 const edit = reactive({ category_id: '', description: '', tag_names: '', change_note: '' })
 const flatCategories = computed(() => { const out: BudgetCategory[] = []; const walk = (items: BudgetCategory[]) => items.forEach(i => { out.push(i); walk(i.children || []) }); walk(categories.value); return out })

No linter for .vue files

/home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetTransactionsPage.vue was modified by sibling subagent '20260517_152502_5297cf' but this agent never read it. Read the file before writing to avoid overwriting the sibling's changes.


    <div class="grid gap-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-4 xl:grid-cols-7">
      <select v-model="filters.year" class="field" @change="loadTransactions"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
      <input v-model="filters.month" class="field" placeholder="Zeitraum YYYY-MM" @change="loadTransactions" />

    <div class="grid gap-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-4 xl:grid-cols-7">
      <input v-model="filters.month" class="field" placeholder="Zeitraum YYYY-MM" @change="loadTransactions" />


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetTransactionsPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetTransactionsPage.vue
@@ -17,6 +17,7 @@
     </div>

     <div class="grid gap-3 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-4 xl:grid-cols-7">
+      <select v-model="filters.year" class="field" @change="loadTransactions"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
       <input v-model="filters.month" class="field" placeholder="Zeitraum YYYY-MM" @change="loadTransactions" />
       <select v-model="filters.account_id" class="field" @change="loadTransactions"><option value="">Alle Konten</option><option v-for="a in accounts" :key="a.budget_account_id" :value="a.budget_account_id">{{ a.name }}</option></select>
       <select v-model="filters.category_id" class="field" @change="loadTransactions"><option value="">Alle Kategorien</option><option value="__uncategorized">Unkategorisiert</option><option v-for="c in flatCategories" :key="c.category_id" :value="c.category_id">{{ c.name }}</option></select>

No linter for .vue files


import { getBudgetCategories, getBudgetCharts, getBudgetOverview, getBudgetTransactions, updateBudgetTransaction, type BudgetCategory, type BudgetCharts, type BudgetOverview, type BudgetStatusRow, type BudgetTransaction } from '@/api/budget'

import { getBudgetCategories, getBudgetOverview, getBudgetTransactions, updateBudgetTransaction, type BudgetCategory, type BudgetOverview, type BudgetStatusRow, type BudgetTransaction } from '@/api/budget'


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -113,7 +113,7 @@
 </template>
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
-import { getBudgetCategories, getBudgetOverview, getBudgetTransactions, updateBudgetTransaction, type BudgetCategory, type BudgetOverview, type BudgetStatusRow, type BudgetTransaction } from '@/api/budget'
+import { getBudgetCategories, getBudgetCharts, getBudgetOverview, getBudgetTransactions, updateBudgetTransaction, type BudgetCategory, type BudgetCharts, type BudgetOverview, type BudgetStatusRow, type BudgetTransaction } from '@/api/budget'
 const overview = ref<BudgetOverview | null>(null)
 const categories = ref<BudgetCategory[]>([])
 const selected = ref<BudgetStatusRow | null>(null)

No linter for .vue files

/home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue was modified by sibling subagent '20260517_104305_ba8569' but this agent never read it. Read the file before writing to avoid overwriting the sibling's changes.


const overview = ref<BudgetOverview | null>(null)
const charts = ref<BudgetCharts | null>(null)
const activeYear = ref('2026')


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -115,6 +115,8 @@
 import { computed, onMounted, reactive, ref } from 'vue'
 import { getBudgetCategories, getBudgetCharts, getBudgetOverview, getBudgetTransactions, updateBudgetTransaction, type BudgetCategory, type BudgetCharts, type BudgetOverview, type BudgetStatusRow, type BudgetTransaction } from '@/api/budget'
 const overview = ref<BudgetOverview | null>(null)
+const charts = ref<BudgetCharts | null>(null)
+const activeYear = ref('2026')
 const categories = ref<BudgetCategory[]>([])
 const selected = ref<BudgetStatusRow | null>(null)
 const transactions = ref<BudgetTransaction[]>([])

No linter for .vue files


async function refresh() { overview.value = await getBudgetOverview(); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }

async function refresh() { overview.value = await getBudgetOverview(); categories.value = await getBudgetCategories() }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -147,7 +147,7 @@
   if (status === 'Kritisch') return 'bg-red-900/60 text-red-100'
   return 'bg-slate-800 text-slate-300'
 }
-async function refresh() { overview.value = await getBudgetOverview(); categories.value = await getBudgetCategories() }
+async function refresh() { overview.value = await getBudgetOverview(); charts.value = await getBudgetCharts(activeYear.value); categories.value = await getBudgetCategories() }
 async function openCategory(row: BudgetStatusRow) { selected.value = row; filters.month = ''; filters.account_id = ''; filters.merchant = ''; filters.tag = ''; filters.status = ''; await loadTransactions() }
 async function loadTransactions() {
   if (!selected.value) return

No linter for .vue files


const params = new URLSearchParams({ category_id: selected.value.category_id, year: activeYear.value })

const params = new URLSearchParams({ category_id: selected.value.category_id })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -151,7 +151,7 @@
 async function openCategory(row: BudgetStatusRow) { selected.value = row; filters.month = ''; filters.account_id = ''; filters.merchant = ''; filters.tag = ''; filters.status = ''; await loadTransactions() }
 async function loadTransactions() {
   if (!selected.value) return
-  const params = new URLSearchParams({ category_id: selected.value.category_id })
+  const params = new URLSearchParams({ category_id: selected.value.category_id, year: activeYear.value })
   for (const [k, v] of Object.entries(filters)) if (v) params.set(k, v)
   transactions.value = await getBudgetTransactions(`?${params.toString()}`)
   transactions.value.forEach((tx) => { edits[tx.budget_transaction_id] = { category_id: (tx as any).category_id || selected.value?.category_id || '', description: tx.description, tag_names: '' } })

No linter for .vue files


{{ error }}

      <select v-model="activeYear" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" @change="refresh"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>

    <div v-if="message"


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -6,7 +6,10 @@

Plan, Ist, Prognose und Ampel je JARVIS-Kategorie. Zeile anklicken öffnet Buchungsdetails.

-
{{ error }}

+

+
{{ error }}

+      <select v-model="activeYear" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" @change="refresh"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
+

{{ message }}

No linter for .vue files


Ist Jahr bisher
              {{ m }}
              Ist Ø Monat

Ist Jahr bisher
              Ist Ø Monat


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -36,6 +36,7 @@
               Budget Monat
               Budget Jahr
               Ist Jahr bisher
+              {{ m }}
               Ist Ø Monat
               Prognose Jahr
               Prognose Ø Monat

No linter for .vue files


CHF {{ row.actual_year_to_date }}
              CHF {{ row.monthly_actuals?.[monthKeys[m]] || '0' }}
              CHF {{ row.actual_avg_month }}

CHF {{ row.actual_year_to_date }}
              CHF {{ row.actual_avg_month }}


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -50,6 +50,7 @@
               CHF {{ row.budget_month }}
               CHF {{ row.budget_year }}
               CHF {{ row.actual_year_to_date }}
+              CHF {{ row.monthly_actuals?.[monthKeys[m]] || '0' }}
               CHF {{ row.actual_avg_month }}
               CHF {{ row.forecast_year }}
               CHF {{ row.forecast_avg_month }}

No linter for .vue files


Einnahmen vs Ausgaben pro Monat

Noch keine bestätigten 2026-Budgetbuchungen.

{{ row.month }} · Einnahmen CHF {{ row.income_chf }} · Ausgaben CHF {{ row.expense_chf }} · Netto CHF {{ row.net_cashflow_chf }}

Budget vs Ist pro Kategorie

{{ row.category }} · Budget CHF {{ row.budget_year }} · Ist CHF {{ row.actual_year_to_date }}

Prognose/Ampel

Keine prognostizierten Überschreitungen.

{{ row.category }} · {{ row.status }} · Prognose CHF {{ row.forecast_year }}

    <aside v-if="selected"


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -78,6 +78,23 @@

{{ tx.transaction_date }} · {{ tx.description }}{{ tx.currency_original }} {{ tx.amount_original }}

Noch keine Buchungen.

+

+

+
+

+

+
Einnahmen vs Ausgaben pro Monat

+
Noch keine bestätigten 2026-Budgetbuchungen.

+

{{ row.month }} · Einnahmen CHF {{ row.income_chf }} · Ausgaben CHF {{ row.expense_chf }} · Netto CHF {{ row.net_cashflow_chf }}

+

+

+
Budget vs Ist pro Kategorie

+

{{ row.category }} · Budget CHF {{ row.budget_year }} · Ist CHF {{ row.actual_year_to_date }}

+

+

+
Prognose/Ampel

+
Keine prognostizierten Überschreitungen.

+
{{ row.category }} · {{ row.status }} · Prognose CHF {{ row.forecast_year }}

No linter for .vue files


const filters = reactive({ month: '', account_id: '', merchant: '', tag: '', status: '' })
const months = ['Jan','Feb','Mrz','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
const monthKeys: Record<string, string> = Object.fromEntries(months.map((m, i) => [m, String(i + 1).padStart(2, '0')]))


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetOverviewPage.vue
@@ -144,6 +144,8 @@
 const transactions = ref<BudgetTransaction[]>([])
 const edits = reactive<Record<string, { category_id: string; description: string; tag_names: string }>>({})
 const filters = reactive({ month: '', account_id: '', merchant: '', tag: '', status: '' })
+const months = ['Jan','Feb','Mrz','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']
+const monthKeys: Record<string, string> = Object.fromEntries(months.map((m, i) => [m, String(i + 1).padStart(2, '0')]))
 const error = ref('')
 const message = ref('')
 const flatCategories = computed(() => {

No linter for .vue files


[Your active task list was preserved across context compression]
- [>] p110-0. Arbeitsbasis prüfen; relevante Budget-Services/UI inventarisieren; Runtime nur read-only bis Backup (in_progress)
- [ ] p110-1. RED-Tests für 2026-Filter, 2025-Archivierung/Confirm-Block, Bulk, Kategorien, effektive Ausgaben, Budgetstatus-Monatsmatrix/Charts schreiben (pending)
- [ ] p110-2. Backend: aktives Jahr 2026 Defaults, 2025-reference/archival, Confirm-Guard, Transaktionsfilter, Budgetstatus-Monatsmatrix/Charts DTOs (pending)
- [ ] p110-3. Backend: Kategorie-Cleanup-Services für kanonische Einnahmen/Ausgaben, Duplikat-/UAT-Archivierung mit Audit (pending)
- [ ] p110-4. Frontend: Jahrfilter Default 2026, Review Bulk UX Fehlerdetails/Zähler, Kategorie-Dropdowns gruppiert/suchbar (pending)
- [ ] p110-5. Frontend: Effektive Ausgaben 2026-only, Budgetstatus Monatsmatrix/Drawer/Charts, Income 2026-only (pending)
- [ ] p110-6. Runtime: Backup erstellen, 2025/UAT/Testdaten sichtbarkeitsbereinigen mit Audit, keine echten Bulk-Confirms (pending)
- [ ] p110-7. Verifikation: compile, pytest, npm test/build, secret scans, Git-Safety, diff-check (pending)
- [ ] p110-8. Commit, Push, Remote-Hash, Abschlussbericht (pending)


Ich setze Phase 1.10 fort, Sir. Stand laut erhaltenem Task-State: Backend-Kernteile und UI-Teile sind teilweise geschrieben; ich prüfe jetzt den aktuellen Diff/Compile-Zustand, behebe die erwartbaren Kanten und arbeite dann Runtime/Verification/Commit sauber ab.


terminal

Output:
M frontend/src/api/budget.ts
 M frontend/src/pages/BudgetImportReviewPage.vue
 M frontend/src/pages/BudgetOverviewPage.vue
 M frontend/src/pages/BudgetTransactionsPage.vue
 M src/jarvis_finance/api/routers/budget.py
 M src/jarvis_finance/services/budget_categories.py
 M src/jarvis_finance/services/budget_imports.py
 M src/jarvis_finance/services/budget_overview.py
 M src/jarvis_finance/services/budget_transactions.py
 M src/jarvis_finance/storage/migrations.py
?? tests/unit/test_budget_phase110_2026_core.py
 frontend/src/api/budget.ts                         |   6 +-
 frontend/src/pages/BudgetImportReviewPage.vue      |  22 ++--
 frontend/src/pages/BudgetOverviewPage.vue          |  34 ++++++-
 frontend/src/pages/BudgetTransactionsPage.vue      |   3 +-
 src/jarvis_finance/api/routers/budget.py           |  31 ++++--
 src/jarvis_finance/services/budget_categories.py   | 111 +++++++++++++++++++++
 src/jarvis_finance/services/budget_imports.py      |  52 +++++++---
 src/jarvis_finance/services/budget_overview.py     |  48 +++++++++
 src/jarvis_finance/services/budget_transactions.py |   4 +-
 src/jarvis_finance/storage/migrations.py           |   4 +-
 10 files changed, 278 insertions(+), 37 deletions(-)


1|<template>
     2|

     3|
     4|
Budget Modul Phase 1.4

     5|
Budgetstatus nach Kategorie

     6|
Plan, Ist, Prognose und Ampel je JARVIS-Kategorie. Zeile anklicken öffnet Buchungsdetails.

     7|
     8|
     9|

    10|
{{ error }}

    11|      <select v-model="activeYear" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" @change="refresh"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
    12|

    13|
{{ message }}

    14|
    15|

    16|

    17|
{{ card.label }}

    18|
{{ card.value }}

    19|
{{ card.hint }}

    20|

    21|

    22|
    23|

    24|

    25|

    26|
Budgetstatus nach Kategorie

    27|
Monats- und Jahresbudget stehen in einer Zeile. Keine Scheingenauigkeit: ohne Ist-Daten bleibt der Status leer/neutral.

    28|

    29|        {{ overview?.current_month || '—' }}
    30|

    31|

    32|

    33|

    34|

    35|              Kategorie
    36|              Budget Monat
    37|              Budget Jahr
    38|              Ist Jahr bisher
    39|              {{ m }}
    40|              Ist Ø Monat
    41|              Prognose Jahr
    42|              Prognose Ø Monat
    43|              Abweichung
    44|              Status
    45|

    46|

    47|

    48|

    49|              {{ row.category }}{{ row.category_type }}
    50|              CHF {{ row.budget_month }}
    51|              CHF {{ row.budget_year }}
    52|              CHF {{ row.actual_year_to_date }}
    53|              CHF {{ row.monthly_actuals?.[monthKeys[m]] || '0' }}
    54|              CHF {{ row.actual_avg_month }}
    55|              CHF {{ row.forecast_year }}
    56|              CHF {{ row.forecast_avg_month }}
    57|              CHF {{ row.deviation_year }}
    58|              {{ row.status }}
    59|

    60|
Noch keine bestätigten Budgetpläne.

    61|

    62|

    63|

    64|

    65|
    66|

    67|

    68|
Review-Hinweise

    69|

    70|

Offene Kategorien ohne Ist-Werte

{{ overview?.open_categories_without_actuals ?? 0 }}

    71|

Seed Review offen

{{ overview?.pending_seed_candidate_count ?? 0 }}

    72|

Import Review offen

{{ overview?.pending_transaction_candidate_count ?? 0 }}

    73|

    74|

    75|

    76|
Letzte Transaktionen

    77|

    78|
{{ tx.transaction_date }} · {{ tx.description }}{{ tx.currency_original }} {{ tx.amount_original }}

    79|
Noch keine Buchungen.

    80|

    81|

    82|

    83|
    84|

    85|

    86|
Einnahmen vs Ausgaben pro Monat

    87|
Noch keine bestätigten 2026-Budgetbuchungen.

    88|

{{ row.month }} · Einnahmen CHF {{ row.income_chf }} · Ausgaben CHF {{ row.expense_chf }} · Netto CHF {{ row.net_cashflow_chf }}

    89|

    90|

    91|
Budget vs Ist pro Kategorie

    92|

{{ row.category }} · Budget CHF {{ row.budget_year }} · Ist CHF {{ row.actual_year_to_date }}

    93|

    94|

    95|
Prognose/Ampel

    96|
Keine prognostizierten Überschreitungen.

    97|
{{ row.category }} · {{ row.status }} · Prognose CHF {{ row.forecast_year }}

    98|

    99|

   100|
   101|
   102|

   103|

   104|
Kategorie-Details

   105|
{{ selected.category }}

   106|
Buchungen filtern und Kategorie/Tags/Beschreibung korrigieren. Betragskorrekturen nur via Adjustment/Reversal.

   107|

   108|        <button class="rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200" @click="selected = null">Schließen</button>
   109|

   110|

   111|        <input v-model="filters.month" placeholder="Monat YYYY-MM" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   112|        <input v-model="filters.account_id" placeholder="Konto-ID" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   113|        <input v-model="filters.merchant" placeholder="Händler" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   114|        <input v-model="filters.tag" placeholder="Tag" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white" />
   115|        <select v-model="filters.status" class="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white"><option value="">Status</option><option>confirmed</option><option>reversed</option></select>
   116|

   117|      <button class="mt-3 rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950" @click="loadTransactions">Filtern</button>
   118|

   119|

   120|
{{ tx.transaction_date }} · {{ tx.description }}{{ tx.currency_original }} {{ tx.amount_original }}

   121|

   122|            <select v-model="edits[tx.budget_transaction_id].category_id" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white">
   123|              <option value="">Unkategorisiert</option>
   124|              <option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option>
   125|            </select>
   126|            <input v-model="edits[tx.budget_transaction_id].description" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
   127|            <input v-model="edits[tx.budget_transaction_id].tag_names" placeholder="Tags, kommagetrennt" class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-white" />
   128|

   129|          <button class="mt-3 rounded-lg border border-emerald-500/60 px-3 py-2 text-xs font-semibold text-emerald-200" @click="saveTransaction(tx.budget_transaction_id)">Änderung mit Audit speichern</button>
   130|

   131|
Keine Buchungen für diese Filter.

   132|

   133|
   134|

   135|</template>
   136|
   191|


1|<template>
     2|

     3|
     4|
Ausgaben · Buchungen prüfen

     5|
Buchungen prüfen

     6|
Zentrale Review-Arbeitsseite. Confirm bleibt Preview → Confirm und erzeugt keine Blindbuchung.

     7|
     8|
{{ message }}

     9|
{{ error }}

    10|
    11|

    12|

    13|
{{ card.label }}

{{ card.value }}

    14|

    15|

    16|
    17|

    18|

    19|        <button v-for="tab in tabs" :key="tab.key" class="rounded-full border px-3 py-1.5 text-xs font-semibold" :class="activeTab === tab.key ? 'border-cyan-400 bg-cyan-400/10 text-cyan-200' : 'border-slate-700 text-slate-400'" @click="setTab(tab.key)">{{ tab.label }}</button>
    20|

    21|

    22|        <select v-model="confirmAccount" class="field"><option value="">Confirm-Konto wählen</option><option v-for="account in accounts" :key="account.budget_account_id" :value="account.budget_account_id">{{ account.name }}</option></select>
    23|        <select v-model="filters.budget_year" class="field"><option value="2026">Budgetjahr 2026</option><option value="2025">Referenzjahr 2025</option></select>
    24|        <select v-model="status" class="field"><option value="">offene Kandidaten</option><option>pending</option><option>auto_categorized</option><option>needs_review</option><option>duplicate</option><option>transfer_candidate</option><option>ignored</option></select>
    25|        <select v-model="bulkCategory" class="field"><option value="">Bulk-Kategorie</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select>
    26|        <input v-model="filters.source_type" class="field" placeholder="Quelle" />
    27|        <input v-model="filters.merchant" class="field" placeholder="Händler" />
    28|        <input v-model="filters.rule" class="field" placeholder="Regel" />
    29|        <select v-model="filters.category_id" class="field"><option value="">Kategorie-Vorschlag</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select>
    30|        <input v-model="filters.min_confidence" class="field" placeholder="Min Confidence" />
    31|        <input v-model="filters.date_from" class="field" type="date" />
    32|        <input v-model="filters.date_to" class="field" type="date" />
    33|        <input v-model="filters.amount_min" class="field" placeholder="Min Betrag" />
    34|        <input v-model="filters.amount_max" class="field" placeholder="Max Betrag" />
    35|        <select v-model="filters.sort_by" class="field"><option value="created_at">Sort: erstellt</option><option value="date">Datum</option><option value="merchant">Händler</option><option value="category">Kategorie</option><option value="confidence">Confidence</option><option value="source">Quelle</option><option value="status">Status</option></select>
    36|        <select v-model="filters.sort_dir" class="field"><option value="desc">absteigend</option><option value="asc">aufsteigend</option></select>
    37|        <button class="btn-primary" @click="load">Filtern</button>
    38|

    39|

    40|        Ausgewählt: {{ selectedIds.length }}
    41|        <button class="btn" :disabled="!selectedIds.length || !bulkCategory" @click="bulkSetCategory">Auswahl Kategorie zuweisen</button>
    42|        <button class="btn" :disabled="!selectedIds.length" @click="previewBatchConfirm">Batch Confirm Preview</button>
    43|        <button class="btn" :disabled="!selectedIds.length" @click="bulkIgnore">Auswahl ignorieren</button>
    44|        <button class="btn" :disabled="!selectedIds.length" @click="bulkReopen">Auswahl wieder öffnen</button>
    45|        <button class="btn" :disabled="!selectedIds.length" @click="bulkTransfer">Als Transfer markieren</button>
    46|        <button class="btn" :disabled="!selectedIds.length" @click="bulkCovered">Als covered_by_migros markieren</button>
    47|

    48|

    49|
Batch Preview: {{ batchPreview.count }} Kandidaten

    50|
Konflikte: {{ batchPreview.conflicts.length }}

    51|
Kategorien: {{ Object.entries(batchPreview.categories).map(([k,v]) => `${k}: ${v}`).join(', ') || '—' }}

    52|        <button class="mt-3 btn-primary" @click="confirmBatchPreview">Jetzt ausgewählte bestätigen</button>
    53|

    54|
    55|

    56|
Fehler pro Kandidat

    57|
{{ err.candidate_id }} · {{ err.error }}

    58|

    59|
    60|

    61|

    62|

<input type="checkbox" :checked="allVisibleSelected" @change="toggleAll" />	Datum	Quelle	Händler/Beschreibung	Betrag	Währung	Kategorie	Confidence	Regel/Grund	Status	Aktionen

    63|

    64|

    65|              <input v-model="selected" :value="item.transaction_candidate_id" type="checkbox" />
    66|              {{ item.transaction_date || '—' }}
    67|              {{ item.source_type }}{{ item.source_file_label }}
    68|              {{ item.merchant || item.description }}{{ item.description }}
    69|              {{ item.amount_original || '—' }}	{{ item.currency_original }}
    70|              <select :value="item.proposed_category_id || ''" class="field w-full" @change="setCategory(item, ($event.target as HTMLSelectElement).value)"><option value="">Review nötig</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><button class="mt-2 text-xs text-cyan-200" @click="openNewCategory(item)">+ Kategorie</button>
    71|              {{ item.confidence }}	{{ item.rule_name || item.classification || '—' }}{{ item.review_reason || item.notes }}	{{ item.status }}
    72|
<button v-if="canConfirm(item)" class="mini green" @click="confirmItem(item)">Bestätigen</button><button class="mini" @click="ignoreItem(item.transaction_candidate_id)">Ignorieren</button><button class="mini" @click="reopenItem(item.transaction_candidate_id)">Wieder öffnen</button><button class="mini amber" @click="markTransfer(item.transaction_candidate_id)">Transfer</button><button class="mini cyan" @click="openSplit(item)">Splitten</button><button class="mini cyan" @click="detail=item">Details</button>

    73|

    74|
Keine Kandidaten.

    75|

    76|

    77|

    78|

    79|
    80|
    81|      <button class="float-right text-slate-300" @click="detail=null">Schließen</button>
Kandidat Details

{{ detail.merchant || detail.description }}

{{ row[0] }}

{{ row[1] || '—' }}

    82|

Neue Kategorie erstellen
<input v-model="newCategory.name" class="field mt-3 w-full" placeholder="Name" /><select v-model="newCategory.parent_category_id" class="field mt-3 w-full"><option value="">Keine Parent-Kategorie</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><select v-model="newCategory.category_type" class="field mt-3 w-full"><option>expense</option><option>income</option><option>transfer</option><option>neutral</option></select><button class="btn-primary mt-3" @click="createCategoryFromReview">Kategorie erstellen & zuordnen</button>

    83|

Split erstellen

<select v-model="split.category_id" class="field"><option value="">Kategorie</option><option v-for="cat in flatCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><input v-model="split.amount_original" class="field" placeholder="Betrag" /><input v-model="split.tag" class="field" placeholder="Tag" /><input v-model="split.note" class="field" placeholder="Notiz" />
<button class="btn mt-3" @click="splits.push({ category_id:'', amount_original:'', tag:'', note:'' })">Zeile +</button><button class="btn-primary mt-3 ml-2" @click="confirmSplit">Split Preview/Confirm</button>

    84|
    85|

    86|</template>
    87|
   126|
   127|


     1|import { apiGet, apiPost } from './client'
     2|
     3|export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
     4|export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; children?: BudgetCategory[] }
     5|export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
     6|export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; transaction_date: string; transaction_type: string; description: string; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; status: string }
     7|export type BudgetPreview = { preview_id: string; summary: string; fx_status?: string; warnings: string[]; payload: Record<string, unknown> }
     8|export type BudgetConfirm = { status: string; entity_id: string; audit_id: string; message: string }
     9|export type BudgetSeedCandidate = {
    10|  seed_candidate_id: string
    11|  source_file_label?: string
    12|  source_sheet: string
    13|  source_row_or_range?: string | null
    14|  candidate_type: 'category' | 'budget_plan' | 'recurring_candidate' | 'unclean_range'
    15|  source_label?: string | null
    16|  proposed_category_id?: string | null
    17|  proposed_parent_label?: string | null
    18|  proposed_name?: string | null
    19|  proposed_period_type?: string | null
    20|  proposed_amount_text?: string | null
    21|  currency?: string | null
    22|  confidence: string
    23|  requires_review: boolean
    24|  status: string
    25|  notes?: string | null
    26|}
    27|export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
    28|export type BudgetStatusRow = { category_id: string; category: string; category_type: string; budget_month: string; budget_year: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; actual_avg_month: string; forecast_year: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; status: string; months_with_bookings: number; plan_names?: string[] }
    29|export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null }
    30|export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
    31|export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean }
    32|export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year: string; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
    33|export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }
    34|
    35|export const getBudgetOverview = () => apiGet<BudgetOverview>('/api/budget/overview')
    36|export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
    37|export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
    38|export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
    39|export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
    40|export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')
    41|export const getBudgetCategories = () => apiGet<BudgetCategory[]>('/api/budget/categories')
    42|export const getBudgetTags = () => apiGet<BudgetTag[]>('/api/budget/tags')
    43|export const previewBudgetTransaction = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transactions/preview', body)
    44|export const confirmBudgetTransaction = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transactions/confirm', body)
    45|export const updateBudgetTransaction = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transactions/${id}/update`, body)
    46|export const previewBudgetTransfer = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transfers/preview', body)
    47|export const confirmBudgetTransfer = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transfers/confirm', body)
    48|export const previewBudgetAccount = (body: unknown) => apiPost<BudgetPreview>('/api/budget/accounts/preview', body)
    49|export const confirmBudgetAccount = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/accounts/confirm', body)
    50|export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
    51|export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
    52|export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
    53|export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
    54|export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
    55|export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)
    56|export const confirmBudgetTag = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/tags/confirm', body)
    57|export const archiveBudgetTag = (id: string) => apiPost<BudgetConfirm>(`/api/budget/tags/${id}/archive`, {})
    58|export const getBudgetSeedCandidates = () => apiGet<BudgetSeedCandidate[]>('/api/budget/seed-candidates')
    59|export const generateBudgetSeedCandidates = (body: unknown) => apiPost<BudgetSeedSummary>('/api/budget/seed-candidates/generate', body)
    60|export const previewBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/seed-candidates/${id}/preview`, body)
    61|export const confirmBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/confirm`, body)
    62|export const ignoreBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/ignore`, { note })
    63|export const reopenBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/reopen`, { note })
    64|export const setBudgetSeedCandidateStatus = (id: string, status: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/status`, { status, note })
    65|export const convertBudgetUncleanRange = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/convert-unclean`, body)
    66|export const previewClearBudgetCategoriesTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; categories: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/categories/preview-clear-takeover', {})
    67|export const confirmClearBudgetCategoriesTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/categories/confirm-clear-takeover', {})
    68|export const previewBudgetPlanTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; budget_plans: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/budget-plans/preview-takeover', {})
    69|export const confirmBudgetPlanTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/budget-plans/confirm-takeover', {})
    70|export const getBudgetTransactionCandidates = (status = '', tab = '', filters: Record<string, string> = {}) => {
    71|  const params = new URLSearchParams()
    72|  if (status) params.set('status', status)
    73|  if (tab) params.set('tab', tab)
    74|  Object.entries(filters).forEach(([k, v]) => { if (v) params.set(k, v) })
    75|  const qs = params.toString()
    76|  return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
    77|}
    78|export const getBudgetTransactionCandidateSummary = () => apiGet<BudgetReviewSummary>('/api/budget/transaction-candidates/summary')
    79|export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
    80|export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
    81|export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)
    82|export const ignoreBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/ignore`, { note })
    83|export const reopenBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/reopen`, { note })
    84|export const updateBudgetTransactionCandidateCategory = (id: string, category_id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/category`, { category_id, note })
    85|export const bulkUpdateBudgetTransactionCandidateCategory = (candidate_ids: string[], category_id: string, note = '') => apiPost<{ updated_count: number; skipped_count: number }>('/api/budget/transaction-candidates/bulk-category', { candidate_ids, category_id, note })
    86|export const splitBudgetTransactionCandidate = (id: string, splits: unknown[]) => apiPost<BudgetConfirm & { split_count: number }>(`/api/budget/transaction-candidates/${id}/split`, { splits })
    87|export const markBudgetTransactionCandidateTransfer = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/transfer`, { note })
    88|export const previewSafeBudgetTransactionCandidates = () => apiPost<{ preview_id: string; summary: string; count: number; groups: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/transaction-candidates/batch-confirm-safe/preview', {})
    89|export const batchConfirmSafeBudgetTransactionCandidates = (account_id: string, candidate_ids: string[] = []) => apiPost<{ confirmed_count: number; skipped_count: number }>('/api/budget/transaction-candidates/batch-confirm-safe', { account_id, candidate_ids })
    90|export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
    91|export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
    92|export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
    93|export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; conflicts: Array<Record<string, unknown>>; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
    94|export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
    95|export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
    96|export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)
    97|export const getBudgetRules = () => apiGet<BudgetRule[]>('/api/budget/rules')
    98|export const createBudgetRule = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/rules', body)
    99|export const deactivateBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/deactivate`, {})
   100|export const testBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/test`, {})
   101|export const applyBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/apply`, {})
   102|


     1|from __future__ import annotations
     2|
     3|import csv
     4|import hashlib
     5|import json
     6|import re
     7|from collections import defaultdict
     8|from dataclasses import dataclass
     9|from datetime import datetime, timedelta
    10|from decimal import Decimal, InvalidOperation
    11|from sqlite3 import Connection
    12|from typing import Any
    13|
    14|from fastapi import HTTPException
    15|
    16|from jarvis_finance.audit.log import record_audit_event
    17|from jarvis_finance.services.budget_common import new_id, now, row_to_dict
    18|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    19|
    20|SUBSCRIPTION_PATTERNS = ["APPLE", "APPLE.COM", "GOOGLE", "AMAZON PRIME", "NETFLIX", "DISNEY", "DISNEYPLUS"]
    21|MIGROS_PATTERNS = ["MIGROS", "MIGROL", "CUMULUS", "MMM "]
    22|GALAXUS_PATTERNS = ["GALAXUS"]
    23|INCOME_PATTERNS = ["LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT"]
    24|TRANSFER_PATTERNS = ["RAIFFEISEN", "AKB", "MARCEL", "MELANIE", "ÜBERTRAG", "UEBERTRAG", "TRANSFER", "EIGENES KONTO", "GUTSCHRIFT VON"]
    25|
    26|
    27|def _decimal_text(value: object) -> str | None:
    28|    if value in (None, ""):
    29|        return None
    30|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-")
    31|    text = text.replace("+", "")
    32|    if text.count(",") == 1 and text.count(".") == 0:
    33|        text = text.replace(",", ".")
    34|    text = text.replace(" ", "")
    35|    try:
    36|        return format(abs(Decimal(text)), "f")
    37|    except (InvalidOperation, ValueError):
    38|        return None
    39|
    40|
    41|def _signed_decimal(value: object) -> Decimal | None:
    42|    if value in (None, ""):
    43|        return None
    44|    text = str(value).strip().replace("'", "").replace("CHF", "").replace("−", "-").replace("+", "")
    45|    if text.count(",") == 1 and text.count(".") == 0:
    46|        text = text.replace(",", ".")
    47|    text = text.replace(" ", "")
    48|    try:
    49|        return Decimal(text)
    50|    except (InvalidOperation, ValueError):
    51|        return None
    52|
    53|
    54|def _normalise(value: object) -> str:
    55|    return str(value or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("\ufeff", "")
    56|
    57|
    58|def _upper_ascii(value: object) -> str:
    59|    return _normalise(value).upper()
    60|
    61|
    62|def _date_text(value: object) -> str | None:
    63|    text = str(value or "").strip()
    64|    if not text:
    65|        return None
    66|    if re.match(r"^\d{4}-\d{2}-\d{2}", text):
    67|        return text[:10]
    68|    m = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})", text)
    69|    if m:
    70|        d, mo, y = m.groups()
    71|        return f"{y}-{int(mo):02d}-{int(d):02d}"
    72|    return text[:10] if len(text) >= 10 else None
    73|
    74|
    75|def _category_name(conn: Connection, category_id: str | None) -> str | None:
    76|    if not category_id:
    77|        return None
    78|    row = conn.execute("SELECT name FROM budget_categories WHERE category_id=?", (category_id,)).fetchone()
    79|    return str(row["name"]) if row else None
    80|
    81|
    82|def _category_by_name(conn: Connection, needles: list[str]) -> tuple[str | None, str | None]:
    83|    cats = conn.execute("SELECT category_id, name FROM budget_categories WHERE is_active=1").fetchall()
    84|    for needle in needles:
    85|        n = _normalise(needle)
    86|        for cat in cats:
    87|            if n and (n in _normalise(cat["name"]) or _normalise(cat["name"]) in n):
    88|                return str(cat["category_id"]), str(cat["name"])
    89|    return None, None
    90|
    91|
    92|def suggest_category(conn: Connection, description: str, source_label: str = "") -> tuple[str | None, str | None, str, bool]:
    93|    text = _normalise(f"{source_label} {description}")
    94|    if "migros" in text or "cumulus" in text:
    95|        cid, cname = _category_by_name(conn, ["Essen & Haushalt", "Grosseinkauf", "Essen", "Haushalt", "Leben"])
    96|        return cid, cname or "Essen & Haushalt / Grosseinkauf", "0.80", cid is None
    97|    keyword_map = [
    98|        (["krankenkasse", "versicherung", "axa", "zurich", "css", "helsana"], ["Versicherungen", "Krankenkasse"]),
    99|        (["coop", "denner", "aldi", "lidl"], ["Essen", "Haushalt", "Leben"]),
   100|        (["sbb", "zug", "bahn", "parking", "tankstelle", "mazda"], ["Mobilität", "Auto"]),
   101|        (["lohn", "salary", "einkommen", "gutschrift"], ["Einnahmen", "Lohn"]),
   102|    ]
   103|    for words, cats in keyword_map:
   104|        if any(w in text for w in words):
   105|            cid, cname = _category_by_name(conn, cats)
   106|            return cid, cname or cats[0], "0.65", cid is None
   107|    return None, None, "0.20", True
   108|
   109|
   110|def _duplicate_of(conn: Connection, tx_date: str | None, amount: str | None, description: str) -> str | None:
   111|    if not tx_date or not amount:
   112|        return None
   113|    row = conn.execute(
   114|        """
   115|        SELECT budget_transaction_id FROM budget_transactions
   116|        WHERE transaction_date=? AND status IN ('confirmed','reversed')
   117|          AND COALESCE(amount_chf, amount_original)=?
   118|          AND lower(description)=lower(?)
   119|        ORDER BY created_at LIMIT 1
   120|        """,
   121|        (tx_date, amount, description),
   122|    ).fetchone()
   123|    return str(row["budget_transaction_id"]) if row else None
   124|
   125|
   126|def _candidate_key(source_label: str, row_ref: str, tx_date: str | None, amount: str | None, description: str) -> str:
   127|    raw = "|".join([source_label, row_ref, tx_date or "", amount or "", description])
   128|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
   129|
   130|
   131|def _fingerprint(parts: list[object]) -> str:
   132|    raw = "|".join(str(p or "").strip() for p in parts)
   133|    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
   134|
   135|
   136|def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
   137|    existing = conn.execute(
   138|        """
   139|        SELECT 1 FROM budget_transaction_candidates
   140|        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
   141|          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
   142|        """,
   143|        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
   144|    ).fetchone()
   145|    if existing:
   146|        return False
   147|    conn.execute(
   148|        """
   149|        INSERT INTO budget_transaction_candidates(
   150|          transaction_candidate_id, source_file_label, source_row_or_range, source_type,
   151|          transaction_date, description, merchant, amount_original, currency_original,
   152|          proposed_category_id, proposed_category_name, duplicate_of_transaction_id,
   153|          confidence, requires_review, status, notes, created_at, updated_at,
   154|          classification, review_reason, rule_id, rule_name, source_priority, covered_by_source,
   155|          receipt_key, linked_candidate_id, account_source, raw_fingerprint
   156|        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   157|        """,
   158|        (
   159|            row["transaction_candidate_id"], row["source_file_label"], row.get("source_row_or_range"), row.get("source_type") or "csv_seed",
   160|            row.get("transaction_date"), row["description"], row.get("merchant"), row.get("amount_original"), row.get("currency_original") or "CHF",
   161|            row.get("proposed_category_id"), row.get("proposed_category_name"), row.get("duplicate_of_transaction_id"),
   162|            row.get("confidence") or "0", 1 if row.get("requires_review", True) else 0, row.get("status") or "pending", row.get("notes"), row["created_at"], row["updated_at"],
   163|            row.get("classification"), row.get("review_reason"), row.get("rule_id"), row.get("rule_name"), row.get("source_priority") or 50, row.get("covered_by_source"),
   164|            row.get("receipt_key"), row.get("linked_candidate_id"), row.get("account_source"), row.get("raw_fingerprint"),
   165|        ),
   166|    )
   167|    return True
   168|
   169|
   170|def seed_transaction_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]] | list[list[Any]], *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   171|    created = duplicates = review = suggested = ignored = confirmed = 0
   172|    for idx, raw in enumerate(rows, start=1):
   173|        if isinstance(raw, dict):
   174|            low = {_normalise(k): v for k, v in raw.items()}
   175|            description = str(low.get("beschreibung") or low.get("description") or low.get("text") or low.get("artikel") or low.get("merchant") or low.get("merchantname") or low.get("haendler") or "").strip()
   176|            merchant = str(low.get("merchant") or low.get("merchantname") or low.get("filiale") or low.get("haendler") or "").strip() or None
   177|            tx_date = _date_text(low.get("datum") or low.get("date") or low.get("buchungsdatum") or low.get("valutadate"))
   178|            amount = _decimal_text(low.get("betrag") or low.get("amount") or low.get("umsatz") or low.get("wert") or low.get("originalamount"))
   179|        else:
   180|            cells = [str(c).strip() for c in raw]
   181|            description = next((c for c in cells if c and _decimal_text(c) is None and not c[:4].isdigit()), "")
   182|            merchant = None
   183|            tx_date = next((_date_text(c) for c in cells if _date_text(c)), None)
   184|            amount = next((_decimal_text(c) for c in cells if _decimal_text(c) is not None), None)
   185|        if not description or not amount:
   186|            ignored += 1
   187|            continue
   188|        cid, cname, conf, needs_review = suggest_category(conn, description, source_file_label)
   189|        dup = _duplicate_of(conn, tx_date, amount, description)
   190|        status = "duplicate" if dup else ("needs_review" if needs_review else "pending")
   191|        if dup:
   192|            duplicates += 1
   193|        if cid:
   194|            suggested += 1
   195|        if needs_review:
   196|            review += 1
   197|        ts = now()
   198|        row_ref = f"R{idx}"
   199|        inserted = _insert_candidate(conn, {
   200|            "transaction_candidate_id": "btxcand_" + _candidate_key(source_file_label, row_ref, tx_date, amount, description),
   201|            "source_file_label": source_file_label,
   202|            "source_row_or_range": row_ref,
   203|            "source_type": source_type,
   204|            "transaction_date": tx_date,
   205|            "description": description[:300],
   206|            "merchant": merchant,
   207|            "amount_original": amount,
   208|            "currency_original": "CHF",
   209|            "proposed_category_id": cid,
   210|            "proposed_category_name": cname,
   211|            "duplicate_of_transaction_id": dup,
   212|            "confidence": conf,
   213|            "requires_review": needs_review or bool(dup),
   214|            "status": status,
   215|            "notes": "mvp_light_category_suggestion",
   216|            "classification": "legacy_csv_seed",
   217|            "review_reason": "legacy_seed_review" if needs_review else "legacy_seed_suggested",
   218|            "raw_fingerprint": _fingerprint([source_file_label, row_ref, tx_date, amount, description]),
   219|            "created_at": ts,
   220|            "updated_at": ts,
   221|        })
   222|        if inserted:
   223|            created += 1
   224|    conn.commit()
   225|    return {"candidate_count": created, "suggested_count": suggested, "review_required_count": review, "possible_duplicate_count": duplicates, "ignored_count": ignored, "confirmed_count": confirmed}
   226|
   227|
   228|def seed_transaction_candidates_from_csv_text(conn: Connection, text: str, *, source_file_label: str, source_type: str = "csv_seed") -> dict[str, int]:
   229|    sample = text[:4096]
   230|    dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") if sample.strip() else csv.excel
   231|    reader = csv.DictReader(text.splitlines(), dialect=dialect)
   232|    if reader.fieldnames:
   233|        return seed_transaction_candidates_from_rows(conn, list(reader), source_file_label=source_file_label, source_type=source_type)
   234|    return seed_transaction_candidates_from_rows(conn, list(csv.reader(text.splitlines(), dialect=dialect)), source_file_label=source_file_label, source_type=source_type)
   235|
   236|
   237|def seed_migros_candidates_from_rows(conn: Connection, rows: list[dict[str, Any]], *, source_file_label: str, auto_category_id: str | None = None, threshold_chf: str = "50.00") -> dict[str, int]:
   238|    threshold = Decimal(threshold_chf)
   239|    auto_category_name = _category_name(conn, auto_category_id)
   240|    grouped: dict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
   241|    ignored = 0
   242|    for idx, raw in enumerate(rows, start=1):
   243|        low = {_normalise(k): v for k, v in raw.items()}
   244|        tx_date = _date_text(low.get("datum") or low.get("date"))
   245|        time = str(low.get("zeit") or low.get("time") or "").strip()
   246|        store = str(low.get("filiale") or low.get("store") or "Migros").strip()
   247|        register = str(low.get("kasse") or low.get("kassennummer") or low.get("register") or "").strip()
   248|        txn = str(low.get("transaktion") or low.get("transaktionsnummer") or low.get("bon") or low.get("bon-id") or "").strip()
   249|        amount = _decimal_text(low.get("umsatz") or low.get("betrag") or low.get("amount"))
   250|        if not tx_date or not amount:
   251|            ignored += 1
   252|            continue
   253|        key = _fingerprint([tx_date, time, store, register, txn])
   254|        grouped[key].append((idx, raw))
   255|    created = auto = review = line_count = 0
   256|    for key, items in grouped.items():
   257|        first_low = {_normalise(k): v for k, v in items[0][1].items()}
   258|        tx_date = _date_text(first_low.get("datum") or first_low.get("date"))
   259|        time = str(first_low.get("zeit") or first_low.get("time") or "").strip()
   260|        store = str(first_low.get("filiale") or first_low.get("store") or "Migros").strip() or "Migros"
   261|

Use offset=261 to continue reading (showing 1-260 of 1033 lines)