   300|
   301|@router.get("/transaction-candidates/summary")
   302|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   303|    return review_candidate_summary(conn, budget_year=budget_year)
   304|
   305|
   306|@router.post("/transaction-candidates/generate")
   307|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   308|    source_type = payload.get("source_type") or "csv_seed"
   309|    rows = payload.get("rows") or []
   310|    if source_type == "credit_card_csv":
   311|        return seed_credit_card_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Credit card CSV", subscription_category_id=payload.get("subscription_category_id"), migros_covered=payload.get("migros_covered", True))
   312|    if source_type == "migros_csv":
   313|        return seed_migros_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Migros CSV", auto_category_id=payload.get("auto_category_id"), threshold_chf=str(payload.get("threshold_chf") or "50.00"))
   314|    if source_type == "bank_csv":
   315|        return seed_bank_transfer_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Bank CSV", income_category_id=payload.get("income_category_id"))
   316|    if payload.get("csv_text"):
   317|        return seed_transaction_candidates_from_csv_text(conn, payload["csv_text"], source_file_label=payload.get("source_file_label") or "CSV Import", source_type=source_type)
   318|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   319|
   320|
   321|@router.post("/transaction-candidates/migros-2026/preview")
   322|def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   323|    return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))
   324|
   325|
   326|@router.post("/transaction-candidates/migros-2026/confirm")
   327|def transaction_candidates_migros_2026_confirm(payload: dict, conn: Connection = Depends(get_db)):
   328|    return confirm_migros_receipts_2026(conn, account_id=str(payload.get("account_id") or ""), year=str(payload.get("year") or "2026"))
   329|
   330|
   331|@router.post("/transaction-candidates/{candidate_id}/preview")
   332|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   333|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   334|
   335|
   336|@router.post("/transaction-candidates/{candidate_id}/confirm")
   337|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   338|    return confirm_transaction_candidate(conn, candidate_id, payload)
   339|
   340|
   341|@router.post("/transaction-candidates/{candidate_id}/ignore")
   342|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   343|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   344|
   345|
   346|@router.post("/transaction-candidates/{candidate_id}/reopen")
   347|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   348|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   349|
   350|
   351|@router.post("/transaction-candidates/{candidate_id}/category")
   352|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   353|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   354|
   355|
   356|@router.post("/transaction-candidates/bulk-category")
   357|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   358|    category_id = str(payload.get("category_id") or "")
   359|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   360|
   361|
   362|@router.post("/transaction-candidates/{candidate_id}/split")
   363|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   364|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   365|
   366|
   367|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   368|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   369|    return confirm_candidate_split(conn, candidate_id, payload)
   370|
   371|
   372|@router.post("/transaction-candidates/{candidate_id}/category/create")
   373|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   374|    return create_review_category_for_candidate(conn, candidate_id, payload)
   375|
   376|
   377|@router.post("/transaction-candidates/{candidate_id}/transfer")
   378|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   379|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   380|
   381|
   382|@router.post("/transaction-candidates/{candidate_id}/type")
   383|def transaction_candidates_type(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   384|    return mark_candidate_type(conn, candidate_id, str(payload.get("candidate_type") or ""), (payload or {}).get("note"))
   385|
   386|
   387|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   388|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   389|    return preview_safe_auto_candidates(conn)
   390|
   391|
   392|@router.post("/transaction-candidates/batch/preview")
   393|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   394|    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"))
   395|
   396|
   397|@router.post("/transaction-candidates/batch")
   398|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   399|    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"))
   400|
   401|
   402|@router.post("/transaction-candidates/archive-reference-year")
   403|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   404|    payload = payload or {}
   405|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   406|
   407|
   408|@router.post("/transaction-candidates/batch-confirm-safe")
   409|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   410|    account_id = str(payload.get("account_id") or "")
   411|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   412|
   413|
   414|@router.post("/transaction-candidates/detect-duplicates")
   415|def transaction_candidates_detect_duplicates(conn: Connection = Depends(get_db)):
   416|    return detect_duplicate_candidates(conn)
   417|
   418|
   419|@router.get("/merchants")
   420|def merchants(include_inactive: bool = False, conn: Connection = Depends(get_db)):
   421|    return list_merchants(conn, include_inactive=include_inactive)
   422|
   423|
   424|@router.post("/merchants")
   425|def merchants_create(payload: dict, conn: Connection = Depends(get_db)):
   426|    return create_merchant(conn, payload)
   427|
   428|
   429|@router.post("/merchants/{merchant_id}/aliases")
   430|def merchants_create_alias(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   431|    return create_merchant_alias(conn, merchant_id, payload)
   432|
   433|
   434|@router.post("/merchants/{merchant_id}/category")
   435|def merchants_update_category(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   436|    return update_merchant_category(conn, merchant_id, payload.get("category_id"))
   437|
   438|
   439|@router.post("/merchants/{merchant_id}/apply")
   440|def merchants_apply(merchant_id: str, conn: Connection = Depends(get_db)):
   441|    return apply_merchant_to_open_candidates(conn, merchant_id)
   442|
   443|
   444|@router.post("/merchants/merge")
   445|def merchants_merge(payload: dict, conn: Connection = Depends(get_db)):
   446|    return merge_merchants(conn, str(payload.get("source_merchant_id") or ""), str(payload.get("target_merchant_id") or ""))
   447|
   448|
   449|@router.post("/seed-candidates/generate")
   450|def seed_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   451|    return seed_budget_candidates_from_rows(conn, payload.get("workbook_rows") or {}, source_file_label=payload.get("source_file_label") or "Budget workbook")
   452|
   453|
   454|@router.post("/seed-candidates/{seed_candidate_id}/preview")
   455|def seed_candidates_preview(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   456|    return preview_seed_candidate(conn, seed_candidate_id, payload or {})
   457|
   458|
   459|@router.post("/seed-candidates/{seed_candidate_id}/confirm")
   460|def seed_candidates_confirm(seed_candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   461|    return confirm_seed_candidate(conn, seed_candidate_id, payload)
   462|
   463|
   464|@router.post("/seed-candidates/{seed_candidate_id}/ignore")
   465|def seed_candidates_ignore(seed_candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   466|    return ignore_seed_candidate(conn, seed_candidate_id, (payload or {}).get("note"))
   467|
   468|
   469|@router.post("/seed-candidates/{seed_candidate_id}/reopen")
   470|

Use offset=470 to continue reading (showing 300-469 of 611 lines)


patch

@router.post("/transaction-candidates/bank-income/reclassify")
def transaction_candidates_bank_income_reclassify(payload: dict | None = None, conn: Connection = Depends(get_db)):
    return reclassify_bank_income_candidates(conn, budget_year=str((payload or {}).get("budget_year") or "2026"))

@router.post("/transaction-candidates/migros-2026/preview")


--- 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
@@ -318,6 +318,11 @@
     return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)

+@router.post("/transaction-candidates/bank-income/reclassify")
+def transaction_candidates_bank_income_reclassify(payload: dict | None = None, conn: Connection = Depends(get_db)):
+    return reclassify_bank_income_candidates(conn, budget_year=str((payload or {}).get("budget_year") or "2026"))
+
+
 @router.post("/transaction-candidates/migros-2026/preview")
 def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
     return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


Backend-Regeln und Reclassify-API sind angelegt. Ich ergänze jetzt das mobile Fallback-Menü als Fullscreen-Overlay mit großen nativen Links — unabhängig vom Bottom-Nav/Mehr-Panel.

Explanation:
**Considering income confirmation**

I need to confirm if a manual income preview is necessary. It seems that the existing budget transaction preview can handle this, and I can call the UI with the transaction type set to income. That sounds good. For navigation, I should make sure to use fullscreen mode. I'll also need to apply the write patch to implement these changes properly. This feels like I'm on the right track to get it all done!

<template>

JARVIS

Finance

PrimeVue Aura Light

      <RouterLink v-for="item in mainItems" :key="item.to" :to="item.to" class="nav-link" active-class="active-nav-link">{{ item.label }}</RouterLink>
      <template v-for="group in budgetGroups" :key="group.title">

{{ group.title }}

        <RouterLink v-for="item in group.items" :key="item.to" :to="item.to" class="nav-link" active-class="active-nav-link">{{ item.label }}</RouterLink>
      </template>

User Flow

Lokale Daten automatisch geladen.

Schreibseiten: Preview/Confirm/Audit.

  <button data-testid="mobile-menu-button" type="button" class="pointer-events-auto fixed right-3 top-3 z-[95] min-h-12 rounded-2xl border border-slate-200 bg-white/95 px-4 py-2 text-sm font-bold text-slate-900 shadow-2xl backdrop-blur lg:hidden" @click="menuOpen = true">Menü</button>

JARVIS

Mobiles Menü

      <button data-testid="mobile-menu-close" type="button" class="min-h-12 rounded-xl border border-slate-300 px-4 py-2 font-semibold" @click="menuOpen = false">Schließen</button>

      <a v-for="item in allMobileMenuItems" :key="item.to" :href="item.to" :data-testid="`mobile-menu-link-${item.key}`" class="block min-h-14 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-base font-semibold text-slate-900 active:bg-cyan-50" :class="isActive(item.to) ? 'ring-2 ring-cyan-300' : ''">{{ item.label }}{{ item.to }}

    <a v-for="item in mobileItems" :key="item.to" :href="item.to" :data-testid="`mobile-nav-${item.key}`" class="mobile-nav-link min-h-12 rounded-xl px-1 py-2 font-medium transition active:bg-cyan-50" :class="isActive(item.to) ? 'mobile-nav-active' : ''" @click="moreOpen = false">{{ item.label }}

      Reports Wallets Einkünfte Kategorien Aktien/ETF & Cash Buchungen prüfen
      <button data-testid="mobile-more-button" type="button" class="min-h-12 w-full rounded-xl px-1 py-2 font-medium text-slate-600 transition active:bg-cyan-50" :class="moreOpen ? 'bg-cyan-50 text-cyan-800 ring-1 ring-cyan-200' : ''" @click="moreOpen = !moreOpen">Mehr</button>

        <a v-for="item in moreItems" :key="item.to" :href="item.to" :data-testid="`mobile-more-${item.key}`" class="block min-h-11 w-full rounded-xl px-3 py-3 text-left text-sm text-slate-700 hover:bg-slate-100 active:bg-cyan-50" :class="isActive(item.to) ? 'mobile-more-active' : ''" @click="moreOpen = false">{{ item.label }}

</template>


No linter for .vue files


Explanation:
**Updating tests and API**

I need to update the tests, but I'll handle that later. It seems I also need to add an API function for reclassification. That sounds important, so I should plan out how to approach it and ensure everything aligns well. I want to be thorough to avoid missing any crucial steps, especially since this can affect how the data is handled afterwards. I'll keep track of this and make a note to revisit it soon!

export const detectBudgetDuplicateCandidates = () => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/detect-duplicates', {})
export const reclassifyBankIncomeCandidates = (budget_year = '2026') => apiPost<Record<string, number>>('/api/budget/transaction-candidates/bank-income/reclassify', { budget_year })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/api/budget.ts
@@ -139,3 +139,4 @@
 export const applyBudgetMerchant = (merchantId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/apply`, {})
 export const mergeBudgetMerchants = (source_merchant_id: string, target_merchant_id: string) => apiPost<Record<string, unknown>>('/api/budget/merchants/merge', { source_merchant_id, target_merchant_id })
 export const detectBudgetDuplicateCandidates = () => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/detect-duplicates', {})
+export const reclassifyBankIncomeCandidates = (budget_year = '2026') => apiPost<Record<string, number>>('/api/budget/transaction-candidates/bank-income/reclassify', { budget_year })

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


Explanation:
**Reviewing UI modifications**

I need to conduct a review of the UI tabs and also consider adding a button for reclassification. It's essential to analyze how these changes can improve user experience. I’ll be thinking about layout adjustments and how best to implement these new features. There’s a lot to consider to ensure the updates contribute positively to the overall design and functionality. Let’s get into the details and see what can be improved!

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

import { batchBudgetTransactionCandidates, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateType, previewBatchBudgetTransactionCandidates, 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
@@ -79,7 +79,7 @@
 import Toolbar from 'primevue/toolbar'
 import { useConfirm } from 'primevue/useconfirm'
 import { useToast } from 'primevue/usetoast'
-import { batchBudgetTransactionCandidates, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateType, previewBatchBudgetTransactionCandidates, updateBudgetTransactionCandidateCategory, type BudgetAccount, type BudgetCategory, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'
+import { batchBudgetTransactionCandidates, confirmBudgetTransactionCandidate, confirmBudgetTransactionCandidateSplit, createReviewCategoryForCandidate, getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, ignoreBudgetTransactionCandidate, markBudgetTransactionCandidateType, previewBatchBudgetTransactionCandidates, reclassifyBankIncomeCandidates, 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 toast = useToast(); const confirm = useConfirm()
 const summary = ref<BudgetReviewSummary | null>(null); const status = ref(''); const activeTab = ref(''); const bulkCategory = ref(''); const confirmAccount = ref(''); const error = ref(''); const message = ref('')

No linter for .vue files


            <Button label="Filtern" icon="pi pi-filter" severity="secondary" @click="load" />
            <Button label="Bankeingänge neu klassifizieren" icon="pi pi-refresh" severity="info" @click="reclassifyIncome" />


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -29,6 +29,7 @@
             <InputText v-model="filters.merchant" placeholder="Händler" class="w-36" />
             <InputText v-model="filters.source_type" placeholder="Quelle" class="w-36" />
             <Button label="Filtern" icon="pi pi-filter" severity="secondary" @click="load" />
+            <Button label="Bankeingänge neu klassifizieren" icon="pi pi-refresh" severity="info" @click="reclassifyIncome" />
           </div></template>
         </Toolbar>
         <Toolbar class="mt-3">

No linter for .vue files


const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'income', label: 'Einkommen erkannt' }, { key: 'possible_income', label: 'Mögliche Einkommen' }, { key: 'transfers', label: 'Interne Transfers' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]

const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'income', label: 'Einnahmen' }, { key: 'transfers', label: 'Transfers' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -88,7 +88,7 @@
 const detail = ref<BudgetTransactionCandidate | null>(null); const splitCandidate = ref<BudgetTransactionCandidate | null>(null); const newCategoryFor = ref<BudgetTransactionCandidate | null>(null)
 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 neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'income', label: 'Einnahmen' }, { key: 'transfers', label: 'Transfers' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]
+const tabs = [{ key: '', label: 'Alle neuen' }, { key: 'visa', label: 'VISA' }, { key: 'migros', label: 'Migros' }, { key: 'raiffeisen', label: 'Raiffeisen' }, { key: 'akb', label: 'AKB' }, { key: 'money_in', label: 'Geld Eingang' }, { key: 'income', label: 'Einkommen erkannt' }, { key: 'possible_income', label: 'Mögliche Einkommen' }, { key: 'transfers', label: 'Interne Transfers' }, { key: 'not_income_transfer', label: 'Nicht Einkommen / Transfer' }, { key: 'credit_card_payment', label: 'Kreditkartenausgleich' }, { key: 'investment_transfers', label: 'True Wealth / Investment Transfers' }, { key: 'duplicates', label: 'Duplikate' }, { key: 'review', label: 'Review nötig' }, { key: 'ignored', label: 'Ignoriert / bereits verarbeitet' }]
 const yearOptions = [{ label: 'Budgetjahr 2026', value: '2026' }, { label: 'Referenzjahr 2025', value: '2025' }]
 const statusLabelMap: Record<string, string> = { pending: 'Offen', auto_categorized: 'Vorgeschlagen', needs_review: 'Prüfung nötig', confirmed: 'Bestätigt', ignored: 'Ignoriert', covered_by_source: 'Durch Quelle abgedeckt', covered_by_migros: 'Durch Migros abgedeckt', duplicate_candidate: 'Mögliches Duplikat', duplicate: 'Mögliches Duplikat', transfer_candidate: 'Möglicher Transfer', superseded: 'Ersetzt', reference_2025: 'Referenz 2025', archived_reference: 'Archivierte Referenz' }
 const statusOptions = [{ label: 'offene Kandidaten', value: '' }, ...['pending','auto_categorized','needs_review','duplicate_candidate','transfer_candidate','ignored'].map(v => ({ label: statusLabelMap[v] || v, value: v }))]

No linter for .vue files


function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
async function confirmItem(item: BudgetTransactionCandidate, allowDuplicate = false) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; notifyError(error.value); return } askRisky(allowDuplicate ? 'Mögliches Duplikat trotzdem produktiv bestätigen? Audit wird geschrieben.' : 'Diesen Kandidaten produktiv bestätigen? Audit wird geschrieben.', async () => { try { const isIncome = ['income_candidate','possible_income'].includes(String(item.classification || '')); const result = await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: isIncome ? 'income' : 'expense', budget_year: filters.budget_year, allow_duplicate: allowDuplicate }); message.value = `Kandidat bestätigt. Audit: ${(result as any).audit_id ?? 'geschrieben'}`; error.value=''; notifySuccess(message.value); await load() } catch (e) { error.value = e instanceof Error ? e.message : 'Confirm fehlgeschlagen'; notifyError(error.value) } }) }

function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
async function confirmItem(item: BudgetTransactionCandidate, allowDuplicate = false) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; notifyError(error.value); return } askRisky(allowDuplicate ? 'Mögliches Duplikat trotzdem produktiv bestätigen? Audit wird geschrieben.' : 'Diesen Kandidaten produktiv bestätigen? Audit wird geschrieben.', async () => { try { const result = await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense', budget_year: filters.budget_year, allow_duplicate: allowDuplicate }); message.value = `Kandidat bestätigt. Audit: ${(result as any).audit_id ?? 'geschrieben'}`; error.value=''; notifySuccess(message.value); await load() } catch (e) { error.value = e instanceof Error ? e.message : 'Confirm fehlgeschlagen'; notifyError(error.value) } }) }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -118,9 +118,9 @@
 async function refreshAll() { categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await load() }
 async function setTab(tab: string) { activeTab.value = tab; await load() }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }
-function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
-function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || item.classification === 'income_candidate') }
-async function confirmItem(item: BudgetTransactionCandidate, allowDuplicate = false) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; notifyError(error.value); return } askRisky(allowDuplicate ? 'Mögliches Duplikat trotzdem produktiv bestätigen? Audit wird geschrieben.' : 'Diesen Kandidaten produktiv bestätigen? Audit wird geschrieben.', async () => { try { const result = await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: item.classification === 'income_candidate' ? 'income' : 'expense', budget_year: filters.budget_year, allow_duplicate: allowDuplicate }); message.value = `Kandidat bestätigt. Audit: ${(result as any).audit_id ?? 'geschrieben'}`; error.value=''; notifySuccess(message.value); await load() } catch (e) { error.value = e instanceof Error ? e.message : 'Confirm fehlgeschlagen'; notifyError(error.value) } }) }
+function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
+function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
+async function confirmItem(item: BudgetTransactionCandidate, allowDuplicate = false) { if (!confirmAccount.value) { error.value = 'Confirm-Konto fehlt.'; notifyError(error.value); return } askRisky(allowDuplicate ? 'Mögliches Duplikat trotzdem produktiv bestätigen? Audit wird geschrieben.' : 'Diesen Kandidaten produktiv bestätigen? Audit wird geschrieben.', async () => { try { const isIncome = ['income_candidate','possible_income'].includes(String(item.classification || '')); const result = await confirmBudgetTransactionCandidate(item.transaction_candidate_id, { account_id: confirmAccount.value, category_id: item.proposed_category_id, transaction_type: isIncome ? 'income' : 'expense', budget_year: filters.budget_year, allow_duplicate: allowDuplicate }); message.value = `Kandidat bestätigt. Audit: ${(result as any).audit_id ?? 'geschrieben'}`; error.value=''; notifySuccess(message.value); await load() } catch (e) { error.value = e instanceof Error ? e.message : 'Confirm fehlgeschlagen'; notifyError(error.value) } }) }
 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() { 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='' }

No linter for .vue files


async function setTab(tab: string) { activeTab.value = tab; await load() }
async function reclassifyIncome() { askRisky('Vorhandene AKB/Raiffeisen-Kandidaten neu klassifizieren? Es werden keine Buchungen bestätigt.', async () => { const result = await reclassifyBankIncomeCandidates(filters.budget_year); message.value = `Bankeingänge neu klassifiziert: ${(result as any).income_candidate_count ?? 0} Einkommen, ${(result as any).possible_income_count ?? 0} mögliche Einkommen, ${(result as any).transfer_candidate_count ?? 0} Transfers.`; notifySuccess(message.value); await load() }) }
function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }

async function setTab(tab: string) { activeTab.value = tab; await load() }
function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetImportReviewPage.vue
@@ -117,6 +117,7 @@
 async function load() { candidates.value = await getBudgetTransactionCandidates(status.value, activeTab.value, filters); summary.value = await getBudgetTransactionCandidateSummary(filters.budget_year); selected.value = selected.value.filter(id => candidates.value.some(c => c.transaction_candidate_id === id)); batchPreview.value = null }
 async function refreshAll() { categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await load() }
 async function setTab(tab: string) { activeTab.value = tab; await load() }
+async function reclassifyIncome() { askRisky('Vorhandene AKB/Raiffeisen-Kandidaten neu klassifizieren? Es werden keine Buchungen bestätigt.', async () => { const result = await reclassifyBankIncomeCandidates(filters.budget_year); message.value = `Bankeingänge neu klassifiziert: ${(result as any).income_candidate_count ?? 0} Einkommen, ${(result as any).possible_income_count ?? 0} mögliche Einkommen, ${(result as any).transfer_candidate_count ?? 0} Transfers.`; notifySuccess(message.value); await load() }) }
 function toggleAll() { selected.value = allVisibleSelected.value ? [] : candidates.value.map(c => c.transaction_candidate_id) }
 function canConfirm(item: BudgetTransactionCandidate) { return !['covered_by_migros','covered_by_source','superseded','transfer_candidate','duplicate_candidate','duplicate','confirmed','ignored','reference_2025','archived_reference'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }
 function canForceConfirmDuplicate(item: BudgetTransactionCandidate) { return ['duplicate_candidate','duplicate'].includes(item.status) && String(item.transaction_date || '').startsWith(filters.budget_year) && (!!item.proposed_category_id || ['income_candidate','possible_income'].includes(String(item.classification || ''))) }

No linter for .vue files


Review-UI hat jetzt neue Tabs und Reclassify-Aktion. Ich ergänze auf der Einkünfte-Seite die Sektion **Geld Eingang** plus manuelle tatsächliche Einkunft mit Preview→Confirm→Audit.

Explanation:
**Updating budget forms**

I need to create a Geld Eingang table and a manual actual form. I could just relabel the existing "Effektive Einnahmen" for now. Let's keep it simple: I'll patch the section heading to Geld Eingang and include the manual actual form. I should check if I need to import, preview, or confirm anything already. The BudgetIncomePage imports don’t seem to preview the budget transaction or confirm it, so I’ll add the actual form using account and category details.

import { archiveIncomePlan, confirmBudgetTransaction, confirmIncomePlan, confirmIncomePlanUpdate, confirmIncomeTemplates, getBudgetAccounts, getBudgetCategories, getBudgetTransactions, getIncomePlans, getIncomeSeedCandidates, getIncomeStatus, previewBudgetTransaction, previewIncomePlan, previewIncomePlanUpdate, previewIncomeTemplates, type BudgetAccount, type BudgetCategory, type BudgetPreview, type BudgetTransaction, type IncomePlan, type IncomeSeedCandidate } from '@/api/budget'

import { archiveIncomePlan, confirmIncomePlan, confirmIncomePlanUpdate, confirmIncomeTemplates, getBudgetAccounts, getBudgetCategories, getBudgetTransactions, getIncomePlans, getIncomeSeedCandidates, getIncomeStatus, previewIncomePlan, previewIncomePlanUpdate, previewIncomeTemplates, type BudgetAccount, type BudgetCategory, type BudgetPreview, type BudgetTransaction, type IncomePlan, type IncomeSeedCandidate } from '@/api/budget'


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -86,7 +86,7 @@
 </template>
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref } from 'vue'
-import { archiveIncomePlan, confirmIncomePlan, confirmIncomePlanUpdate, confirmIncomeTemplates, getBudgetAccounts, getBudgetCategories, getBudgetTransactions, getIncomePlans, getIncomeSeedCandidates, getIncomeStatus, previewIncomePlan, previewIncomePlanUpdate, previewIncomeTemplates, type BudgetAccount, type BudgetCategory, type BudgetPreview, type BudgetTransaction, type IncomePlan, type IncomeSeedCandidate } from '@/api/budget'
+import { archiveIncomePlan, confirmBudgetTransaction, confirmIncomePlan, confirmIncomePlanUpdate, confirmIncomeTemplates, getBudgetAccounts, getBudgetCategories, getBudgetTransactions, getIncomePlans, getIncomeSeedCandidates, getIncomeStatus, previewBudgetTransaction, previewIncomePlan, previewIncomePlanUpdate, previewIncomeTemplates, type BudgetAccount, type BudgetCategory, type BudgetPreview, type BudgetTransaction, type IncomePlan, type IncomeSeedCandidate } from '@/api/budget'
 import { formatMoney } from '@/utils/formatters'
 const year = ref('2026')
 const activeTab = ref<'planned' | 'effective' | 'comparison'>('planned')

No linter for .vue files


const addPreview = ref<BudgetPreview | null>(null)
const manualIncomePreview = ref<BudgetPreview | null>(null)


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -103,6 +103,7 @@
 const message = ref('')
 const templatePreview = ref<Record<string, any> | null>(null)
 const addPreview = ref<BudgetPreview | null>(null)
+const manualIncomePreview = ref<BudgetPreview | null>(null)
 const editPreview = ref<(BudgetPreview & { requires_explicit_confirm?: boolean; review?: Record<string, string> }) | null>(null)
 const editing = ref<IncomePlan | null>(null)
 const form = reactive({ name: '', person: 'Marcel', employer: '', income_type: 'Lohn', cadence: 'monthly', target_month: '', plan_amount: '', amount_mode: 'monthly', category_id: '', notes: '' })

No linter for .vue files


const form = reactive({ name: '', person: 'Marcel', employer: '', income_type: 'Lohn', cadence: 'monthly', target_month: '', plan_amount: '', amount_mode: 'monthly', category_id: '', notes: '' })
const manualIncomeForm = reactive({ account_id: '', transaction_date: '', description: '', category_id: '', amount_original: '', currency_original: 'CHF', notes: '' })
const editForm = reactive({ monthly_amount_chf: '', annual_amount_chf: '', cadence: 'monthly', target_month: '', notes: '' })

const form = reactive({ name: '', person: 'Marcel', employer: '', income_type: 'Lohn', cadence: 'monthly', target_month: '', plan_amount: '', amount_mode: 'monthly', category_id: '', notes: '' })
const editForm = reactive({ monthly_amount_chf: '', annual_amount_chf: '', cadence: 'monthly', target_month: '', notes: '' })


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -107,6 +107,7 @@
 const editPreview = ref<(BudgetPreview & { requires_explicit_confirm?: boolean; review?: Record<string, string> }) | null>(null)
 const editing = ref<IncomePlan | null>(null)
 const form = reactive({ name: '', person: 'Marcel', employer: '', income_type: 'Lohn', cadence: 'monthly', target_month: '', plan_amount: '', amount_mode: 'monthly', category_id: '', notes: '' })
+const manualIncomeForm = reactive({ account_id: '', transaction_date: '', description: '', category_id: '', amount_original: '', currency_original: 'CHF', notes: '' })
 const editForm = reactive({ monthly_amount_chf: '', annual_amount_chf: '', cadence: 'monthly', target_month: '', notes: '' })
 const incomeFilters = reactive({ month: '', account_id: '', category_id: '', tag: '', search: '' })
 const incomeCategories = computed(() => categories.value.flatMap((c) => c.children?.length ? c.children : [c]).filter((c) => c.category_type === 'income'))

No linter for .vue files


Geld Eingang

Fortlaufende Tabelle bestätigter effektiver Einnahmen. Planwerte bleiben separat.

Tatsächliche Einkunft manuell erfassen

Preview → Confirm → Audit. Nicht mit geplanten Einkünften verwechseln.

<button class="btn-secondary min-h-10" @click="previewManualIncome">Preview</button><button class="btn min-h-10" :disabled="!manualIncomePreview" @click="confirmManualIncome">Confirm</button>

<select v-model="manualIncomeForm.account_id" class="field"><option value="">Konto</option><option v-for="a in accounts" :key="a.budget_account_id" :value="a.budget_account_id">{{ a.name }}</option></select><input v-model="manualIncomeForm.transaction_date" type="date" class="field" /><input v-model="manualIncomeForm.description" class="field" placeholder="Quelle/Beschreibung" /><select v-model="manualIncomeForm.category_id" class="field"><option value="">Einkommenskategorie</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><input v-model="manualIncomeForm.amount_original" inputmode="decimal" class="field" placeholder="Betrag" /><input v-model="manualIncomeForm.currency_original" class="field" placeholder="Währung" /><input v-model="manualIncomeForm.notes" class="field" placeholder="Notiz" />

Preview bereit: {{ manualIncomePreview.summary }}

<input v-model="incomeFilters.month" class="field" placeholder="Monat YYYY-MM" @change="loadActualIncome" /><select v-model="incomeFilters.category_id" class="field" @change="loadActualIncome"><option value="">Alle Kategorien</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><select v-model="incomeFilters.account_id" class="field" @change="loadActualIncome"><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><input v-model="incomeFilters.search" class="field" placeholder="Suche" @keyup.enter="loadActualIncome" />

Summe aktueller Monat: {{ formatMoney(data?.actual_income_current_month || '0') }}

Summe Jahr bisher: {{ formatMoney(data?.actual_income_year_to_date || '0') }}

Prognose Jahr: {{ formatMoney(data?.planned_income_year || '0') }}

Abweichung: {{ formatMoney(data?.deviation_year || '0') }}

Effektive Einnahmen

Tatsächliche Einnahmen: nur bestätigte Buchungen. Keine Importlogik wird ausgelöst.

<input v-model="incomeFilters.month" class="field" placeholder="Monat YYYY-MM" @change="loadActualIncome" /><select v-model="incomeFilters.category_id" class="field" @change="loadActualIncome"><option value="">Alle Kategorien</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><select v-model="incomeFilters.account_id" class="field" @change="loadActualIncome"><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><input v-model="incomeFilters.tag" class="field" placeholder="Tag" @keyup.enter="loadActualIncome" /><input v-model="incomeFilters.search" class="field" placeholder="Suche" @keyup.enter="loadActualIncome" />

Summe Einnahmen im Zeitraum: {{ formatMoney(actualIncomeTotal) }}


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -63,9 +63,14 @@

-

Effektive Einnahmen

Tatsächliche Einnahmen: nur bestätigte Buchungen. Keine Importlogik wird ausgelöst.

-
<input v-model="incomeFilters.month" class="field" placeholder="Monat YYYY-MM" @change="loadActualIncome" /><select v-model="incomeFilters.category_id" class="field" @change="loadActualIncome"><option value="">Alle Kategorien</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><select v-model="incomeFilters.account_id" class="field" @change="loadActualIncome"><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><input v-model="incomeFilters.tag" class="field" placeholder="Tag" @keyup.enter="loadActualIncome" /><input v-model="incomeFilters.search" class="field" placeholder="Suche" @keyup.enter="loadActualIncome" />

-
Summe Einnahmen im Zeitraum: {{ formatMoney(actualIncomeTotal) }}

+

Geld Eingang

Fortlaufende Tabelle bestätigter effektiver Einnahmen. Planwerte bleiben separat.

+

+

Tatsächliche Einkunft manuell erfassen

Preview → Confirm → Audit. Nicht mit geplanten Einkünften verwechseln.

<button class="btn-secondary min-h-10" @click="previewManualIncome">Preview</button><button class="btn min-h-10" :disabled="!manualIncomePreview" @click="confirmManualIncome">Confirm</button>

+
<select v-model="manualIncomeForm.account_id" class="field"><option value="">Konto</option><option v-for="a in accounts" :key="a.budget_account_id" :value="a.budget_account_id">{{ a.name }}</option></select><input v-model="manualIncomeForm.transaction_date" type="date" class="field" /><input v-model="manualIncomeForm.description" class="field" placeholder="Quelle/Beschreibung" /><select v-model="manualIncomeForm.category_id" class="field"><option value="">Einkommenskategorie</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><input v-model="manualIncomeForm.amount_original" inputmode="decimal" class="field" placeholder="Betrag" /><input v-model="manualIncomeForm.currency_original" class="field" placeholder="Währung" /><input v-model="manualIncomeForm.notes" class="field" placeholder="Notiz" />

+
Preview bereit: {{ manualIncomePreview.summary }}

+

+
<input v-model="incomeFilters.month" class="field" placeholder="Monat YYYY-MM" @change="loadActualIncome" /><select v-model="incomeFilters.category_id" class="field" @change="loadActualIncome"><option value="">Alle Kategorien</option><option v-for="cat in incomeCategories" :key="cat.category_id" :value="cat.category_id">{{ cat.name }}</option></select><select v-model="incomeFilters.account_id" class="field" @change="loadActualIncome"><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><input v-model="incomeFilters.search" class="field" placeholder="Suche" @keyup.enter="loadActualIncome" />

+

Summe aktueller Monat: {{ formatMoney(data?.actual_income_current_month || '0') }}

Summe Jahr bisher: {{ formatMoney(data?.actual_income_year_to_date || '0') }}

Prognose Jahr: {{ formatMoney(data?.planned_income_year || '0') }}

Abweichung: {{ formatMoney(data?.deviation_year || '0') }}

{{ group.date }}
<article v-for="tx in group.items" :key="tx.budget_transaction_id" :data-test="`income-row-${tx.budget_transaction_id}`" class="cursor-pointer border-b border-slate-200 p-4 last:border-b-0 hover:bg-white" @click="selectedIncome = tx">

{{ tx.merchant_display_name || tx.payee || tx.description }}

{{ tx.category_name || 'Einnahme' }} · {{ tx.account_name || 'Konto' }}

{{ formatMoney(tx.amount_chf || tx.amount_original) }}

Noch keine bestätigten Einnahmen für diese Filter.

No linter for .vue files


async function load() { data.value = await getIncomeStatus(year.value); plans.value = await getIncomePlans(year.value); candidates.value = await getIncomeSeedCandidates(year.value); categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await loadActualIncome(); if (!form.category_id && incomeCategories.value.length) form.category_id = incomeCategories.value[0].category_id; if (!manualIncomeForm.category_id && incomeCategories.value.length) manualIncomeForm.category_id = incomeCategories.value[0].category_id; if (!manualIncomeForm.account_id && accounts.value.length) manualIncomeForm.account_id = accounts.value[0].budget_account_id }

async function load() { data.value = await getIncomeStatus(year.value); plans.value = await getIncomePlans(year.value); candidates.value = await getIncomeSeedCandidates(year.value); categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await loadActualIncome(); if (!form.category_id && incomeCategories.value.length) form.category_id = incomeCategories.value[0].category_id }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -131,7 +131,7 @@
 const actualIncomeTotal = computed(() => actualIncome.value.reduce((acc, tx) => acc + Number(tx.amount_chf || tx.amount_original || 0), 0))
 function cadenceLabel(c: string) { return c === 'annual' ? 'jährlich' : c === 'one_time' ? 'einmalig' : c === 'irregular' ? 'unregelmässig' : 'monatlich' }
 async function loadActualIncome() { const params = new URLSearchParams({ transaction_type: 'income', status: 'confirmed', year: year.value }); for (const [k, v] of Object.entries(incomeFilters)) if (v) params.set(k, v); actualIncome.value = await getBudgetTransactions(`?${params.toString()}`) }
-async function load() { data.value = await getIncomeStatus(year.value); plans.value = await getIncomePlans(year.value); candidates.value = await getIncomeSeedCandidates(year.value); categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await loadActualIncome(); if (!form.category_id && incomeCategories.value.length) form.category_id = incomeCategories.value[0].category_id }
+async function load() { data.value = await getIncomeStatus(year.value); plans.value = await getIncomePlans(year.value); candidates.value = await getIncomeSeedCandidates(year.value); categories.value = await getBudgetCategories(); accounts.value = await getBudgetAccounts(); await loadActualIncome(); if (!form.category_id && incomeCategories.value.length) form.category_id = incomeCategories.value[0].category_id; if (!manualIncomeForm.category_id && incomeCategories.value.length) manualIncomeForm.category_id = incomeCategories.value[0].category_id; if (!manualIncomeForm.account_id && accounts.value.length) manualIncomeForm.account_id = accounts.value[0].budget_account_id }
 function notes(person: string, incomeType: string, targetMonth: string, employer = '', extra = '') { return `person=${person};income_type=${incomeType};target_month=${targetMonth};employer=${employer};${extra}` }
 function buildPayload() { const payload: Record<string, unknown> = { plan_month: `${year.value}-01`, category_id: form.category_id, name: form.name, cadence: form.cadence, source_type: 'manual', notes: notes(form.person, form.income_type, form.target_month, form.employer, form.notes) }; if (form.amount_mode === 'monthly') payload.monthly_amount_chf = form.plan_amount; else payload.annual_amount_chf = form.plan_amount; return payload }
 async function previewTemplates() { error.value = ''; templatePreview.value = await previewIncomeTemplates(year.value) }

No linter for .vue files


async function confirmAddIncome() { if (!addPreview.value) return; await confirmIncomePlan(addPreview.value.payload); message.value = 'Einkunft gespeichert und auditiert.'; addPreview.value = null; form.name = ''; form.plan_amount = ''; form.notes = ''; await load() }
function buildManualIncomePayload() { return { account_id: manualIncomeForm.account_id, transaction_type: 'income', transaction_date: manualIncomeForm.transaction_date, description: manualIncomeForm.description, payee: manualIncomeForm.description, amount_original: manualIncomeForm.amount_original, currency_original: manualIncomeForm.currency_original || 'CHF', category_id: manualIncomeForm.category_id, source_type: 'manual', notes: manualIncomeForm.notes } }
async function previewManualIncome() { error.value = ''; message.value = ''; if (!manualIncomeForm.account_id || !manualIncomeForm.transaction_date || !manualIncomeForm.description || !manualIncomeForm.category_id || !manualIncomeForm.amount_original) { error.value = 'Konto, Datum, Beschreibung, Kategorie und Betrag sind Pflicht.'; return } manualIncomePreview.value = await previewBudgetTransaction(buildManualIncomePayload()) }
async function confirmManualIncome() { if (!manualIncomePreview.value) return; await confirmBudgetTransaction(manualIncomePreview.value.payload); message.value = 'Tatsächliche Einkunft gespeichert und auditiert.'; manualIncomePreview.value = null; manualIncomeForm.description = ''; manualIncomeForm.amount_original = ''; manualIncomeForm.notes = ''; await load() }
function startEdit(plan: IncomePlan) { editing.value = plan; editPreview.value = null; editForm.monthly_amount_chf = plan.monthly_value_chf === '0.00' ? '' : plan.monthly_value_chf; editForm.annual_amount_chf = plan.annual_value_chf === '0.00' ? '' : plan.annual_value_chf; editForm.cadence = plan.cadence; editForm.target_month = plan.target_month || ''; editForm.notes = plan.notes || '' }

async function confirmAddIncome() { if (!addPreview.value) return; await confirmIncomePlan(addPreview.value.payload); message.value = 'Einkunft gespeichert und auditiert.'; addPreview.value = null; form.name = ''; form.plan_amount = ''; form.notes = ''; await load() }
function startEdit(plan: IncomePlan) { editing.value = plan; editPreview.value = null; editForm.monthly_amount_chf = plan.monthly_value_chf === '0.00' ? '' : plan.monthly_value_chf; editForm.annual_amount_chf = plan.annual_value_chf === '0.00' ? '' : plan.annual_value_chf; editForm.cadence = plan.cadence; editForm.target_month = plan.target_month || ''; editForm.notes = plan.notes || '' }


--- a//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
+++ b//home/agent/.hermes/repos/FinanceManager/frontend/src/pages/BudgetIncomePage.vue
@@ -138,6 +138,9 @@
 async function confirmTemplates() { error.value = ''; const res = await confirmIncomeTemplates(year.value); message.value = `Templates aktualisiert: ${res.created_count ?? 0} neu, ${res.renamed_count ?? 0} umbenannt`; templatePreview.value = null; await load() }
 async function previewAddIncome() { error.value = ''; message.value = ''; if (!form.name || !form.category_id || !form.plan_amount) { error.value = 'Name, Kategorie und Planwert sind Pflicht.'; return } addPreview.value = await previewIncomePlan(buildPayload()) }
 async function confirmAddIncome() { if (!addPreview.value) return; await confirmIncomePlan(addPreview.value.payload); message.value = 'Einkunft gespeichert und auditiert.'; addPreview.value = null; form.name = ''; form.plan_amount = ''; form.notes = ''; await load() }
+function buildManualIncomePayload() { return { account_id: manualIncomeForm.account_id, transaction_type: 'income', transaction_date: manualIncomeForm.transaction_date, description: manualIncomeForm.description, payee: manualIncomeForm.description, amount_original: manualIncomeForm.amount_original, currency_original: manualIncomeForm.currency_original || 'CHF', category_id: manualIncomeForm.category_id, source_type: 'manual', notes: manualIncomeForm.notes } }
+async function previewManualIncome() { error.value = ''; message.value = ''; if (!manualIncomeForm.account_id || !manualIncomeForm.transaction_date || !manualIncomeForm.description || !manualIncomeForm.category_id || !manualIncomeForm.amount_original) { error.value = 'Konto, Datum, Beschreibung, Kategorie und Betrag sind Pflicht.'; return } manualIncomePreview.value = await previewBudgetTransaction(buildManualIncomePayload()) }
+async function confirmManualIncome() { if (!manualIncomePreview.value) return; await confirmBudgetTransaction(manualIncomePreview.value.payload); message.value = 'Tatsächliche Einkunft gespeichert und auditiert.'; manualIncomePreview.value = null; manualIncomeForm.description = ''; manualIncomeForm.amount_original = ''; manualIncomeForm.notes = ''; await load() }
 function startEdit(plan: IncomePlan) { editing.value = plan; editPreview.value = null; editForm.monthly_amount_chf = plan.monthly_value_chf === '0.00' ? '' : plan.monthly_value_chf; editForm.annual_amount_chf = plan.annual_value_chf === '0.00' ? '' : plan.annual_value_chf; editForm.cadence = plan.cadence; editForm.target_month = plan.target_month || ''; editForm.notes = plan.notes || '' }
 function closeEdit() { editing.value = null; editPreview.value = null }
 async function previewEdit() { if (!editing.value) return; error.value = ''; editPreview.value = await previewIncomePlanUpdate(editing.value.plan_item_id, { monthly_amount_chf: editForm.monthly_amount_chf || null, annual_amount_chf: editForm.annual_amount_chf || null, cadence: editForm.cadence, target_month: editForm.target_month, notes: editForm.notes, source_type: 'manual' }) }

No linter for .vue files


UI-Grundlagen sind ergänzt. Jetzt schreibe/erweitere Tests für Navigation-Fallback und Income Recognition/Confirm.

Explanation:
**Considering testing steps**

I think I need to run some tests first. Maybe I’ll start with a quick backend pytest to compile everything. It seems crucial to apply the runtime reclassification after making a backup. Should I really do the tests before anything else? I should remember to add backend tests too, and I need to look into how to import those tests properly. Alright, I’ll sort this out step by step!


     1|from __future__ import annotations
     2|
     3|from jarvis_finance.services.budget_accounts import confirm_create_budget_account
     4|from jarvis_finance.services.budget_categories import confirm_create_category
     5|from jarvis_finance.services.budget_csv_imports import (
     6|    detect_import_profile,
     7|    import_budget_csv_text,
     8|    parse_budget_csv_text,
     9|)
    10|from jarvis_finance.services.budget_imports import list_transaction_candidates
    11|from jarvis_finance.services.budget_transactions import confirm_budget_transaction
    12|from jarvis_finance.storage.database import connect_memory
    13|from jarvis_finance.storage.migrations import apply_migrations
    14|
    15|
    16|def db():
    17|    conn = connect_memory()
    18|    apply_migrations(conn)
    19|    return conn
    20|
    21|
    22|def setup_base(conn):
    23|    account = confirm_create_budget_account(conn, {"name": "Budget Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    24|    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"]
    25|    income = confirm_create_category(conn, {"name": "Lohn Marcel", "category_type": "income"})["entity_id"]
    26|    return account, food, income
    27|
    28|
    29|def test_import_profiles_detect_real_drive_csv_columns() -> None:
    30|    assert detect_import_profile(["TransactionId", "CardId", "Date", "Amount", "Currency", "MerchantName"]) == "visa_credit_card"
    31|    assert detect_import_profile(["Datum", "Zeit", "Filiale", "Kassennummer", "Transaktionsnummer", "Artikel", "Umsatz"]) == "migros_receipts"
    32|    assert detect_import_profile(["IBAN", "Booked At", "Text", "Credit/Debit Amount", "Balance", "Valuta Date"]) == "raiffeisen_bank"
    33|    assert detect_import_profile(["Buchung", "Valuta", "Buchungstext", "Belastung", "Gutschrift", "Saldo CHF"]) == "akb_bank"
    34|
    35|
    36|def test_dry_run_parses_all_profiles_without_writing_candidates() -> None:
    37|    conn = db(); _account, food, income = setup_base(conn)
    38|    samples = {
    39|        "visa_credit_card": "TransactionId,CardId,Date,ValutaDate,Amount,Currency,MerchantName,Details\nT1,C1,2026-01-03,2026-01-04,-12.34,CHF,APPLE.COM,Bill\n",
    40|        "migros_receipts": "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.01.2026;10:00;Migros Test;1;99;Artikel A;1;;1.10\n02.01.2026;10:00;Migros Test;1;99;Artikel B;1;;2.20\n",
    41|        "raiffeisen_bank": "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH00;2026-01-05;Lohn Marcel;123.45;1000;2026-01-05\n",
    42|        "akb_bank": "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n05.01.2026;05.01.2026;Miete Verwaltung;88.00;;1000\n",
    43|    }
    44|    for profile, text in samples.items():
    45|        result = import_budget_csv_text(conn, profile, text, source_file_label=f"{profile}.csv", dry_run=True, default_category_id=food, income_category_id=income)
    46|        assert result["profile"] == profile
    47|        assert result["dry_run"] is True
    48|        assert result["parsed_rows"] >= 1
    49|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 0
    50|
    51|
    52|def test_import_is_idempotent_and_marks_already_processed_rows() -> None:
    53|    conn = db(); _account, food, _income = setup_base(conn)
    54|    csv_text = "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nTID-1,CARD-1,2026-02-01,-10.00,CHF,Netflix,Subscription\n"
    55|
    56|    first = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    57|    second = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    58|
    59|    assert first["candidate_count"] == 1
    60|    assert second["candidate_count"] == 0
    61|    assert second["already_processed_count"] == 1
    62|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates").fetchone()[0] == 1
    63|
    64|
    65|def test_import_marks_existing_confirmed_transaction_as_duplicate_existing() -> None:
    66|    conn = db(); account, food, _income = setup_base(conn)
    67|    confirm_budget_transaction(conn, {
    68|        "account_id": account,
    69|        "transaction_type": "expense",
    70|        "transaction_date": "2026-02-10",
    71|        "description": "Manual Netflix",
    72|        "amount_original": "10.00",
    73|        "currency_original": "CHF",
    74|        "category_id": food,
    75|        "source_type": "manual",
    76|    })
    77|    csv_text = "TransactionId,CardId,Date,Amount,Currency,MerchantName,Details\nTID-2,CARD-1,2026-02-10,-10.00,CHF,Netflix,Subscription\n"
    78|
    79|    result = import_budget_csv_text(conn, "visa_credit_card", csv_text, source_file_label="visa.csv", dry_run=False, default_category_id=food)
    80|    row = list_transaction_candidates(conn, tab="duplicates", include_reference=True)[0]
    81|
    82|    assert result["possible_duplicate_count"] == 1
    83|    assert row["status"] == "duplicate_candidate"
    84|
    85|
    86|def test_bank_import_classifies_transfers_true_wealth_credit_card_payment_and_income() -> None:
    87|    conn = db(); _account, _food, income = setup_base(conn)
    88|    csv_text = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\nCH1;2026-03-01;Übertrag an AKB eigenes Konto;-100.00;0;2026-03-01\nCH1;2026-03-02;True Wealth Einzahlung;-200.00;0;2026-03-02\nCH1;2026-03-03;VISA Kartenabrechnung;-300.00;0;2026-03-03\nCH1;2026-03-04;Lohn Marcel;400.00;0;2026-03-04\n"
    89|
    90|    result = import_budget_csv_text(conn, "raiffeisen_bank", csv_text, source_file_label="raiffeisen.csv", dry_run=False, income_category_id=income)
    91|    candidates = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
    92|    classifications = [c["classification"] for c in candidates]
    93|
    94|    assert result["transfer_candidate_count"] >= 3
    95|    assert "transfer_candidate" in classifications
    96|    assert "investment_transfer" in classifications
    97|    assert "credit_card_payment" in classifications
    98|    assert "income_candidate" in classifications
    99|    assert all(c["status"] != "confirmed" for c in candidates)
   100|
   101|
   102|def test_migros_import_creates_one_receipt_candidate_and_detail_line_items() -> None:
   103|    conn = db(); _account, food, _income = setup_base(conn)
   104|    csv_text = "Datum;Zeit;Filiale;Kassennummer;Transaktionsnummer;Artikel;Menge;Aktion;Umsatz\n02.01.2026;10:00;Migros Test;1;99;Artikel A;1;;1.10\n02.01.2026;10:00;Migros Test;1;99;Artikel B;1;;2.20\n"
   105|
   106|    result = import_budget_csv_text(conn, "migros_receipts", csv_text, source_file_label="migros.csv", dry_run=False, default_category_id=food)
   107|
   108|    assert result["candidate_count"] == 1
   109|    assert result["line_item_count"] == 2
   110|    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE source_type='migros_receipts'").fetchone()[0] == 1
   111|    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 2
   112|


Explanation:
**Planning tests**

I need to add some tests for this. It seems important to include tests for the mobile version too. I wonder if I should start with the basic functionality or focus on edge cases first. It might be useful to consider how users will interact with it on their devices. I'll try to organize the tests in a way that covers both desktop and mobile experiences. This should help ensure everything works smoothly!

from jarvis_finance.services.budget_imports import confirm_transaction_candidate, list_transaction_candidates, reclassify_bank_income_candidates

from jarvis_finance.services.budget_imports import list_transaction_candidates


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_production_v2.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_production_v2.py
@@ -7,7 +7,7 @@
     import_budget_csv_text,
     parse_budget_csv_text,
 )
-from jarvis_finance.services.budget_imports import list_transaction_candidates
+from jarvis_finance.services.budget_imports import confirm_transaction_candidate, list_transaction_candidates, reclassify_bank_income_candidates
 from jarvis_finance.services.budget_transactions import confirm_budget_transaction
 from jarvis_finance.storage.database import connect_memory
 from jarvis_finance.storage.migrations import apply_migrations


    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 2

def test_bank_income_recognition_patterns_and_negative_guards() -> None:
    conn = db(); _account, _food, income = setup_base(conn)
    csv_text = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\n" \
        "CH1;2026-04-01;ERNE AG Lohnzahlung;10.00;0;2026-04-01\n" \
        "CH1;2026-04-02;Musikschule Lohn Melanie;11.00;0;2026-04-02\n" \
        "CH1;2026-04-03;Gasser Bauunternehmen Salär;12.00;0;2026-04-03\n" \
        "CH1;2026-04-04;Bonus Gratifikation;13.00;0;2026-04-04\n" \
        "CH1;2026-04-05;Solarstrom Rückvergütung;14.00;0;2026-04-05\n" \
        "CH1;2026-04-06;AKB Umbuchung eigenes Konto;15.00;0;2026-04-06\n" \
        "CH1;2026-04-07;True Wealth Einzahlung;16.00;0;2026-04-07\n" \
        "CH1;2026-04-08;VISA Kartenabrechnung;17.00;0;2026-04-08\n"
    import_budget_csv_text(conn, "raiffeisen_bank", csv_text, source_file_label="raiffeisen.csv", dry_run=False, income_category_id=income)
    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
    by_desc = {r["description"]: r for r in rows}
    assert by_desc["ERNE AG Lohnzahlung"]["classification"] == "income_candidate"
    assert by_desc["Musikschule Lohn Melanie"]["classification"] == "income_candidate"
    assert by_desc["Gasser Bauunternehmen Salär"]["classification"] == "income_candidate"
    assert by_desc["Bonus Gratifikation"]["classification"] == "income_candidate"
    assert by_desc["Solarstrom Rückvergütung"]["classification"] == "income_candidate"
    assert by_desc["AKB Umbuchung eigenes Konto"]["classification"] == "transfer_candidate"
    assert by_desc["True Wealth Einzahlung"]["classification"] == "investment_transfer"
    assert by_desc["VISA Kartenabrechnung"]["classification"] == "credit_card_payment"

def test_reclassify_existing_akb_raiffeisen_candidates_without_confirming() -> None:
    conn = db(); account, _food, income = setup_base(conn)
    import_budget_csv_text(conn, "akb_bank", "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n01.04.2026;01.04.2026;ERNE Lohn;;10.00;0\n02.04.2026;02.04.2026;AKB Raiffeisen Übertrag;;10.00;0\n", source_file_label="akb.csv", dry_run=False, income_category_id=income)
    conn.execute("UPDATE budget_transaction_candidates SET status='needs_review', classification='bank_review', proposed_category_id=NULL, proposed_category_name=NULL")
    result = reclassify_bank_income_candidates(conn, budget_year="2026")
    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
    assert result["updated_count"] >= 2
    assert rows[0]["classification"] == "income_candidate"
    assert rows[0]["status"] == "pending"
    assert rows[1]["classification"] == "transfer_candidate"
    assert all(r["status"] != "confirmed" for r in rows)
    confirmed = confirm_transaction_candidate(conn, rows[0]["transaction_candidate_id"], {"account_id": account, "category_id": income, "transaction_type": "income", "budget_year": "2026"})
    assert confirmed["status"] == "confirmed"
    tx = conn.execute("SELECT transaction_type, source_type, source_candidate_id, status FROM budget_transactions WHERE budget_transaction_id=?", (confirmed["entity_id"],)).fetchone()
    assert dict(tx) == {"transaction_type": "income", "source_type": "import_candidate", "source_candidate_id": rows[0]["transaction_candidate_id"], "status": "confirmed"}


--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_production_v2.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_import_production_v2.py
@@ -109,3 +109,44 @@
     assert result["line_item_count"] == 2
     assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE source_type='migros_receipts'").fetchone()[0] == 1
     assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 2
+
+
+def test_bank_income_recognition_patterns_and_negative_guards() -> None:
+    conn = db(); _account, _food, income = setup_base(conn)
+    csv_text = "IBAN;Booked At;Text;Credit/Debit Amount;Balance;Valuta Date\n" \
+        "CH1;2026-04-01;ERNE AG Lohnzahlung;10.00;0;2026-04-01\n" \
+        "CH1;2026-04-02;Musikschule Lohn Melanie;11.00;0;2026-04-02\n" \
+        "CH1;2026-04-03;Gasser Bauunternehmen Salär;12.00;0;2026-04-03\n" \
+        "CH1;2026-04-04;Bonus Gratifikation;13.00;0;2026-04-04\n" \
+        "CH1;2026-04-05;Solarstrom Rückvergütung;14.00;0;2026-04-05\n" \
+        "CH1;2026-04-06;AKB Umbuchung eigenes Konto;15.00;0;2026-04-06\n" \
+        "CH1;2026-04-07;True Wealth Einzahlung;16.00;0;2026-04-07\n" \
+        "CH1;2026-04-08;VISA Kartenabrechnung;17.00;0;2026-04-08\n"
+    import_budget_csv_text(conn, "raiffeisen_bank", csv_text, source_file_label="raiffeisen.csv", dry_run=False, income_category_id=income)
+    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
+    by_desc = {r["description"]: r for r in rows}
+    assert by_desc["ERNE AG Lohnzahlung"]["classification"] == "income_candidate"
+    assert by_desc["Musikschule Lohn Melanie"]["classification"] == "income_candidate"
+    assert by_desc["Gasser Bauunternehmen Salär"]["classification"] == "income_candidate"
+    assert by_desc["Bonus Gratifikation"]["classification"] == "income_candidate"
+    assert by_desc["Solarstrom Rückvergütung"]["classification"] == "income_candidate"
+    assert by_desc["AKB Umbuchung eigenes Konto"]["classification"] == "transfer_candidate"
+    assert by_desc["True Wealth Einzahlung"]["classification"] == "investment_transfer"
+    assert by_desc["VISA Kartenabrechnung"]["classification"] == "credit_card_payment"
+
+
+def test_reclassify_existing_akb_raiffeisen_candidates_without_confirming() -> None:
+    conn = db(); account, _food, income = setup_base(conn)
+    import_budget_csv_text(conn, "akb_bank", "Buchung;Valuta;Buchungstext;Belastung;Gutschrift;Saldo CHF\n01.04.2026;01.04.2026;ERNE Lohn;;10.00;0\n02.04.2026;02.04.2026;AKB Raiffeisen Übertrag;;10.00;0\n", source_file_label="akb.csv", dry_run=False, income_category_id=income)
+    conn.execute("UPDATE budget_transaction_candidates SET status='needs_review', classification='bank_review', proposed_category_id=NULL, proposed_category_name=NULL")
+    result = reclassify_bank_income_candidates(conn, budget_year="2026")
+    rows = list_transaction_candidates(conn, include_reference=True, sort_by="date", sort_dir="asc")
+    assert result["updated_count"] >= 2
+    assert rows[0]["classification"] == "income_candidate"
+    assert rows[0]["status"] == "pending"
+    assert rows[1]["classification"] == "transfer_candidate"
+    assert all(r["status"] != "confirmed" for r in rows)
+    confirmed = confirm_transaction_candidate(conn, rows[0]["transaction_candidate_id"], {"account_id": account, "category_id": income, "transaction_type": "income", "budget_year": "2026"})
+    assert confirmed["status"] == "confirmed"
+    tx = conn.execute("SELECT transaction_type, source_type, source_candidate_id, status FROM budget_transactions WHERE budget_transaction_id=?", (confirmed["entity_id"],)).fetchone()
+    assert dict(tx) == {"transaction_type": "income", "source_type": "import_candidate", "source_candidate_id": rows[0]["transaction_candidate_id"], "status": "confirmed"}


Explanation:
**Determining pattern adjustments**

I need to focus on "Salär," but it's not fitting into my patterns. Should I consider adding "salaris" or maybe the test is failing because of that? Maybe I should try adding a normalized version like "SALÄR." But then I wonder if _matches is just looking for upper ASCII and isn’t replacing "ä." If the normalize function converts "ä" to "ae" and then to uppercase as "SALAER," I definitely need to ensure the pattern recognizes "SALAER."

"BONUS", "GRATIFIKATION", "SALAER", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",

"BONUS", "GRATIFIKATION", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py
@@ -23,7 +23,7 @@
 INCOME_PATTERNS = [
     "LOHN", "SALARY", "ARBEITGEBER", "PAYROLL", "GEHALT",
     "ERNE", "MUSIKSCHULE", "GASSER", "GASSER BAUUNTERNEHMEN",
-    "BONUS", "GRATIFIKATION", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
+    "BONUS", "GRATIFIKATION", "SALAER", "RUECKVERGUETUNG", "RÜCKVERGÜTUNG",
     "RUECKERSTATTUNG", "RÜCKERSTATTUNG", "ERSTATTUNG", "SOLARSTROM", "VERGUETUNG", "VERGÜTUNG",
 ]
 STRONG_INCOME_PATTERNS = [


[Your active task list was preserved across context compression]
- [>] final-0. Preflight: Branch/Git/Runtime, Review-/Import-/Income-Code und vorhandene AKB/Raiffeisen-Kandidaten inventarisieren (in_progress)
- [ ] final-1. Mobile Navigation final stabilisieren: Fullscreen-Fallback-Menü mit großen nativen Links, Overlay/pointer-events prüfen (pending)
- [ ] final-2. Income Recognition Rules für vorhandene AKB/Raiffeisen-Kandidaten erweitern und Reclassify-Workflow ohne Auto-Confirm bauen (pending)
- [ ] final-3. Review UI Tabs/Geld-Eingang-Kandidaten: Einkommen, mögliche Einkommen, Transfers, Nicht-Einkommen (pending)
- [ ] final-4. Geld Eingang Tabelle auf Einkünfte-Seite inkl. Filter/Summen/Aktionen ergänzen (pending)
- [ ] final-5. Income Confirm Flow und manuelle tatsächliche Einkunft Preview/Confirm/Audit absichern (pending)
- [ ] final-6. Tests: Mobile Menü, Income Recognition, Confirm, Manual Income, Safety (pending)
- [ ] final-7. Verification: compile, pytest, npm test/build, scans, Git-Safety, Browser/Tailscale sanity (pending)
- [ ] final-8. Commit, Push, Remote-Hash, Abschlussbericht mit Grenzen (pending)


Ich fahre fort. Aktueller Stand: Branch `feat/mobile-nav-income-csv-final`; Fullscreen-Fallback-Menü, Reclassification-Regeln/API, Review-Tabs und Geld-Eingang-Grundsektion sind bereits im Working Tree begonnen. Ich prüfe jetzt den Ist-Zustand, repariere Compiler/Testfehler und wende die Reclassification erst nach Backup auf die Runtime-Kandidaten an.

Explanation:
**Continuing compression work**

I need to pick up where I left off with compression and inspect the current state. I'll have to use some tools to finalize tests, making sure to proceed carefully to avoid excessive chatting. However, the tool outputs might still include numbers, which could cause confusion. So, I’ll run the necessary tests and update the status of my to-dos while checking the files involved in this process. I want to get this right!

terminal

git status --short --branch && python -m compileall -q src tests


Output:
## feat/mobile-nav-income-csv-final
 M frontend/src/api/budget.ts
 M frontend/src/components/SidebarNav.vue
 M frontend/src/pages/BudgetImportReviewPage.vue
 M frontend/src/pages/BudgetIncomePage.vue
 M src/jarvis_finance/api/routers/budget.py
 M src/jarvis_finance/services/budget_imports.py
 M tests/unit/test_budget_import_production_v2.py


todo

Preflight: Branch/Git/Runtime, Review-/Import-/Income-Code und vorhandene AKB/Raiffeisen-Kandidaten inventarisieren

Mobile Navigation final stabilisieren: Fullscreen-Fallback-Menü mit großen nativen Links, Overlay/pointer-events prüfen

Income Recognition Rules für vorhandene AKB/Raiffeisen-Kandidaten erweitern und Reclassify-Workflow ohne Auto-Confirm bauen

Review UI Tabs/Geld-Eingang-Kandidaten: Einkommen, mögliche Einkommen, Transfers, Nicht-Einkommen

Geld Eingang Tabelle auf Einkünfte-Seite inkl. Filter/Summen/Aktionen ergänzen

Income Confirm Flow und manuelle tatsächliche Einkunft Preview/Confirm/Audit absichern

Tests: Mobile Menü, Income Recognition, Confirm, Manual Income, Safety

Verification: compile, pytest, npm test/build, scans, Git-Safety, Browser/Tailscale sanity

Commit, Push, Remote-Hash, Abschlussbericht mit Grenzen


Compile ist sauber. Ich lasse nun die gezielten Backend-/Frontend-Tests laufen, um die aktuellen Bruchstellen präzise zu sehen.

terminal

pytest -q tests/unit/test_budget_import_production_v2.py tests/unit/test_budget_phase111_status_income_formatting.py

npm test -- --run src/components/SidebarNav.mobile.test.ts src/pages/BudgetIncomePage.test.ts


Output:
..............                                                           [100%]
14 passed in 0.82s


Output:
> jarvis-finance-dashboard-vue@0.0.0 test
> vitest run --environment jsdom --run src/components/SidebarNav.mobile.test.ts src/pages/BudgetIncomePage.test.ts

 RUN  v4.1.6 /home/agent/.hermes/repos/FinanceManager/frontend

 ✓ src/components/SidebarNav.mobile.test.ts (2 tests) 182ms
 ✓ src/pages/BudgetIncomePage.test.ts (4 tests) 365ms

 Test Files  2 passed (2)
      Tests  6 passed (6)
   Start at  14:50:07
   Duration  1.61s (transform 712ms, setup 341ms, import 751ms, tests 547ms, environment 1.16s)


Gezielte Tests sind grün. Ich ergänze jetzt explizite Tests für das neue Fullscreen-Mobile-Menü und die manuelle tatsächliche Einkunft auf der Einkünfte-Seite, damit die Akzeptanzpunkte nicht nur zufällig mitlaufen.

Explanation:
**Inspecting tests for fallback menu**

I need to consider adding tests for the fallback menu, especially since there might be some updates needed for the manual income part. I'm thinking that the existing tests could be outdated, so I need to inspect those tests carefully. It’s important to make sure everything is functioning properly. I’ll take a closer look to confirm that everything is up-to-date and working as it should be!


     1|import { mount } from '@vue/test-utils'
     2|import { describe, expect, it } from 'vitest'
     3|import SidebarNav from './SidebarNav.vue'
     4|
     5|const RouterLinkStub = { props: ['to'], template: '<a :href="to"><slot /></a>' }
     6|function mountNav(path = '/') {
     7|  window.history.replaceState({}, '', path)
     8|  return mount(SidebarNav, { global: { stubs: { RouterLink: RouterLinkStub } } })
     9|}
    10|
    11|describe('SidebarNav mobile bottom navigation', () => {
    12|  it('uses native iPhone-safe anchors for every primary bottom item with one-tap targets', () => {
    13|    const wrapper = mountNav('/planning/budget')
    14|    const nav = wrapper.get('[data-testid="mobile-bottom-nav"]')
    15|    expect(nav.classes().join(' ')).toContain('z-[70]')
    16|    expect(nav.classes().join(' ')).toContain('pointer-events-auto')
    17|    for (const [testId, target] of [['mobile-nav-home', '/'], ['mobile-nav-budget', '/planning/budget'], ['mobile-nav-expenses', '/planning/budget/expenses/actual'], ['mobile-nav-crypto', '/crypto'], ['mobile-nav-portfolio', '/portfolio']] as const) {
    18|      const link = wrapper.get(`[data-testid="${testId}"]`)
    19|      expect(link.element.tagName).toBe('A')
    20|      expect(link.attributes('href')).toBe(target)
    21|      expect(link.attributes('disabled')).toBeUndefined()
    22|      expect(link.classes().join(' ')).toContain('min-h-12')
    23|    }
    24|    expect(wrapper.get('[data-testid="mobile-nav-budget"]').classes()).toContain('mobile-nav-active')
    25|  })
    26|
    27|  it('opens More and exposes native anchors for the secondary routes', async () => {
    28|    const wrapper = mountNav('/crypto')
    29|    await wrapper.get('[data-testid="mobile-more-button"]').trigger('click')
    30|    const menu = wrapper.get('[data-testid="mobile-more-panel"]')
    31|    expect(menu.text()).toContain('Reports')
    32|    expect(menu.text()).toContain('Wallets')
    33|    expect(menu.text()).toContain('Einkünfte')
    34|    expect(menu.text()).toContain('Kategorien')
    35|    expect(menu.text()).toContain('Aktien/ETF & Cash')
    36|    for (const [testId, target] of [['mobile-more-reports', '/reports'], ['mobile-more-wallets', '/wallets'], ['mobile-more-income', '/planning/budget/income'], ['mobile-more-categories', '/planning/budget/categories'], ['mobile-more-equity', '/equity']] as const) {
    37|      const link = wrapper.get(`[data-testid="${testId}"]`)
    38|      expect(link.element.tagName).toBe('A')
    39|      expect(link.attributes('href')).toBe(target)
    40|      expect(link.attributes('disabled')).toBeUndefined()
    41|      expect(link.classes().join(' ')).toContain('min-h-11')
    42|    }
    43|  })
    44|})
    45|


     1|import { mount, flushPromises } from '@vue/test-utils'
     2|import { beforeEach, describe, expect, it, vi } from 'vitest'
     3|import BudgetIncomePage from './BudgetIncomePage.vue'
     4|import { archiveIncomePlan, confirmIncomePlan, confirmIncomePlanUpdate, confirmIncomeTemplates, getBudgetAccounts, getBudgetCategories, getBudgetTransactions, getIncomePlans, getIncomeSeedCandidates, getIncomeStatus, previewIncomePlan, previewIncomePlanUpdate, previewIncomeTemplates } from '@/api/budget'
     5|
     6|vi.mock('@/api/budget', () => ({
     7|  getIncomeStatus: vi.fn(),
     8|  getIncomePlans: vi.fn(),
     9|  getIncomeSeedCandidates: vi.fn(),
    10|  getBudgetCategories: vi.fn(),
    11|  getBudgetAccounts: vi.fn(),
    12|  getBudgetTransactions: vi.fn(),
    13|  previewIncomeTemplates: vi.fn(),
    14|  confirmIncomeTemplates: vi.fn(),
    15|  previewIncomePlan: vi.fn(),
    16|  confirmIncomePlan: vi.fn(),
    17|  previewIncomePlanUpdate: vi.fn(),
    18|  confirmIncomePlanUpdate: vi.fn(),
    19|  archiveIncomePlan: vi.fn()
    20|}))
    21|
    22|const incomePlan = { plan_item_id: 'p1', name: 'Lohn Marcel (ERNE)', person_source: 'Marcel', employer_source: 'ERNE', income_type: 'Lohn', target_month: 'monatlich', cadence: 'monthly', monthly_value_chf: '0.00', annual_value_chf: '0.00', monthly_average_chf: '0.00', actual_income_year_to_date_chf: '0.00', forecast_income_year_chf: '0.00', deviation_year_chf: '0.00', status_label: 'Noch keine Planwerte', category_id: 'cat_income', category_name: 'Lohn Marcel', is_active: true, is_template: true, notes: 'person=Marcel;income_type=Lohn;employer=ERNE;template=true' }
    23|
    24|beforeEach(() => {
    25|  vi.clearAllMocks()
    26|  vi.mocked(getIncomeStatus).mockResolvedValue({ planned_income_month: '0.00', planned_income_year: '0.00', planned_income_monthly_average: '0.00', actual_income_current_month: '0.00', actual_income_year_to_date: '0.00', deviation_year: '0.00', status_label: 'Noch keine Ist-Daten' } as any)
    27|  vi.mocked(getIncomePlans).mockResolvedValue([incomePlan] as any)
    28|  vi.mocked(getIncomeSeedCandidates).mockResolvedValue([] as any)
    29|  vi.mocked(getBudgetCategories).mockResolvedValue([{ category_id: 'cat_income', name: 'Lohn Marcel', category_type: 'income', is_active: true, children: [] }] as any)
    30|  vi.mocked(getBudgetAccounts).mockResolvedValue([{ budget_account_id: 'acc1', name: 'Haushalt', account_type: 'checking', currency: 'CHF', is_active: true }] as any)
    31|  vi.mocked(getBudgetTransactions).mockResolvedValue([{ budget_transaction_id: 'tx_inc_1', transaction_date: '2026-05-25', transaction_date_group: '2026-05-25', merchant_display_name: 'Arbeitgeber', description: 'Lohn', category_name: 'Lohn Marcel', account_name: 'Haushalt', amount_chf: '100.00' }] as any)
    32|  vi.mocked(previewIncomeTemplates).mockResolvedValue({ preview_id: 'prev_templates', template_count: 10, create_count: 2 } as any)
    33|  vi.mocked(confirmIncomeTemplates).mockResolvedValue({ status: 'confirmed', created_count: 2, renamed_count: 1, audit_id: 'audit1' } as any)
    34|  vi.mocked(previewIncomePlanUpdate).mockResolvedValue({ preview_id: 'prev_update', summary: 'Einkunft aktualisieren', warnings: [], payload: { monthly_amount_chf: '1000.00' }, review: { name: 'Lohn Marcel (ERNE)', income_type: 'Lohn', monthly_value_chf: '1000.00', annual_value_chf: '12000.00', monthly_average_chf: '1000.00', target_month: 'monatlich' }, requires_explicit_confirm: true } as any)
    35|  vi.mocked(confirmIncomePlanUpdate).mockResolvedValue({ status: 'updated', entity_id: 'p1', audit_id: 'audit2', message: 'updated' } as any)
    36|  vi.mocked(previewIncomePlan).mockResolvedValue({ preview_id: 'prev_add', summary: 'Neue Einkunft', warnings: [], payload: { name: 'Rückvergütung Solarstrom', annual_amount_chf: '100.00' } } as any)
    37|  vi.mocked(confirmIncomePlan).mockResolvedValue({ status: 'confirmed', entity_id: 'p2', audit_id: 'audit3', message: 'created' } as any)
    38|  vi.mocked(archiveIncomePlan).mockResolvedValue({ status: 'archived', entity_id: 'p1', audit_id: 'audit4', message: 'archived' } as any)
    39|})
    40|
    41|describe('BudgetIncomePage planning workflow', () => {
    42|  it('shows tabs, household summary, empty editable ERNE template and mobile cards', async () => {
    43|    const wrapper = mount(BudgetIncomePage)
    44|    await flushPromises()
    45|    expect(wrapper.get('[data-testid="income-tabs"]').text()).toContain('Geplante Einkünfte')
    46|    expect(wrapper.get('[data-testid="income-tabs"]').text()).toContain('Effektive Einnahmen')
    47|    expect(wrapper.get('[data-testid="income-tabs"]').text()).toContain('Plan vs Ist')
    48|    expect(wrapper.get('[data-testid="income-summary-cards"]').text()).toContain('Geplant Monat')
    49|    expect(wrapper.text()).toContain('Monatsdurchschnitt')
    50|    expect(wrapper.text()).toContain('Lohn Marcel (ERNE)')
    51|    expect(wrapper.text()).toContain('ERNE')
    52|    expect(wrapper.get('[data-testid="income-mobile-cards"]').text()).toContain('Planwert bearbeiten')
    53|  })
    54|
    55|  it('uses Preview then Confirm to seed income templates without amounts', async () => {
    56|    const wrapper = mount(BudgetIncomePage)
    57|    await flushPromises()
    58|    await wrapper.get('[data-testid="income-template-preview"]').trigger('click')
    59|    await flushPromises()
    60|    expect(previewIncomeTemplates).toHaveBeenCalledWith('2026')
    61|    expect(wrapper.text()).toContain('Keine Beträge')
    62|    await wrapper.get('[data-testid="income-template-confirm"]').trigger('click')
    63|    await flushPromises()
    64|    expect(confirmIncomeTemplates).toHaveBeenCalledWith('2026')
    65|  })
    66|
    67|  it('edits a template value through mobile-safe update preview and confirm', async () => {
    68|    const wrapper = mount(BudgetIncomePage)
    69|    await flushPromises()
    70|    await wrapper.findAll('button').find((button: any) => button.text() === 'Planwert bearbeiten')!.trigger('click')
    71|    await flushPromises()
    72|    expect(wrapper.get('[data-testid="income-edit-panel"]').text()).toContain('Planwert bearbeiten')
    73|    await wrapper.find('input[placeholder="Monatswert"]').setValue('1000.00')
    74|    await wrapper.get('[data-testid="income-update-preview"]').trigger('click')
    75|    await flushPromises()
    76|    expect(previewIncomePlanUpdate).toHaveBeenCalledWith('p1', expect.objectContaining({ monthly_amount_chf: '1000.00' }))
    77|    expect(wrapper.text()).toContain('Zusammenfassung prüfen')
    78|    expect(wrapper.text()).toContain('Jahreswert')
    79|    await wrapper.get('[data-testid="income-update-confirm"]').trigger('click')
    80|    await flushPromises()
    81|    expect(confirmIncomePlanUpdate).toHaveBeenCalledWith('p1', { monthly_amount_chf: '1000.00' })
    82|  })
    83|
    84|  it('adds and archives income through explicit preview/confirm/audit APIs', async () => {
    85|    const wrapper = mount(BudgetIncomePage)
    86|    await flushPromises()
    87|    await wrapper.find('input[placeholder="Name"]').setValue('Rückvergütung Solarstrom')
    88|    await wrapper.find('input[placeholder="Planwert"]').setValue('100.00')
    89|    await wrapper.findAll('button').find((button: any) => button.text() === 'Preview')!.trigger('click')
    90|    await flushPromises()
    91|    expect(previewIncomePlan).toHaveBeenCalled()
    92|    await wrapper.findAll('button').find((button: any) => button.text() === 'Confirm')!.trigger('click')
    93|    await flushPromises()
    94|    expect(confirmIncomePlan).toHaveBeenCalled()
    95|    await wrapper.findAll('button').find((button: any) => button.text() === 'Archivieren')!.trigger('click')
    96|    await flushPromises()
    97|    expect(archiveIncomePlan).toHaveBeenCalledWith('p1')
    98|  })
    99|})
   100|