     1|import { apiGet, apiPost } from './client'
     2|
     3|export type BudgetAccount = { budget_account_id: string; name: string; account_type: string; currency: string; is_active: boolean; linked_account_id?: string | null; archived_at?: string | null; notes?: string | null }
     4|export type BudgetCategory = { category_id: string; parent_category_id?: string | null; name: string; category_type: string; is_active: boolean; sort_order?: number; color?: string | null; icon?: string | null; transaction_count?: number; expense_sum_chf?: string | number; active_plan_count?: number; merchant_count?: number; rule_count?: number; children?: BudgetCategory[] }
     5|export type BudgetTag = { tag_id: string; name: string; color?: string | null; is_active: boolean }
     6|export type BudgetTransaction = { budget_transaction_id: string; account_id?: string; account_name?: string; category_id?: string | null; category_name?: string | null; category_icon?: string | null; category_color?: string | null; transaction_date: string; transaction_date_group?: string; transaction_type: string; description: string; payee?: string | null; merchant_display_name?: string | null; amount_original: string; currency_original: string; amount_chf?: string | null; fx_status?: string; source_type?: string; source_candidate_id?: string | null; status: string; tag_names?: string[]; line_item_count?: number; article_rows_detail_only?: boolean }
     7|export type BudgetTransfer = { transfer_id: string; transaction_date: string; from_account_id: string; to_account_id: string; from_account_name: string; to_account_name: string; description: string; amount_original: string; currency_original: string; transfer_type: string; status: string; notes?: string | null }
     8|export type BudgetMonthlyAnalysis = { month: string; month_label: string; actual_chf: string; budget_chf: string; difference_chf: string; status: string; bar_color: 'green' | 'yellow' | 'red' | 'slate' | string }
     9|export type BudgetMonthDetail = { category_id: string; category: string; category_type: string; month: string; budget_month: string; actual_chf: string; difference_chf: string; difference_percent: string; status: string; bar_color: string; breakdown_mode: string; breakdown: Array<{ label: string; amount_chf: string; share_percent: string }>; transactions: BudgetTransaction[]; expense_filter_url: string }
    10|export type BudgetPlanningRow = { category_id: string; category: string; category_type: string; budget_month_chf?: string | null; budget_year_chf?: string | null; budget_cadence?: string | null; has_budget: boolean; actual_current_month_chf: string; actual_months_2026: Record<string, string>; actual_year_to_date_chf: string; actual_avg_month_chf: string; linear_forecast_chf: string; budget_adjusted_forecast_chf: string; forecast_year_chf: string; previous_year_months: Record<string, string>; previous_year_total_chf: string; previous_year_monthly_average_chf: string; has_previous_year: boolean; actual_vs_budget_chf: string; forecast_vs_budget_chf: string; current_month_vs_budget_chf: string; current_month_vs_previous_year_chf: string; ytd_vs_previous_year_chf: string; forecast_vs_previous_year_chf: string; status: string; status_color: string; plausibility_checks?: Array<{ severity: string; code: string; message: string }>; data_explorer_url: string; effective_expenses_url: string; monthly_detail_url: string; monthly_chart: Array<{ month: string; actual_2026_chf: string; budget_chf: string; actual_2025_chf: string; deviation_chf: string }> }
    11|export type BudgetPlanningMatrix = { purpose: 'budget_planning_forecast_v1'; year: string; previous_year: string; current_month: string; forecast_method: string; forecast_method_help?: Record<string, string>; rows: BudgetPlanningRow[]; categories_without_budget: string[]; categories_without_previous_year: string[]; categories_with_actuals_without_budget: string[]; categories_with_budget_without_actuals: string[]; categories_with_previous_year_without_budget?: string[] }
    12|export type BudgetPlanningAudit = { category_id: string; category?: string; entries: Array<{ timestamp: string; action_label: string; source: string; note?: string; created_by: string; changes: Array<{ field_label: string; old_value: string; new_value: string }>; technical?: { audit_id: string } }> }
    13|export type BudgetRecurringPayment = { recurring_id: string; name: string; category_id: string; category_name?: string | null; account_id?: string | null; merchant_id?: string | null; expected_amount_text: string; expected_amount_chf?: string; currency: string; frequency: string; expected_day_of_month?: number | null; expected_month?: number | null; amount_tolerance_pct: string; date_tolerance_days: number; recurring_type: string; status: string; source: string; confidence?: string; last_seen_date?: string | null; next_expected_date?: string | null; notes?: string | null; budget_month_chf?: string; budget_year_chf?: string }
    14|export type BudgetRecurringDashboard = { purpose: 'fixed_costs_subscriptions_v1'; candidates: BudgetRecurringPayment[]; active: BudgetRecurringPayment[]; warnings: Array<Record<string, string>>; category_breakdown: Array<Record<string, string | number>>; kpis?: { monthly_fixed_costs_chf?: string; annual_fixed_costs_chf?: string; fixed_cost_quote_percent?: string; active_recurring_count?: number; open_candidate_count?: number; missing_payment_count?: number; next_due?: BudgetRecurringPayment | null; largest_fixed_costs?: BudgetRecurringPayment[] }; analytics: Record<string, any> }
    15|export type BudgetImportSession = { import_session_id: string; source?: string; file_name?: string; profile?: string; rows_total: number; new_candidates: number; already_known: number; duplicate_count: number; ignored_count: number; covered_by_source_count: number; error_count: number; status: string; review_url?: string; created_at: string }
    16|export type BudgetMonthlyImportDashboard = { purpose: 'budget_monthly_import_dashboard_v1'; latest_import_by_source: Record<string, BudgetImportSession>; open_candidates_by_source: Record<string, number>; counts: Record<string, number>; rule_suggestions_open: number; review_url: string }
    17|export type BudgetMonthlyImportResult = { purpose: string; status: string; files_total: number; session_ids: string[]; files: Array<Record<string, unknown>>; totals: Record<string, number>; review_url: string }
    18|export type BudgetUploadImportResult = { purpose: string; preview_id?: string; import_session_id?: string; status: string; file?: { detected: boolean; name: string; source_hash?: string }; profile?: string | null; source_type?: string | null; period?: { start?: string | null; end?: string | null }; totals: Record<string, number>; review_url: string; warnings?: string[]; errors?: string[] }
    19|export type BudgetRuleSuggestion = { suggestion_id: string; merchant_pattern: string; match_type: string; source_scope: string; category_id?: string | null; category_name?: string | null; recurring_type?: string | null; confidence: string; affected_open_candidate_count: number; status: string; created_at: string }
    20|export type GroceryMapping = { mapping_id: string; target_retailer: string; target_product_name: string; target_product_url: string; status: string; match_type?: string; confidence?: string; cache_status?: string; last_price_checked_at?: string; target_price_text?: string; target_unit_price_text?: string; target_price_date?: string; target_price_source?: string }
    21|export type GroceryManualMappingPreview = { preview_id: string; product_item_id: string; target_retailer: string; target_product_name: string; target_product_url: string; target_package_size?: string; target_unit?: string; candidate_price_text?: string; candidate_unit_price_text?: string; target_price_date?: string; target_price_source?: string; quality_flags: string[]; can_count_as_secure_saving: boolean; requires_confirm: boolean }
    22|export type GroceryProductItem = { product_item_id: string; receipt_id: string; purchase_date: string; store_name: string; raw_product_name: string; normalized_product_name: string; quantity_text?: string; unit?: string; unit_price_text?: string; total_price_text: string; action_label?: string; include_in_analysis: number; health_analysis_status: string; known_mappings?: GroceryMapping[] }
    23|export type GroceryReceipt = { receipt_id: string; purchase_date: string; store_name: string; article_count: number; total_text: string; items: GroceryProductItem[] }
    24|export type GroceryDashboard = { purpose: 'migros_product_savings_grocery_optimizer_v1'; receipts: GroceryReceipt[]; supported_retailers: string[]; health_analysis_status: string; disclaimer: string }
    25|export type GroceryRun = { run_id: string; receipt_id: string; original_total_text: string; optimized_total_text: string; quality_status: string; summary: Record<string, any>; optimized_lists: Array<Record<string, any>>; product_comparisons: Array<Record<string, any>>; health_analysis_status: string }
    26|export type GroceryOnlinePriceMatch = { match_id?: string; mapping_id?: string; product_item_id: string; retailer: string; product_name: string; product_url: string; price_text?: string; unit_price_text?: string; fetched_at?: string; quality_flags?: string[]; confidence?: string; status: string; source?: string; cache_status?: string }
    27|export type GroceryOnlinePriceSearch = { receipt_id: string; provider_calls: number; cache_hits: number; matches: GroceryOnlinePriceMatch[] }
    28|export type BudgetPreview = { preview_id: string; summary: string; fx_status?: string; warnings: string[]; payload: Record<string, unknown>; review?: Record<string, string>; requires_explicit_confirm?: boolean }
    29|export type BudgetConfirm = { status: string; entity_id: string; audit_id: string; message: string }
    30|export type BudgetSeedCandidate = {
    31|  seed_candidate_id: string
    32|  source_file_label?: string
    33|  source_sheet: string
    34|  source_row_or_range?: string | null
    35|  candidate_type: 'category' | 'budget_plan' | 'recurring_candidate' | 'unclean_range'
    36|  source_label?: string | null
    37|  proposed_category_id?: string | null
    38|  proposed_parent_label?: string | null
    39|  proposed_name?: string | null
    40|  proposed_period_type?: string | null
    41|  proposed_amount_text?: string | null
    42|  currency?: string | null
    43|  confidence: string
    44|  requires_review: boolean
    45|  status: string
    46|  notes?: string | null
    47|}
    48|export type BudgetSeedSummary = { category_candidate_count: number; budget_plan_candidate_count: number; recurring_candidate_count: number; unclean_range_count: number; current_budget_sheet?: string }
    49|export type BudgetStatusRow = { purpose?: string; category_id: string; category: string; category_type: string; budget_month?: string | null; budget_year?: string | null; has_budget?: boolean; actual_current_month?: string; actual_year_to_date: string; monthly_actuals?: Record<string, string>; monthly_analysis?: BudgetMonthlyAnalysis[]; actual_avg_month: string; forecast_year: string; linear_forecast?: string; forecast_avg_month: string; deviation_year: string; deviation_month: string; actual_2025_year?: string; previous_year_total?: string; previous_year_months?: Record<string, string>; deviation_previous_year?: string; has_previous_year?: boolean; data_explorer_url?: string; effective_expenses_url?: string; monthly_chart?: Array<Record<string, string>>; status: string; status_hint?: string; months_with_bookings: number; plan_names?: string[]; is_rollup?: boolean; rollup_child_count?: number }
    50|export type BudgetTransactionCandidate = { transaction_candidate_id: string; source_file_label: string; source_row_or_range?: string | null; source_type: string; transaction_date?: string | null; description: string; merchant?: string | null; amount_original?: string | null; currency_original: string; proposed_category_id?: string | null; proposed_category_name?: string | null; duplicate_of_transaction_id?: string | null; confidence: string; requires_review: boolean; status: string; notes?: string | null; classification?: string | null; review_reason?: string | null; rule_id?: string | null; rule_name?: string | null; account_source?: string | null; covered_by_source?: string | null; linked_candidate_id?: string | null }
    51|export type BudgetReviewSummary = { open_total: number; auto_categorized: number; review_needed: number; galaxus_digitec: number; migros_over_50: number; covered_by_migros: number; unclear: number; possible_duplicates: number }
    52|export type BudgetRule = { rule_id: string; merchant_contains: string; source_type?: string | null; category_id?: string | null; category_name?: string | null; target_status: string; is_active: boolean; priority?: number; confidence?: string | null; notes?: string | null }
    53|export type BudgetMerchant = { merchant_id: string; display_name: string; normalized_name: string; default_category_id?: string | null; default_category_name?: string | null; is_active: boolean; notes?: string | null; alias_count?: number; open_candidate_count?: number }
    54|export type BudgetCharts = { year: string; empty_state: boolean; cashflow_by_month: Array<{ year: string; month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; budget_vs_actual_by_category: Array<{ year: string; category_id: string; category: string; category_type: string; budget_year?: string | null; actual_year_to_date: string; forecast_year: string; status: string }>; forecast_overruns: Array<Record<string, string>> }
    55|export type CategoryAnalysis = { year: string; month?: string | null; mode: 'income' | 'expense'; total_chf: string; categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null; transactions_url: string }> }
    56|export type MonthlyComparison = { year: string; months: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; savings_rate_percent?: string; cumulative_income_chf?: string; cumulative_expense_chf?: string; transaction_count: number; top_categories: Array<{ category: string; amount_chf: string }>; top_merchants: Array<{ merchant: string; amount_chf: string }>; largest_transactions?: Array<Record<string, string>>; transactions_url: string }> }
    57|export type BudgetDataExplorer = { purpose: 'budget_data_explorer'; filters: Record<string, string>; kpis: { income_sum_chf: string; expense_sum_chf: string; net_cashflow_chf: string; transaction_count: number; average_transaction_chf: string; largest_transaction_chf: string; largest_transaction_id?: string | null; top_category: string; top_merchant: string }; transactions: BudgetTransaction[]; top_merchants: Array<{ merchant: string; transaction_count: number; sum_chf: string; average_chf: string; last_transaction_date?: string | null; top_category: string; change_vs_previous_month_chf: string }>; fixed_variable: Record<string, string>; export: { enabled: boolean; target: string; message: string } }
    58|export type IncomePlan = { plan_item_id: string; name: string; person_source: string; employer_source?: string; income_type: string; target_month?: string; cadence: string; sort_order?: number; monthly_value_chf: string; annual_value_chf: string; monthly_average_chf?: string; actual_income_year_to_date_chf?: string; forecast_income_year_chf?: string; deviation_year_chf?: string; status_label?: string; category_id: string; category_name: string; is_active: boolean; is_template?: boolean; notes?: string | null }
    59|export type IncomeSeedCandidate = { seed_candidate_id: string; candidate_type: string; proposed_name?: string | null; proposed_parent_label?: string | null; confidence: string; requires_review: boolean; status: string; source_sheet?: string; source_row_or_range?: string | null }
    60|export type BudgetOverview = { current_month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string; review_count: number; uncategorized_count: number; pending_seed_candidate_count?: number; pending_transaction_candidate_count?: number; planned_income_chf?: string; planned_expense_chf?: string; planned_net_chf?: string; actual_income_chf?: string; actual_expense_chf: string; actual_net_chf?: string; actuals_label?: string; open_categories_without_actuals?: number; planned_budget_chf: string; remaining_budget_chf: string; budget_status: string; budget_categories: BudgetStatusRow[]; uncategorized_transactions: BudgetTransaction[]; top_categories: Array<{ category_id?: string; category_name: string; amount_chf: string; transaction_count?: number }>; recent_transactions: BudgetTransaction[] }
    61|export type BudgetDashboardCockpit = { purpose: 'monthly_cockpit'; month: string; year: string; chart_library: string; kpis: { income_current_month_chf: string; expense_current_month_chf: string; net_cashflow_current_month_chf: string; budget_consumption_percent: string; savings_rate_percent: string; open_review_transactions: number; fixed_costs_month_chf?: string; fixed_cost_quote_percent?: string; open_fixed_cost_candidate_count?: number; missing_fixed_cost_payment_count?: number }; monthly_cashflow: { income_chf: string; expense_chf: string; net_cashflow_chf: string }; top_expense_categories: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; transaction_count: number; color?: string | null; icon?: string | null }>; critical_categories: BudgetStatusRow[]; recent_confirmed_expenses: BudgetTransaction[]; open_todos: { review_candidates: number; categories_without_budget: number; unconfirmed_transactions: number } }
    62|export type BudgetVsActualAnalysis = { purpose: 'plan_vs_reality_analysis'; year: string; chart_library: string; empty_state: boolean; budget_vs_actual_by_category: BudgetStatusRow[]; top_overruns: Array<Record<string, string>>; top_under_budget: Array<Record<string, string>>; cashflow_by_month: Array<{ month: string; income_chf: string; expense_chf: string; net_cashflow_chf: string }>; category_donut: Array<{ category_id: string; category: string; amount_chf: string; share_percent: string; color?: string | null }>; trend_by_month: Array<{ month: string; cumulative_income_chf: string; cumulative_expense_chf: string; cumulative_net_chf: string }> }
    63|
    64|export const getBudgetDashboardCockpit = (month = '2026-05') => apiGet<BudgetDashboardCockpit>(`/api/budget/dashboard/cockpit?month=${month}`)
    65|export const getBudgetPlanningMatrix = (year = '2026', currentMonth = '') => apiGet<BudgetPlanningMatrix>(`/api/budget/planning/matrix?year=${year}${currentMonth ? `&current_month=${encodeURIComponent(currentMonth)}` : ''}`)
    66|export const getBudgetPlanningAudit = (categoryId: string) => apiGet<BudgetPlanningAudit>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/audit`)
    67|export const previewBudgetPlanningCategory = (categoryId: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/preview`, body)
    68|export const confirmBudgetPlanningCategory = (categoryId: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/planning/categories/${encodeURIComponent(categoryId)}/confirm`, body)
    69|export const previewBudgetPlanningBatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/batch/preview', body)
    70|export const confirmBudgetPlanningBatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/batch/confirm', body)
    71|export const previewBudgetPlanningExcelTemplate = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/planning/excel-template/preview', body)
    72|export const getBudgetRecurringDashboard = () => apiGet<BudgetRecurringDashboard>('/api/budget/recurring')
    73|export const getBudgetMonthlyImportDashboard = () => apiGet<BudgetMonthlyImportDashboard>('/api/budget/monthly-import/dashboard')
    74|export const getGroceryOptimizerDashboard = () => apiGet<GroceryDashboard>('/api/budget/grocery-optimizer/dashboard')
    75|export const getGroceryProviderStrategy = () => apiGet<Record<string, unknown>>('/api/budget/grocery-optimizer/provider-strategy')
    76|export const getGroceryRappnLink = (productName: string) => apiGet<Record<string, unknown>>(`/api/budget/grocery-optimizer/rappn-link?product_name=${encodeURIComponent(productName)}`)
    77|export const searchOpenPricesSpike = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/open-prices-spike', body)
    78|export const createGroceryDemoReceipt = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/demo-receipt', body)
    79|export const addGroceryProductMatch = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/matches', body)
    80|export const searchGroceryOnlinePrices = (body: unknown) => apiPost<GroceryOnlinePriceSearch>('/api/budget/grocery-optimizer/search-prices', body)
    81|export const acceptGroceryProductMatch = (body: { match_id: string; save_as_preferred?: boolean; user_note?: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/matches/${encodeURIComponent(body.match_id)}/accept`, body)
    82|export const rejectGroceryProductMatch = (body: { match_id: string; user_note?: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/matches/${encodeURIComponent(body.match_id)}/reject`, body)
    83|export const previewManualGroceryMapping = (body: unknown) => apiPost<GroceryManualMappingPreview>('/api/budget/grocery-optimizer/manual-mapping/preview', body)
    84|export const confirmManualGroceryMapping = (body: GroceryManualMappingPreview) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/manual-mapping/confirm', body)
    85|export const saveManualGroceryMapping = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/manual-mapping', body)
    86|export const refreshGroceryMappingPrice = (body: { mapping_id: string }) => apiPost<Record<string, unknown>>(`/api/budget/grocery-optimizer/mappings/${encodeURIComponent(body.mapping_id)}/refresh-price`, {})
    87|export const runGroceryOptimization = (body: unknown) => apiPost<GroceryRun>('/api/budget/grocery-optimizer/optimize', body)
    88|export const generateGroceryOptimizationReport = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/grocery-optimizer/report', body)
    89|export const getBudgetImportHistory = () => apiGet<BudgetImportSession[]>('/api/budget/monthly-import/history')
    90|export const getBudgetImportSessions = () => apiGet<BudgetImportSession[]>('/api/budget/import/sessions')
    91|export const previewBudgetUploadImport = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/preview', body)
    92|export const confirmBudgetUploadImportCandidates = (body: unknown) => apiPost<BudgetUploadImportResult>('/api/budget/import/upload/confirm-candidates', body)
    93|export const scanBudgetMonthlyImportDrive = (body: unknown = {}) => apiPost<Record<string, unknown>>('/api/budget/monthly-import/drive-scan', body)
    94|export const dryRunBudgetMonthlyImport = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/dry-run', body)
    95|export const confirmBudgetMonthlyImportCandidates = (body: unknown) => apiPost<BudgetMonthlyImportResult>('/api/budget/monthly-import/confirm-candidates', body)
    96|export const getBudgetRuleSuggestions = () => apiGet<BudgetRuleSuggestion[]>('/api/budget/rules/suggestions')
    97|export const applyBudgetRuleSuggestion = (id: string, applyToOpen = true) => apiPost<Record<string, unknown>>(`/api/budget/rules/suggestions/${encodeURIComponent(id)}/apply`, { apply_to_open: applyToOpen })
    98|export const rejectBudgetRuleSuggestion = (id: string, later = false) => apiPost<Record<string, unknown>>(`/api/budget/rules/suggestions/${encodeURIComponent(id)}/reject`, { later })
    99|export const detectBudgetRecurringCandidates = () => apiPost<Record<string, unknown>>('/api/budget/recurring/detect', {})
   100|export const previewBudgetRecurringCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/recurring/candidates/${encodeURIComponent(id)}/preview`, body)
   101|export const confirmBudgetRecurringCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/recurring/candidates/${encodeURIComponent(id)}/confirm`, body)
   102|export const previewManualRecurringPayment = (body: unknown) => apiPost<BudgetPreview>('/api/budget/recurring/manual/preview', body)
   103|export const confirmManualRecurringPayment = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/recurring/manual/confirm', body)
   104|export const archiveRecurringPayment = (id: string) => apiPost<BudgetConfirm>(`/api/budget/recurring/${encodeURIComponent(id)}/archive`, {})
   105|export const getBudgetOverview = (month = '2026-01') => apiGet<BudgetOverview>(`/api/budget/overview?month=${month}`)
   106|export const getBudgetStatusByCategory = (year = '2026') => apiGet<BudgetStatusRow[]>(`/api/budget/status/categories?year=${year}`)
   107|export const getCategoryMonthDetail = (categoryId: string, month: string) => apiGet<BudgetMonthDetail>(`/api/budget/status/categories/${encodeURIComponent(categoryId)}/months/${encodeURIComponent(month)}`)
   108|export const getBudgetCharts = (year = '2026') => apiGet<BudgetCharts>(`/api/budget/charts?year=${year}`)
   109|export const getBudgetVsActualAnalysis = (year = '2026') => apiGet<BudgetVsActualAnalysis>(`/api/budget/analysis/budget-vs-actual?year=${year}`)
   110|export const getCategoryAnalysis = (year = '2026', txType: 'income' | 'expense' = 'expense', month = '') => {
   111|  const params = new URLSearchParams({ year, tx_type: txType })
   112|  if (month) params.set('month', month)
   113|  return apiGet<CategoryAnalysis>(`/api/budget/analysis/categories?${params.toString()}`)
   114|}
   115|export const getMonthlyComparison = (year = '2026') => apiGet<MonthlyComparison>(`/api/budget/analysis/monthly-comparison?year=${year}`)
   116|export const getBudgetDataExplorer = (filters: Record<string, string> = {}) => {
   117|  const params = new URLSearchParams()
   118|  Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value) })
   119|  return apiGet<BudgetDataExplorer>(`/api/budget/analysis/data-explorer?${params.toString()}`)
   120|}
   121|export const getIncomeStatus = (year = '2026') => apiGet<Record<string, unknown>>(`/api/budget/income/status?year=${year}`)
   122|export const getIncomePlans = (year = '2026') => apiGet<IncomePlan[]>(`/api/budget/income/plans?year=${year}`)
   123|export const getIncomeSeedCandidates = (year = '2026') => apiGet<IncomeSeedCandidate[]>(`/api/budget/income/seed-candidates?year=${year}`)
   124|export const previewIncomePlan = (body: unknown) => apiPost<BudgetPreview>('/api/budget/income/plans/preview', body)
   125|export const confirmIncomePlan = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/income/plans/confirm', body)
   126|export const previewIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/preview', { year })
   127|export const confirmIncomeTemplates = (year = '2026') => apiPost<Record<string, unknown>>('/api/budget/income/templates/confirm', { year })
   128|export const previewIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetPreview & { requires_explicit_confirm?: boolean }>(`/api/budget/income/plans/${id}/update/preview`, body)
   129|export const confirmIncomePlanUpdate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/update/confirm`, body)
   130|export const reorderIncomePlans = (plan_item_ids: string[], year = '2026') => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/income/plans/reorder', { plan_item_ids, year })
   131|export const archiveIncomePlan = (id: string) => apiPost<BudgetConfirm>(`/api/budget/income/plans/${id}/archive`, {})
   132|export const getBudgetTransactions = (query = '') => apiGet<BudgetTransaction[]>(`/api/budget/transactions${query}`)
   133|export const getBudgetTransfers = (query = '') => apiGet<BudgetTransfer[]>(`/api/budget/transfers${query}`)
   134|export const getBudgetAccounts = () => apiGet<BudgetAccount[]>('/api/budget/accounts')
   135|export const getBudgetCategories = () => apiGet<BudgetCategory[]>('/api/budget/categories')
   136|export const getBudgetTags = () => apiGet<BudgetTag[]>('/api/budget/tags')
   137|export const previewBudgetTransaction = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transactions/preview', body)
   138|export const confirmBudgetTransaction = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transactions/confirm', body)
   139|export const updateBudgetTransaction = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transactions/${id}/update`, body)
   140|export const previewBudgetTransfer = (body: unknown) => apiPost<BudgetPreview>('/api/budget/transfers/preview', body)
   141|export const confirmBudgetTransfer = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/transfers/confirm', body)
   142|export const previewBudgetAccount = (body: unknown) => apiPost<BudgetPreview>('/api/budget/accounts/preview', body)
   143|export const confirmBudgetAccount = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/accounts/confirm', body)
   144|export const archiveBudgetAccount = (id: string) => apiPost<BudgetConfirm>(`/api/budget/accounts/${id}/archive`, {})
   145|export const previewBudgetCategory = (body: unknown) => apiPost<BudgetPreview>('/api/budget/categories/preview', body)
   146|export const confirmBudgetCategory = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/categories/confirm', body)
   147|export const previewBudgetCategoryCleanup = () => apiPost<Record<string, unknown> & { preview_id: string; summary: string; target_expense_categories: string[]; auto_mappings: Array<Record<string, unknown>>; review_required: Array<Record<string, unknown>>; empty_categories: Array<Record<string, unknown>>; payload: Record<string, unknown>; warnings: string[] }>('/api/budget/categories/cleanup/preview', {})
   148|export const confirmBudgetCategoryCleanup = (body: unknown) => apiPost<Record<string, unknown> & { audit_id: string }>('/api/budget/categories/cleanup/confirm', body)
   149|export const reorderBudgetCategories = (category_ids: string[], category_type: 'expense' | 'income' = 'expense') => apiPost<BudgetConfirm & { updated_count: number }>('/api/budget/categories/reorder', { category_ids, category_type })
   150|export const updateBudgetCategory = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/update`, body)
   151|export const archiveBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/archive`, {})
   152|export const previewBudgetTag = (body: unknown) => apiPost<BudgetPreview>('/api/budget/tags/preview', body)
   153|export const confirmBudgetTag = (body: unknown) => apiPost<BudgetConfirm>('/api/budget/tags/confirm', body)
   154|export const archiveBudgetTag = (id: string) => apiPost<BudgetConfirm>(`/api/budget/tags/${id}/archive`, {})
   155|export const getBudgetSeedCandidates = () => apiGet<BudgetSeedCandidate[]>('/api/budget/seed-candidates')
   156|export const generateBudgetSeedCandidates = (body: unknown) => apiPost<BudgetSeedSummary>('/api/budget/seed-candidates/generate', body)
   157|export const previewBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/seed-candidates/${id}/preview`, body)
   158|export const confirmBudgetSeedCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/confirm`, body)
   159|export const ignoreBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/ignore`, { note })
   160|export const reopenBudgetSeedCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/reopen`, { note })
   161|export const setBudgetSeedCandidateStatus = (id: string, status: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/status`, { status, note })
   162|export const convertBudgetUncleanRange = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/seed-candidates/${id}/convert-unclean`, body)
   163|export const previewClearBudgetCategoriesTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; categories: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/categories/preview-clear-takeover', {})
   164|export const confirmClearBudgetCategoriesTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/categories/confirm-clear-takeover', {})
   165|export const previewBudgetPlanTakeover = () => apiPost<{ preview_id: string; summary: string; count: number; budget_plans: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/seed-candidates/budget-plans/preview-takeover', {})
   166|export const confirmBudgetPlanTakeover = () => apiPost<BudgetConfirm & { confirmed_count: number; skipped_count: number }>('/api/budget/seed-candidates/budget-plans/confirm-takeover', {})
   167|export const getBudgetTransactionCandidates = (status = '', tab = '', filters: Record<string, string> = {}) => {
   168|  const params = new URLSearchParams()
   169|  if (status) params.set('status', status)
   170|  if (tab) params.set('tab', tab)
   171|  Object.entries(filters).forEach(([k, v]) => { if (v) params.set(k, v) })
   172|  const qs = params.toString()
   173|  return apiGet<BudgetTransactionCandidate[]>(`/api/budget/transaction-candidates${qs ? `?${qs}` : ''}`)
   174|}
   175|export const getBudgetTransactionCandidateSummary = (budget_year = '2026') => apiGet<BudgetReviewSummary>(`/api/budget/transaction-candidates/summary?budget_year=${budget_year}`)
   176|export const generateBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, number>>('/api/budget/transaction-candidates/generate', body)
   177|export const previewBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetPreview>(`/api/budget/transaction-candidates/${id}/preview`, body)
   178|export const confirmBudgetTransactionCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/confirm`, body)
   179|export const ignoreBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/ignore`, { note })
   180|export const reopenBudgetTransactionCandidate = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/reopen`, { note })
   181|export const updateBudgetTransactionCandidateCategory = (id: string, category_id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/category`, { category_id, note })
   182|export const bulkUpdateBudgetTransactionCandidateCategory = (candidate_ids: string[], category_id: string, note = '') => apiPost<{ updated_count: number; skipped_count: number }>('/api/budget/transaction-candidates/bulk-category', { candidate_ids, category_id, note })
   183|export const splitBudgetTransactionCandidate = (id: string, splits: unknown[]) => apiPost<BudgetConfirm & { split_count: number }>(`/api/budget/transaction-candidates/${id}/split`, { splits })
   184|export const markBudgetTransactionCandidateTransfer = (id: string, note = '') => apiPost<BudgetConfirm>(`/api/budget/transaction-candidates/${id}/transfer`, { note })
   185|export const markBudgetTransactionCandidateType = (id: string, candidate_type: 'transfer' | 'investment_transfer' | 'credit_card_payment', note = '') => apiPost<BudgetConfirm & { classification: string }>(`/api/budget/transaction-candidates/${id}/type`, { candidate_type, note })
   186|export const getBudgetImportProfiles = () => apiGet<Array<Record<string, unknown>>>('/api/budget/import-profiles')
   187|export const dryRunBudgetImportProfile = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/dry-run`, body)
   188|export const createBudgetImportCandidates = (profile: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/import-profiles/${profile}/candidates`, body)
   189|export const previewSafeBudgetTransactionCandidates = () => apiPost<{ preview_id: string; summary: string; count: number; groups: Array<Record<string, unknown>>; warnings: string[] }>('/api/budget/transaction-candidates/batch-confirm-safe/preview', {})
   190|export const batchConfirmSafeBudgetTransactionCandidates = (account_id: string, candidate_ids: string[] = []) => apiPost<{ confirmed_count: number; skipped_count: number }>('/api/budget/transaction-candidates/batch-confirm-safe', { account_id, candidate_ids })
   191|export const deleteEmptyBudgetCategory = (id: string) => apiPost<BudgetConfirm>(`/api/budget/categories/${id}/delete-empty`, {})
   192|export const previewBudgetCategoryMerges = () => apiPost<{ preview_id: string; summary: string; suggestions: Array<{ target_category_id: string; target_name: string; source_category_ids: string[]; source_names: string[] }>; warnings: string[] }>('/api/budget/categories/merge/preview', {})
   193|export const confirmBudgetCategoryMerge = (body: unknown) => apiPost<BudgetConfirm & { moved_transactions: number; moved_budget_plans: number; moved_children: number }>('/api/budget/categories/merge/confirm', body)
   194|export const previewBatchBudgetTransactionCandidates = (candidate_ids: string[], account_id: string, budget_year = '2026') => apiPost<{ preview_id: string; count: number; categories: Record<string, number>; sources?: Record<string, number>; possible_duplicates?: number; blockers?: Record<string, number>; conflicts: Array<Record<string, unknown>>; can_confirm?: boolean; requires_explicit_confirm: boolean }>('/api/budget/transaction-candidates/batch/preview', { candidate_ids, account_id, budget_year })
   195|export const batchBudgetTransactionCandidates = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/batch', body)
   196|export const confirmBudgetTransactionCandidateSplit = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/transaction-candidates/${id}/split/confirm`, body)
   197|export const createReviewCategoryForCandidate = (id: string, body: unknown) => apiPost<BudgetConfirm & { category_id: string }>(`/api/budget/transaction-candidates/${id}/category/create`, body)
   198|export const getBudgetRules = () => apiGet<BudgetRule[]>('/api/budget/rules')
   199|export const createBudgetRule = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/rules', body)
   200|export const updateBudgetRule = (id: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/update`, body)
   201|export const prioritizeBudgetRule = (id: string, priority: number) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/priority`, { priority })
   202|export const deactivateBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/deactivate`, {})
   203|export const testBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/test`, {})
   204|export const applyBudgetRule = (id: string) => apiPost<Record<string, unknown>>(`/api/budget/rules/${id}/apply`, {})
   205|export const getBudgetMerchants = () => apiGet<BudgetMerchant[]>('/api/budget/merchants')
   206|export const createBudgetMerchant = (body: unknown) => apiPost<Record<string, unknown>>('/api/budget/merchants', body)
   207|export const createBudgetMerchantAlias = (merchantId: string, body: unknown) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/aliases`, body)
   208|export const updateBudgetMerchantCategory = (merchantId: string, categoryId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/category`, { category_id: categoryId })
   209|export const applyBudgetMerchant = (merchantId: string) => apiPost<Record<string, unknown>>(`/api/budget/merchants/${merchantId}/apply`, {})
   210|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 })
   211|export const detectBudgetDuplicateCandidates = () => apiPost<Record<string, unknown>>('/api/budget/transaction-candidates/detect-duplicates', {})
   212|export const reclassifyBankIncomeCandidates = (budget_year = '2026') => apiPost<Record<string, number>>('/api/budget/transaction-candidates/bank-income/reclassify', { budget_year })
   213|


     1|import { defineStore } from 'pinia'
     2|import { confirmBudgetTransactionCandidate, getBudgetTransactionCandidates, getBudgetTransactionCandidateSummary, type BudgetReviewSummary, type BudgetTransactionCandidate } from '@/api/budget'
     3|
     4|export const useBudgetReviewStore = defineStore('budgetReview', {
     5|  state: () => ({ candidates: [] as BudgetTransactionCandidate[], summary: null as BudgetReviewSummary | null, loading: false, error: '', lastFilters: { budget_year: '2026' } as Record<string, string>, lastStatus: '', lastTab: '', confirmAccountId: '' }),
     6|  actions: {
     7|    async load(filters?: Record<string, string>, status?: string, tab?: string) {
     8|      filters = filters || this.lastFilters; status = status ?? this.lastStatus; tab = tab ?? this.lastTab
     9|      this.loading = true; this.error = ''; this.lastFilters = { ...filters }; this.lastStatus = status; this.lastTab = tab
    10|      try {
    11|        this.candidates = await getBudgetTransactionCandidates(status, tab, filters)
    12|        this.summary = await getBudgetTransactionCandidateSummary(filters.budget_year || filters.year || '2026')
    13|      } catch (e) { this.error = e instanceof Error ? e.message : 'Buchungen prüfen nicht verfügbar' }
    14|      finally { this.loading = false }
    15|    },
    16|    async confirmCandidate(id: string, body: unknown) { const result = await confirmBudgetTransactionCandidate(id, body); await this.load(this.lastFilters, this.lastStatus, this.lastTab); return result },
    17|    async refresh() { await this.load(this.lastFilters, this.lastStatus, this.lastTab) },
    18|  },
    19|})
    20|


Explanation:
**Exploring routes further**

It seems like I need to look into routes in more detail. There’s a lot of information to consider and I want to make sure I gather comprehensive options. I’m curious about different routes available, their lengths, and any notable characteristics. Let's break down the specifics to help pin down the best path forward. I'll focus on understanding the user’s needs regarding routes, so they can find the most suitable option!


   520|
   521|@router.post("/accounts/{account_id}/archive")
   522|def accounts_archive(account_id: str, conn: Connection = Depends(get_db)):
   523|    return archive_budget_account(conn, account_id)
   524|
   525|
   526|@router.get("/categories")
   527|def categories(include_archived: bool = False, grouped: bool = False, conn: Connection = Depends(get_db)):
   528|    if grouped:
   529|        return list_category_tree_grouped(conn, include_archived=include_archived)
   530|    return list_categories(conn, include_archived=include_archived)
   531|
   532|
   533|@router.get("/categories/tree")
   534|def category_tree(include_archived: bool = False, conn: Connection = Depends(get_db)):
   535|    return list_category_tree_grouped(conn, include_archived=include_archived)
   536|
   537|
   538|@router.get("/tags")
   539|def tags(include_archived: bool = False, conn: Connection = Depends(get_db)):
   540|    return list_tags(conn, include_archived=include_archived)
   541|
   542|
   543|@router.post("/tags/preview")
   544|def tags_preview(payload: dict, conn: Connection = Depends(get_db)):
   545|    return create_tag_preview(conn, payload)
   546|
   547|
   548|@router.post("/tags/confirm")
   549|def tags_confirm(payload: dict, conn: Connection = Depends(get_db)):
   550|    return confirm_create_tag(conn, payload)
   551|
   552|
   553|@router.post("/tags/{tag_id}/archive")
   554|def tags_archive(tag_id: str, conn: Connection = Depends(get_db)):
   555|    return archive_tag(conn, tag_id)
   556|
   557|
   558|@router.get("/plans")
   559|def plans(month: str | None = None, include_archived: bool = True, conn: Connection = Depends(get_db)):
   560|    return list_budget_plan_items(conn, month=month, include_archived=include_archived)
   561|
   562|
   563|@router.post("/plans/preview")
   564|def plans_preview(payload: dict, conn: Connection = Depends(get_db)):
   565|    return create_budget_plan_item_preview(conn, payload)
   566|
   567|
   568|@router.post("/plans/confirm")
   569|def plans_confirm(payload: dict, conn: Connection = Depends(get_db)):
   570|    return confirm_budget_plan_item(conn, payload)
   571|
   572|
   573|@router.get("/seed-candidates")
   574|def seed_candidates(status: str | None = None, candidate_type: str | None = None, conn: Connection = Depends(get_db)):
   575|    return list_seed_candidates(conn, status=status, candidate_type=candidate_type)
   576|
   577|
   578|@router.get("/import-profiles")
   579|def budget_import_profiles():
   580|    return import_profile_specs()
   581|
   582|
   583|@router.post("/import-profiles/{profile_name}/dry-run")
   584|def budget_import_profile_dry_run(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   585|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=True, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   586|
   587|
   588|@router.post("/import-profiles/{profile_name}/candidates")
   589|def budget_import_profile_candidates(profile_name: str, payload: dict, conn: Connection = Depends(get_db)):
   590|    return import_budget_csv_text(conn, profile_name, str(payload.get("csv_text") or ""), source_file_label=payload.get("source_file_label") or f"{profile_name}.csv", dry_run=False, default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   591|
   592|
   593|@router.post("/import/upload/preview")
   594|def budget_import_upload_preview(payload: dict, conn: Connection = Depends(get_db)):
   595|    return preview_uploaded_budget_import(conn, filename=str(payload.get("filename") or "budget.csv"), csv_text=str(payload.get("csv_text") or ""), profile=payload.get("profile") or "auto", default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   596|
   597|
   598|@router.post("/import/upload/confirm-candidates")
   599|def budget_import_upload_confirm_candidates(payload: dict, conn: Connection = Depends(get_db)):
   600|    return confirm_uploaded_budget_import_candidates(conn, str(payload.get("preview_id") or payload.get("import_session_id") or ""), default_category_id=payload.get("default_category_id"), income_category_id=payload.get("income_category_id"))
   601|
   602|
   603|@router.get("/import/sessions")
   604|def budget_import_sessions(limit: int = 30, conn: Connection = Depends(get_db)):
   605|    return list_budget_import_sessions(conn, limit=limit)
   606|
   607|
   608|@router.get("/import/sessions/{import_session_id}")
   609|def budget_import_session(import_session_id: str, conn: Connection = Depends(get_db)):
   610|    return get_budget_import_session(conn, import_session_id)
   611|
   612|
   613|@router.get("/transaction-candidates")
   614|def transaction_candidates(status: str | None = None, tab: str | None = None, source_type: str | None = None, import_session_id: str | None = None, category_id: str | None = None, merchant: str | None = None, rule: str | None = None, min_confidence: str | None = None, date_from: str | None = None, date_to: str | None = None, amount_min: str | None = None, amount_max: str | None = None, sort_by: str = "created_at", sort_dir: str = "desc", budget_year: str = "2026", include_reference: bool = False, conn: Connection = Depends(get_db)):
   615|    return list_transaction_candidates(conn, status=status, tab=tab, source_type=source_type, import_session_id=import_session_id, category_id=category_id, merchant=merchant, rule=rule, min_confidence=min_confidence, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max, sort_by=sort_by, sort_dir=sort_dir, budget_year=budget_year, include_reference=include_reference)
   616|
   617|
   618|@router.get("/transaction-candidates/summary")
   619|def transaction_candidates_summary(budget_year: str = "2026", conn: Connection = Depends(get_db)):
   620|    return review_candidate_summary(conn, budget_year=budget_year)
   621|
   622|
   623|@router.post("/transaction-candidates/generate")
   624|def transaction_candidates_generate(payload: dict, conn: Connection = Depends(get_db)):
   625|    source_type = payload.get("source_type") or "csv_seed"
   626|    rows = payload.get("rows") or []
   627|    if source_type == "credit_card_csv":
   628|        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))
   629|    if source_type == "migros_csv":
   630|        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"))
   631|    if source_type == "bank_csv":
   632|        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"))
   633|    if payload.get("csv_text"):
   634|        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)
   635|    return seed_transaction_candidates_from_rows(conn, rows, source_file_label=payload.get("source_file_label") or "Budget Import", source_type=source_type)
   636|
   637|
   638|@router.post("/transaction-candidates/bank-income/reclassify")
   639|def transaction_candidates_bank_income_reclassify(payload: dict | None = None, conn: Connection = Depends(get_db)):
   640|    return reclassify_bank_income_candidates(conn, budget_year=str((payload or {}).get("budget_year") or "2026"))
   641|
   642|
   643|@router.post("/transaction-candidates/migros-2026/preview")
   644|def transaction_candidates_migros_2026_preview(payload: dict | None = None, conn: Connection = Depends(get_db)):
   645|    return preview_migros_receipts_2026(conn, year=str((payload or {}).get("year") or "2026"))
   646|
   647|
   648|@router.post("/transaction-candidates/migros-2026/confirm")
   649|def transaction_candidates_migros_2026_confirm(payload: dict, conn: Connection = Depends(get_db)):
   650|    return confirm_migros_receipts_2026(conn, account_id=str(payload.get("account_id") or ""), year=str(payload.get("year") or "2026"))
   651|
   652|
   653|@router.post("/transaction-candidates/{candidate_id}/preview")
   654|def transaction_candidates_preview(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   655|    return preview_transaction_candidate(conn, candidate_id, payload or {})
   656|
   657|
   658|@router.post("/transaction-candidates/{candidate_id}/confirm")
   659|def transaction_candidates_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   660|    return confirm_transaction_candidate(conn, candidate_id, payload)
   661|
   662|
   663|@router.post("/transaction-candidates/{candidate_id}/ignore")
   664|def transaction_candidates_ignore(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   665|    return ignore_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   666|
   667|
   668|@router.post("/transaction-candidates/{candidate_id}/reopen")
   669|def transaction_candidates_reopen(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   670|    return reopen_transaction_candidate(conn, candidate_id, (payload or {}).get("note"))
   671|
   672|
   673|@router.post("/transaction-candidates/{candidate_id}/category")
   674|def transaction_candidates_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   675|    return update_transaction_candidate_category(conn, candidate_id, payload.get("category_id"), payload.get("note"))
   676|
   677|
   678|@router.post("/transaction-candidates/bulk-category")
   679|def transaction_candidates_bulk_category(payload: dict, conn: Connection = Depends(get_db)):
   680|    category_id = str(payload.get("category_id") or "")
   681|    return bulk_update_transaction_candidate_category(conn, candidate_ids=payload.get("candidate_ids") or [], category_id=category_id, note=payload.get("note"))
   682|
   683|
   684|@router.post("/transaction-candidates/{candidate_id}/split")
   685|def transaction_candidates_split(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   686|    return create_candidate_split(conn, candidate_id, payload.get("splits") or [])
   687|
   688|
   689|@router.post("/transaction-candidates/{candidate_id}/split/confirm")
   690|def transaction_candidates_split_confirm(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   691|    return confirm_candidate_split(conn, candidate_id, payload)
   692|
   693|
   694|@router.post("/transaction-candidates/{candidate_id}/category/create")
   695|def transaction_candidates_create_category(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   696|    return create_review_category_for_candidate(conn, candidate_id, payload)
   697|
   698|
   699|@router.post("/transaction-candidates/{candidate_id}/transfer")
   700|def transaction_candidates_transfer(candidate_id: str, payload: dict | None = None, conn: Connection = Depends(get_db)):
   701|    return mark_candidate_transfer(conn, candidate_id, (payload or {}).get("note"))
   702|
   703|
   704|@router.post("/transaction-candidates/{candidate_id}/type")
   705|def transaction_candidates_type(candidate_id: str, payload: dict, conn: Connection = Depends(get_db)):
   706|    return mark_candidate_type(conn, candidate_id, str(payload.get("candidate_type") or ""), (payload or {}).get("note"))
   707|
   708|
   709|@router.post("/transaction-candidates/batch-confirm-safe/preview")
   710|def transaction_candidates_batch_confirm_safe_preview(conn: Connection = Depends(get_db)):
   711|    return preview_safe_auto_candidates(conn)
   712|
   713|
   714|@router.post("/transaction-candidates/batch/preview")
   715|def transaction_candidates_batch_preview(payload: dict, conn: Connection = Depends(get_db)):
   716|    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"))
   717|
   718|
   719|@router.post("/transaction-candidates/batch")
   720|def transaction_candidates_batch(payload: dict, conn: Connection = Depends(get_db)):
   721|    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"))
   722|
   723|
   724|@router.post("/transaction-candidates/archive-reference-year")
   725|def transaction_candidates_archive_reference(payload: dict | None = None, conn: Connection = Depends(get_db)):
   726|    payload = payload or {}
   727|    return archive_reference_year_candidates(conn, active_year=str(payload.get("active_year") or "2026"), reference_year=str(payload.get("reference_year") or "2025"))
   728|
   729|
   730|@router.post("/transaction-candidates/batch-confirm-safe")
   731|def transaction_candidates_batch_confirm_safe(payload: dict, conn: Connection = Depends(get_db)):
   732|    account_id = str(payload.get("account_id") or "")
   733|    return batch_confirm_safe_candidates(conn, account_id=account_id, candidate_ids=payload.get("candidate_ids") or None)
   734|
   735|
   736|@router.post("/transaction-candidates/detect-duplicates")
   737|def transaction_candidates_detect_duplicates(conn: Connection = Depends(get_db)):
   738|    return detect_duplicate_candidates(conn)
   739|
   740|
   741|@router.get("/merchants")
   742|def merchants(include_inactive: bool = False, conn: Connection = Depends(get_db)):
   743|    return list_merchants(conn, include_inactive=include_inactive)
   744|
   745|
   746|@router.post("/merchants")
   747|def merchants_create(payload: dict, conn: Connection = Depends(get_db)):
   748|    return create_merchant(conn, payload)
   749|
   750|
   751|@router.post("/merchants/{merchant_id}/aliases")
   752|def merchants_create_alias(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   753|    return create_merchant_alias(conn, merchant_id, payload)
   754|
   755|
   756|@router.post("/merchants/{merchant_id}/category")
   757|def merchants_update_category(merchant_id: str, payload: dict, conn: Connection = Depends(get_db)):
   758|    return update_merchant_category(conn, merchant_id, payload.get("category_id"))
   759|
   760|
   761|@router.post("/merchants/{merchant_id}/apply")
   762|def merchants_apply(merchant_id: str, conn: Connection = Depends(get_db)):
   763|    return apply_merchant_to_open_candidates(conn, merchant_id)
   764|
   765|
   766|@router.post("/merchants/merge")
   767|def merchants_merge(payload: dict, conn: Connection = Depends(get_db)):
   768|    return merge_merchants(conn, str(payload.get("source_merchant_id") or ""), str(payload.get("target_merchant_id") or ""))
   769|
   770|

Use offset=770 to continue reading (showing 520-769 of 958 lines)


Explanation:
**Considering testing needs**

I realize I need to focus on tests now. I could write a backend hotfix test to ensure things work smoothly. It might also be necessary to check if there are any existing frontend tests that need updates or additions. Making sure everything is well-tested will definitely save headaches down the line! I just have to prioritize what tests are most critical to write first.


     1|import { mount, flushPromises } from '@vue/test-utils'
     2|import { beforeEach, describe, expect, it, vi } from 'vitest'
     3|import BudgetImportReviewPage from './BudgetImportReviewPage.vue'
     4|import { getBudgetAccounts, getBudgetCategories, getBudgetTransactionCandidateSummary, getBudgetTransactionCandidates, markBudgetTransactionCandidateType, batchBudgetTransactionCandidates, previewBatchBudgetTransactionCandidates } from '@/api/budget'
     5|
     6|vi.mock('primevue/usetoast', () => ({ useToast: () => ({ add: vi.fn() }) }))
     7|vi.mock('primevue/useconfirm', () => ({ useConfirm: () => ({ require: ({ accept }: { accept: () => void }) => accept() }) }))
     8|vi.mock('@/api/budget', () => ({
     9|  getBudgetAccounts: vi.fn(),
    10|  getBudgetCategories: vi.fn(),
    11|  getBudgetTransactionCandidateSummary: vi.fn(),
    12|  getBudgetTransactionCandidates: vi.fn(),
    13|  updateBudgetTransactionCandidateCategory: vi.fn(),
    14|  previewBatchBudgetTransactionCandidates: vi.fn(),
    15|  batchBudgetTransactionCandidates: vi.fn(),
    16|  confirmBudgetTransactionCandidate: vi.fn(),
    17|  ignoreBudgetTransactionCandidate: vi.fn(),
    18|  confirmBudgetTransactionCandidateSplit: vi.fn(),
    19|  createReviewCategoryForCandidate: vi.fn(),
    20|  markBudgetTransactionCandidateType: vi.fn()
    21|}))
    22|
    23|beforeEach(() => {
    24|  vi.clearAllMocks()
    25|  vi.mocked(getBudgetAccounts).mockResolvedValue([{ budget_account_id: 'acc1', name: 'Haushalt', account_type: 'checking', currency: 'CHF', is_active: true }] as any)
    26|  vi.mocked(getBudgetCategories).mockResolvedValue([{ category_id: 'cat1', name: 'Essen & Haushalt', category_type: 'expense', is_active: true, children: [] }, { category_id: 'cat_media', name: 'Elektronische Medien', category_type: 'expense', is_active: true, children: [] }] as any)
    27|  vi.mocked(getBudgetTransactionCandidateSummary).mockResolvedValue({ open_total: 2, auto_categorized: 0, review_needed: 1, galaxus_digitec: 0, migros_over_50: 0, covered_by_migros: 0, unclear: 0, possible_duplicates: 1 } as any)
    28|  vi.mocked(getBudgetTransactionCandidates).mockResolvedValue([
    29|    { transaction_candidate_id: 'cand1', source_file_label: 'visa.csv', source_type: 'credit_card_csv', transaction_date: '2026-03-02', description: 'APPLE.COM/BILL', merchant: 'Apple', amount_original: '10.00', currency_original: 'CHF', proposed_category_id: 'cat_media', proposed_category_name: 'Elektronische Medien', confidence: '0.90', requires_review: false, status: 'pending', classification: 'subscription_auto', review_reason: 'user_category_override' },
    30|    { transaction_candidate_id: 'cand2', source_file_label: 'visa.csv', source_type: 'credit_card_csv', transaction_date: '2026-03-03', description: 'GOOGLE STORAGE', merchant: 'Google', amount_original: '2.00', currency_original: 'CHF', confidence: '0.80', requires_review: true, status: 'duplicate_candidate', classification: 'subscription_auto', review_reason: 'duplicate_detection_v1' }
    31|  ] as any)
    32|  vi.mocked(markBudgetTransactionCandidateType).mockResolvedValue({ status: 'transfer_candidate', classification: 'credit_card_payment', entity_id: 'cand1', audit_id: 'audit1', message: 'OK' } as any)
    33|  vi.mocked(batchBudgetTransactionCandidates).mockResolvedValue({ updated_count: 2, skipped_count: 0, confirmed_count: 2, ignored_count: 2, reopened_count: 2, audit_id: 'audit_batch', errors: [] } as any)
    34|  vi.mocked(previewBatchBudgetTransactionCandidates).mockResolvedValue({ preview_id: 'batchprev1', count: 1, categories: { 'Elektronische Medien': 1 }, sources: { credit_card_csv: 1 }, possible_duplicates: 1, blockers: { duplicate_candidates: 1, covered_by_source: 0, transfer_candidates: 0, without_category: 0, missing_required_fields: 0, not_confirmable: 0 }, conflicts: [{ candidate_id: 'cand2', reason: 'duplicate_candidate_blocked' }], can_confirm: false, requires_explicit_confirm: true } as any)
    35|})
    36|
    37|describe('Budget Import Production v2 review UI', () => {
    38|  it('shows source-aware review tabs and candidate type actions', async () => {
    39|    const wrapper = mount(BudgetImportReviewPage, { global: { stubs: { teleport: true } } })
    40|    await flushPromises()
    41|
    42|    expect(wrapper.text()).toContain('VISA')
    43|    expect(wrapper.text()).toContain('Migros')
    44|    expect(wrapper.text()).toContain('Raiffeisen')
    45|    expect(wrapper.text()).toContain('AKB')
    46|    expect(wrapper.text()).toContain('Kreditkartenausgleich')
    47|    expect(wrapper.text()).toContain('True Wealth / Investment Transfers')
    48|    expect(wrapper.text()).toContain('subscription_auto')
    49|  })
    50|
    51|  it('marks a candidate as credit card payment through audited type action', async () => {
    52|    const wrapper = mount(BudgetImportReviewPage, { global: { stubs: { teleport: true } } })
    53|    await flushPromises()
    54|
    55|    await (wrapper.vm as any).markType({ transaction_candidate_id: 'cand1' }, 'credit_card_payment')
    56|    await flushPromises()
    57|
    58|    expect(markBudgetTransactionCandidateType).toHaveBeenCalledWith('cand1', 'credit_card_payment', 'User marked credit_card_payment')
    59|  })
    60|
    61|  it('shows bulk toolbar state, applies category to selected candidates, and keeps partial feedback visible', async () => {
    62|    const wrapper = mount(BudgetImportReviewPage, { global: { stubs: { teleport: true } } })
    63|    await flushPromises()
    64|    expect(wrapper.get('[data-test="bulk-selection-count"]').text()).toContain('Ausgewählt: 0')
    65|    expect('disabled' in wrapper.get('[data-test="bulk-apply-category"]').attributes() || wrapper.get('[data-test="bulk-apply-category"]').attributes('aria-disabled') === 'true').toBe(true)
    66|
    67|    ;(wrapper.vm as any).selected = ['cand1', 'cand2']
    68|    ;(wrapper.vm as any).bulkCategory = 'cat_media'
    69|    await flushPromises()
    70|    await (wrapper.vm as any).bulkSetCategory()
    71|    await flushPromises()
    72|
    73|    expect(batchBudgetTransactionCandidates).toHaveBeenCalledWith({ candidate_ids: ['cand1', 'cand2'], action: 'category', category_id: 'cat_media', budget_year: '2026', note: 'Bulk category assignment' })
    74|    expect(wrapper.text()).toContain('Kategorie auf Auswahl angewendet')
    75|  })
    76|
    77|  it('opens bulk confirm preview dialog and disables final confirm when blockers exist', async () => {
    78|    const wrapper = mount(BudgetImportReviewPage, { global: { stubs: { teleport: true } } })
    79|    await flushPromises()
    80|    ;(wrapper.vm as any).selected = ['cand1', 'cand2']
    81|    ;(wrapper.vm as any).confirmAccount = 'acc1'
    82|    await (wrapper.vm as any).previewBatchConfirm()
    83|    await flushPromises()
    84|
    85|    expect(previewBatchBudgetTransactionCandidates).toHaveBeenCalledWith(['cand1', 'cand2'], 'acc1', '2026')
    86|    expect(wrapper.text()).toContain('Bulk Confirm Preview')
    87|    expect(wrapper.text()).toContain('duplicate_candidate_blocked')
    88|    expect(wrapper.text()).toContain('Elektronische Medien')
    89|    expect('disabled' in wrapper.get('[data-test="bulk-confirm-final"]').attributes() || wrapper.get('[data-test="bulk-confirm-final"]').attributes('aria-disabled') === 'true').toBe(true)
    90|  })
    91|})
    92|


  1500|    bb = set(_normalized_merchant_name(b).split())
  1501|    if not aa or not bb:
  1502|        return False
  1503|    return bool(aa & bb) or _normalized_merchant_name(a) in _normalized_merchant_name(b) or _normalized_merchant_name(b) in _normalized_merchant_name(a)
  1504|
  1505|
  1506|def detect_duplicate_candidates(conn: Connection, *, day_window: int = 2) -> dict[str, Any]:
  1507|    candidates = conn.execute("""
  1508|        SELECT * FROM budget_transaction_candidates
  1509|        WHERE status NOT IN ('confirmed','ignored','covered_by_source','covered_by_migros','auto_ignored_duplicate','superseded','reference_2025','archived_reference')
  1510|          AND transaction_date IS NOT NULL AND amount_original IS NOT NULL
  1511|    """).fetchall()
  1512|    transactions = conn.execute("SELECT * FROM budget_transactions WHERE status='confirmed' AND transaction_date IS NOT NULL").fetchall()
  1513|    safe = possible = 0
  1514|    ts = now()
  1515|    for cand in candidates:
  1516|        try:
  1517|            cdate = datetime.fromisoformat(str(cand["transaction_date"])[:10])
  1518|            camount = Decimal(str(cand["amount_original"]))
  1519|        except Exception:
  1520|            continue
  1521|        cmerchant = str(cand["merchant_display_name"] or cand["merchant"] or cand["description"] or "")
  1522|        for tx in transactions:
  1523|            try:
  1524|                tdate = datetime.fromisoformat(str(tx["transaction_date"])[:10])
  1525|                tamount = Decimal(str(tx["amount_original"]))
  1526|            except Exception:
  1527|                continue
  1528|            day_diff = abs((cdate - tdate).days)
  1529|            if day_diff > day_window or camount != tamount or str(cand["currency_original"] or "CHF") != str(tx["currency_original"] or "CHF"):
  1530|                continue
  1531|            if not _similar_merchant(cmerchant, str(tx["payee"] or tx["description"] or "")):
  1532|                continue
  1533|            exact_text = _normalized_merchant_name(cmerchant) == _normalized_merchant_name(str(tx["payee"] or tx["description"] or ""))
  1534|            high_conf = Decimal(str(cand["confidence"] or "0")) >= Decimal("0.95")
  1535|            if day_diff == 0 and exact_text and high_conf:
  1536|                conn.execute("UPDATE budget_transaction_candidates SET status='auto_ignored_duplicate', status_label='Automatisch ignoriertes Duplikat', duplicate_of_transaction_id=?, requires_review=0, review_reason='safe_duplicate_existing_transaction', updated_at=? WHERE transaction_candidate_id=?", (tx["budget_transaction_id"], ts, cand["transaction_candidate_id"]))
  1537|                record_audit_event(conn, source="vue_dashboard", action="auto_ignore_duplicate", entity_type="budget_transaction_candidate", entity_id=cand["transaction_candidate_id"], old_values={"status": cand["status"]}, new_values={"status": "auto_ignored_duplicate", "duplicate_of_transaction_id": tx["budget_transaction_id"]}, created_by="system")
  1538|                safe += 1
  1539|            else:
  1540|                conn.execute("UPDATE budget_transaction_candidates SET status='duplicate_candidate', status_label=?, duplicate_of_transaction_id=?, requires_review=1, review_reason='duplicate_detection_v1', updated_at=? WHERE transaction_candidate_id=?", (STATUS_LABELS["duplicate_candidate"], tx["budget_transaction_id"], ts, cand["transaction_candidate_id"]))
  1541|                possible += 1
  1542|            break
  1543|    audit_id = record_audit_event(conn, source="vue_dashboard", action="budget_duplicate_detection_v2", entity_type="budget_transaction_candidate", entity_id="duplicate_detection_v2", new_values={"safe_duplicate_count": safe, "possible_duplicate_count": possible}, created_by="system")
  1544|    conn.commit()
  1545|    return {"duplicate_count": safe + possible, "safe_duplicate_count": safe, "possible_duplicate_count": possible, "audit_id": audit_id}
  1546|
  1547|
  1548|def archive_reference_year_candidates(conn: Connection, *, active_year: str = "2026", reference_year: str = "2025") -> dict[str, int | str]:
  1549|    ts = now()
  1550|    rows = conn.execute("SELECT * FROM budget_transaction_candidates WHERE substr(transaction_date,1,4)=? AND status NOT IN ('confirmed','reference_2025','archived_reference','superseded')", (reference_year,)).fetchall()
  1551|    updated = 0
  1552|    for row in rows:
  1553|        status = f"reference_{reference_year}" if reference_year == "2025" else "archived_reference"
  1554|        conn.execute("UPDATE budget_transaction_candidates SET status=?, requires_review=0, review_reason=?, updated_at=? WHERE transaction_candidate_id=?", (status, f"reference_year_not_active_budget_{active_year}", ts, row["transaction_candidate_id"]))
  1555|        record_audit_event(conn, source="phase110_cleanup", action="archive_reference_year_candidate", entity_type="budget_transaction_candidate", entity_id=row["transaction_candidate_id"], old_values=row_to_dict(row), new_values={"status": status, "active_year": active_year}, created_by="system")
  1556|        updated += 1
  1557|    conn.commit()
  1558|    return {"updated_count": updated, "active_year": active_year, "reference_year": reference_year}
  1559|
  1560|
  1561|def cleanup_migros_article_candidates(conn: Connection, *, dry_run: bool = True) -> dict[str, int]:
  1562|    where = """
  1563|        status IN ('pending','needs_review','duplicate')
  1564|        AND (lower(COALESCE(source_file_label,'')) LIKE '%migros%' OR lower(COALESCE(source_file_label,'')) LIKE '%cumulus%')
  1565|        AND COALESCE(source_type,'') NOT IN ('migros_receipt')
  1566|    """
  1567|    rows = conn.execute(f"SELECT transaction_candidate_id FROM budget_transaction_candidates WHERE {where}").fetchall()
  1568|    count = len(rows)
  1569|    if dry_run:
  1570|        return {"article_candidate_count": count, "superseded_count": 0, "dry_run": 1}
  1571|    ts = now()
  1572|    conn.execute(f"UPDATE budget_transaction_candidates SET status='superseded', review_reason='superseded_by_migros_receipt_aggregation', updated_at=? WHERE {where}", (ts,))
  1573|    audit_id = record_audit_event(conn, source="budget_phase15_cleanup", action="cleanup_migros_article_candidates", entity_type="budget_transaction_candidate", entity_id="migros_article_candidates", new_values={"article_candidate_count": count, "status": "superseded"}, created_by="agent")
  1574|    conn.commit()
  1575|    return {"article_candidate_count": count, "superseded_count": count, "audit_id": audit_id, "dry_run": 0}
  1576|


from __future__ import annotations

import pytest
from fastapi import HTTPException

from jarvis_finance.services.budget_accounts import confirm_create_budget_account
from jarvis_finance.services.budget_categories import confirm_create_category
from jarvis_finance.services.budget_csv_imports import import_budget_csv_text
from jarvis_finance.services.budget_imports import (
    batch_mark_candidates,
    confirm_duplicate_override,
    confirm_transaction_candidate,
    list_transaction_candidates,
    preview_batch_confirm_candidates,
)
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

def db():
    conn = connect_memory()
    apply_migrations(conn)
    return conn

def setup_base(conn):
    account = confirm_create_budget_account(conn, {"name": "Budget Konto", "account_type": "cash", "currency": "CHF"})["entity_id"]
    food = confirm_create_category(conn, {"name": "Essen & Haushalt", "category_type": "expense"})["entity_id"]
    return account, food

def candidate(conn, cid: str, *, status: str = "pending", category_id: str | None = None, description: str = "M Windisch", source_type: str = "migros_receipts") -> None:
    conn.execute(
        """
        INSERT INTO budget_transaction_candidates(
            transaction_candidate_id, source_file_label, source_row_or_range, source_type,
            transaction_date, description, merchant, amount_original, currency_original,
            proposed_category_id, proposed_category_name, confidence, requires_review, status,
            classification, review_reason, raw_fingerprint, created_at, updated_at
        ) VALUES (?, '2026 Migros Einkäufe.csv', ?, ?, '2026-03-20', ?, 'M Windisch', '0.40', 'CHF', ?,
            CASE WHEN ? IS NULL THEN NULL ELSE 'Essen & Haushalt' END, '0.80', 0, ?, 'migros_receipt_food_household',
            'duplicate_detection_v1', ?, '2026-01-01T00:00:00', '2026-01-01T00:00:00')
        """,
        (cid, cid, source_type, description, category_id, category_id, status, cid),
    )
    conn.commit()

def test_normal_review_and_migros_tab_exclude_duplicates_but_duplicates_tab_shows_them() -> None:
    conn = db(); _account, food = setup_base(conn)
    candidate(conn, "open1", status="pending", category_id=food)
    candidate(conn, "dup1", status="duplicate_candidate", category_id=food)
    candidate(conn, "blocked1", status="duplicate_blocked", category_id=food)
    candidate(conn, "possible1", status="possible_duplicate", category_id=food)

    normal_ids = {r["transaction_candidate_id"] for r in list_transaction_candidates(conn, budget_year="2026")}
    migros_ids = {r["transaction_candidate_id"] for r in list_transaction_candidates(conn, tab="migros", budget_year="2026")}
    duplicate_ids = {r["transaction_candidate_id"] for r in list_transaction_candidates(conn, tab="duplicates", budget_year="2026")}

    assert normal_ids == {"open1"}
    assert migros_ids == {"open1"}
    assert duplicate_ids == {"dup1", "blocked1", "possible1"}

@pytest.mark.parametrize("status", ["duplicate_candidate", "duplicate_blocked", "possible_duplicate", "superseded", "covered_by_migros", "ignored"])
def test_normal_confirm_guard_blocks_duplicate_and_covered_statuses(status: str) -> None:
    conn = db(); account, food = setup_base(conn)
    candidate(conn, f"cand_{status}", status=status, category_id=food)

    with pytest.raises(HTTPException) as exc:
        confirm_transaction_candidate(conn, f"cand_{status}", {"account_id": account, "category_id": food, "budget_year": "2026"})

    assert exc.value.status_code == 409
    assert "Kandidat ist als Duplikat/covered/ignored markiert" in str(exc.value.detail)
    assert conn.execute("SELECT COUNT(*) FROM budget_transactions").fetchone()[0] == 0

def test_duplicate_override_requires_reason_and_audits_explicit_booking() -> None:
    conn = db(); account, food = setup_base(conn)
    candidate(conn, "dup1", status="duplicate_candidate", category_id=food)

    with pytest.raises(HTTPException) as exc:
        confirm_duplicate_override(conn, "dup1", {"account_id": account, "category_id": food, "budget_year": "2026"})
    assert exc.value.status_code == 422

    result = confirm_duplicate_override(conn, "dup1", {"account_id": account, "category_id": food, "budget_year": "2026", "reason": "Bewusst separater Bon trotz Treffer"})

    assert result["status"] == "confirmed"
    assert conn.execute("SELECT COUNT(*) FROM budget_transactions").fetchone()[0] == 1
    audit_actions = {r[0] for r in conn.execute("SELECT action FROM audit_events").fetchall()}
    assert "candidate_duplicate_override_confirmed" in audit_actions

def test_batch_confirm_skips_duplicates_and_reports_per_candidate_error() -> None:
    conn = db(); account, food = setup_base(conn)
    candidate(conn, "ok1", status="pending", category_id=food, description="Coop Einkauf", source_type="visa_credit_card")
    candidate(conn, "dup1", status="duplicate_blocked", category_id=food)

    preview = preview_batch_confirm_candidates(conn, candidate_ids=["ok1", "dup1"], account_id=account, budget_year="2026")
    assert preview["count"] == 1
    assert preview["can_confirm"] is False
    assert preview["conflicts"] == [{"candidate_id": "dup1", "reason": "duplicate_candidate_blocked"}]

    result = batch_mark_candidates(conn, candidate_ids=["ok1", "dup1"], action="confirm", account_id=account, preview_id=preview["preview_id"], budget_year="2026")
    assert result["confirmed_count"] == 1
    assert result["skipped_count"] == 1
    assert result["errors"] == [{"candidate_id": "dup1", "error": "duplicate_candidate_blocked"}]
    assert conn.execute("SELECT status FROM budget_transaction_candidates WHERE transaction_candidate_id='dup1'").fetchone()[0] == "duplicate_blocked"

def test_migros_receipt_key_import_is_idempotent_across_reimports_and_article_rows_are_detail_only() -> None:
    conn = db(); _account, food = setup_base(conn)
    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"

    first = import_budget_csv_text(conn, "migros_receipts", csv_text, source_file_label="2026 Migros Einkäufe.csv", dry_run=False, default_category_id=food)
    second = import_budget_csv_text(conn, "migros_receipts", csv_text, source_file_label="same receipt exported again.csv", dry_run=False, default_category_id=food)

    assert first["candidate_count"] == 1
    assert second["candidate_count"] == 0
    assert conn.execute("SELECT COUNT(*) FROM budget_transaction_candidates WHERE source_type IN ('migros_receipts','migros_receipt')").fetchone()[0] == 1
    assert conn.execute("SELECT COUNT(*) FROM budget_import_line_items").fetchone()[0] == 2


LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_duplicate_handling_hotfix.py">
ERROR [11:5] "confirm_duplicate_override" is unknown import symbol [reportAttributeAccessIssue] (Pyright)
</diagnostics>


terminal

Output:
==================================== ERRORS ====================================
_____ ERROR collecting tests/unit/test_budget_duplicate_handling_hotfix.py _____
ImportError while importing test module '/home/agent/.hermes/repos/FinanceManager/tests/unit/test_budget_duplicate_handling_hotfix.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../../.local/share/uv/python/cpython-3.11.15-linux-x86_64-gnu/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/unit/test_budget_duplicate_handling_hotfix.py:9: in <module>
    from jarvis_finance.services.budget_imports import (
E   ImportError: cannot import name 'confirm_duplicate_override' from 'jarvis_finance.services.budget_imports' (/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py)
=========================== short test summary info ============================
ERROR tests/unit/test_budget_duplicate_handling_hotfix.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.30s


Explanation:
**Implementing patch updates**

I’m focusing on using a patch for the implementation. I need to include the necessary router imports and set up the endpoint along with the frontend API. It might also be important to add some UI tests, but I think I'll prioritize the backend first. I’m noticing I have some constants around 145 that I need to patch. So let’s get started on that!

patch

USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate"}
USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"}
USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}

USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate", "possible_duplicate", "duplicate_blocked"}
USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"} | USER_DUPLICATE_STATUSES
USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}
NORMAL_REVIEW_EXCLUDED_STATUSES = USER_EXCLUDED_STATUSES | {"covered_by_migros", "covered_by_source"}
DUPLICATE_CONFIRM_BLOCKED_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_migros", "covered_by_source", "ignored", "superseded", "auto_ignored_duplicate", "reference_2025", "archived_reference", "already_processed"}
DUPLICATE_CONFIRM_BLOCK_MESSAGE = "Kandidat ist als Duplikat/covered/ignored markiert und kann nicht normal bestätigt werden."


--- 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
@@ -143,9 +143,12 @@

 USER_OPEN_STATUSES = {"pending", "auto_categorized", "needs_review", "transfer_candidate"}
-USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate"}
-USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"}
+USER_DUPLICATE_STATUSES = {"duplicate", "duplicate_candidate", "possible_duplicate", "duplicate_blocked"}
+USER_EXCLUDED_STATUSES = {"confirmed", "ignored", "superseded", "auto_ignored_duplicate", "covered_by_source", "covered_by_migros", "reference_2025", "archived_reference", "already_processed"} | USER_DUPLICATE_STATUSES
 USER_VISIBLE_SPECIAL_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_source", "covered_by_migros", "already_processed"}
+NORMAL_REVIEW_EXCLUDED_STATUSES = USER_EXCLUDED_STATUSES | {"covered_by_migros", "covered_by_source"}
+DUPLICATE_CONFIRM_BLOCKED_STATUSES = USER_DUPLICATE_STATUSES | {"covered_by_migros", "covered_by_source", "ignored", "superseded", "auto_ignored_duplicate", "reference_2025", "archived_reference", "already_processed"}
+DUPLICATE_CONFIRM_BLOCK_MESSAGE = "Kandidat ist als Duplikat/covered/ignored markiert und kann nicht normal bestätigt werden."

 STATUS_LABELS = {
     "pending": "Offen",

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


    if row.get("receipt_key") and row.get("source_type") in {"migros_receipt", "migros_receipts"}:
        existing_receipt = conn.execute(
            """
            SELECT 1 FROM budget_transaction_candidates
            WHERE source_type IN ('migros_receipt','migros_receipts')
              AND receipt_key=?
              AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
            """,
            (row.get("receipt_key"),),
        ).fetchone()
        if existing_receipt:
            return False
    existing = conn.execute(
        """
        SELECT 1 FROM budget_transaction_candidates
        WHERE source_file_label=? AND source_row_or_range=? AND transaction_date IS ?
          AND description=? AND COALESCE(amount_original,'')=COALESCE(?, '')
        """,
        (row["source_file_label"], row.get("source_row_or_range"), row.get("transaction_date"), row["description"], row.get("amount_original")),
    ).fetchone()
    if existing:
        return False


--- 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
@@ -204,6 +204,18 @@

 def _insert_candidate(conn: Connection, row: dict[str, Any]) -> bool:
+    if row.get("receipt_key") and row.get("source_type") in {"migros_receipt", "migros_receipts"}:
+        existing_receipt = conn.execute(
+            """
+            SELECT 1 FROM budget_transaction_candidates
+            WHERE source_type IN ('migros_receipt','migros_receipts')
+              AND receipt_key=?
+              AND status NOT IN ('superseded','ignored','reference_2025','archived_reference')
+            """,
+            (row.get("receipt_key"),),
+        ).fetchone()
+        if existing_receipt:
+            return False
     existing = conn.execute(
         """
         SELECT 1 FROM budget_transaction_candidates


    "duplicate_candidate": "Mögliches Duplikat",
    "duplicate": "Mögliches Duplikat",

    "duplicate_candidate": "Mögliches Duplikat",
    "possible_duplicate": "Mögliches Duplikat",
    "duplicate_blocked": "Blockiertes Duplikat",
    "duplicate": "Mögliches Duplikat",


--- 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
@@ -159,6 +159,8 @@
     "covered_by_source": "Durch Quelle abgedeckt",
     "covered_by_migros": "Durch Migros abgedeckt",
     "duplicate_candidate": "Mögliches Duplikat",
+    "possible_duplicate": "Mögliches Duplikat",
+    "duplicate_blocked": "Blockiertes Duplikat",
     "duplicate": "Mögliches Duplikat",
     "transfer_candidate": "Möglicher Transfer",
     "already_processed": "Bereits verarbeitet",


Explanation:
**Considering query optimization**

I need to patch the list filters and think about whether to use a parameter list. It might make sense to simplify the SQL string definition. I should incorporate constants into the query instead of hardcoding strings. I’m planning to replace lines 765-770 and will need to address mapping any duplicates or migros too. This could streamline things significantly. Let's dive into the details!

    normal_excluded_sql = "'confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked','superseded','reference_2025','archived_reference','already_processed'"
    if status == "open":
        clauses.append("status IN ('pending','auto_categorized','needs_review')")
    elif status:
        clauses.append("status=?"); params.append(status)
    elif not include_reference and not tab:
        clauses.append(f"status NOT IN ({normal_excluded_sql})")

    if status == "open":
        clauses.append("status IN ('pending','auto_categorized','needs_review','transfer_candidate')")
    elif status:
        clauses.append("status=?"); params.append(status)
    elif not include_reference and not tab:
        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference','already_processed')")


--- 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
@@ -779,12 +779,13 @@
     clauses=[]; params=[]
     if budget_year:
         clauses.append("substr(transaction_date,1,4)=?"); params.append(budget_year)
+    normal_excluded_sql = "'confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked','superseded','reference_2025','archived_reference','already_processed'"
     if status == "open":
-        clauses.append("status IN ('pending','auto_categorized','needs_review','transfer_candidate')")
+        clauses.append("status IN ('pending','auto_categorized','needs_review')")
     elif status:
         clauses.append("status=?"); params.append(status)
     elif not include_reference and not tab:
-        clauses.append("status NOT IN ('confirmed','ignored','covered_by_migros','covered_by_source','auto_ignored_duplicate','duplicate_candidate','superseded','reference_2025','archived_reference','already_processed')")
+        clauses.append(f"status NOT IN ({normal_excluded_sql})")
     if tab:
         mapping = {
             "auto": "requires_review=0 AND status IN ('pending','auto_categorized')",


"duplicates": "status IN ('duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked')",

"duplicates": "status IN ('duplicate','duplicate_candidate')",


--- 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
@@ -799,7 +799,7 @@
             "pets": "classification='pets'",
             "unclear": "classification IN ('admin_other','unclear','legacy_csv_seed','card_uncategorized')",
             "covered_by_migros": "status='covered_by_migros'",
-            "duplicates": "status IN ('duplicate','duplicate_candidate')",
+            "duplicates": "status IN ('duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked')",
             "auto_ignored_duplicates": "status='auto_ignored_duplicate'",
             "covered_by_source": "status IN ('covered_by_migros','covered_by_source')",
             "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",


"migros": "(source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros') AND status NOT IN ('confirmed','duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked','superseded','ignored','reference_2025','archived_reference')",

"migros": "source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros'",


--- 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
@@ -805,7 +805,7 @@
             "transfers": "classification='transfer_candidate' OR status='transfer_candidate'",
             "bank": "source_type IN ('bank_csv','raiffeisen_bank','akb_bank')",
             "visa": "source_type='visa_credit_card' OR source_type='credit_card_csv'",
-            "migros": "source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros'",
+            "migros": "(source_type IN ('migros_receipts','migros_receipt') OR status='covered_by_migros') AND status NOT IN ('confirmed','duplicate','duplicate_candidate','possible_duplicate','duplicate_blocked','superseded','ignored','reference_2025','archived_reference')",
             "raiffeisen": "source_type='raiffeisen_bank'",
             "akb": "source_type='akb_bank'",
             "income": "classification='income_candidate'",


    status = str(row["status"] or "")
    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES:
        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
    payload = payload or {}


--- 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
@@ -901,6 +901,9 @@
     row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
     if not row:
         raise HTTPException(status_code=404, detail="transaction candidate not found")
+    status = str(row["status"] or "")
+    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES:
+        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
     payload = payload or {}
     cat_id = payload.get("category_id") or row["proposed_category_id"]
     notes = row["notes"] or ""


Explanation:
**Revising preview logic**

I'm thinking about whether this patch replaces the first payload, perhaps with preview_transaction_candidate. That seems promising! But it might also block the normal preview for duplicate tab details. If I need to override, should I call preview? I worry that it will block it. It might be better to modify preview_transaction_candidate so it accepts allow_duplicate_override—like adding a guard condition if payload has a "duplicate_override." That way, I'm covering all the bases!

    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES and not payload.get("duplicate_override"):
        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)

    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES:
        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)


--- 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
@@ -902,7 +902,7 @@
     if not row:
         raise HTTPException(status_code=404, detail="transaction candidate not found")
     status = str(row["status"] or "")
-    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES:
+    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES and not payload.get("duplicate_override"):
         raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
     payload = payload or {}
     cat_id = payload.get("category_id") or row["proposed_category_id"]

LSP diagnostics introduced by this edit:
<diagnostics file="/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/budget_imports.py">
ERROR [905:69] "get" is not a known attribute of "None" [reportOptionalMemberAccess] (Pyright)
</diagnostics>


   895|    conn.commit()
   896|    counts["audit_written"] = 1 if audit_id else 0
   897|    return counts
   898|
   899|
   900|def preview_transaction_candidate(conn: Connection, candidate_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
   901|    row = conn.execute("SELECT * FROM budget_transaction_candidates WHERE transaction_candidate_id=?", (candidate_id,)).fetchone()
   902|    if not row:
   903|        raise HTTPException(status_code=404, detail="transaction candidate not found")
   904|    status = str(row["status"] or "")
   905|    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES and not payload.get("duplicate_override"):
   906|        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
   907|    payload = payload or {}
   908|    cat_id = payload.get("category_id") or row["proposed_category_id"]
   909|    notes = row["notes"] or ""
   910|    tx_type = payload.get("transaction_type") or "expense"
   911|    try:
   912|        meta = json.loads(notes) if notes and notes.startswith("{") else {}
   913|        tx_type = payload.get("transaction_type") or meta.get("transaction_type") or tx_type
   914|    except json.JSONDecodeError:
   915|        pass
   916|    if not cat_id and tx_type in {"expense", "income"}:
   917|        raise HTTPException(status_code=422, detail="category required before confirm")
   918|    if cat_id and not conn.execute("SELECT 1 FROM budget_categories WHERE category_id=? AND is_active=1", (cat_id,)).fetchone():
   919|        raise HTTPException(status_code=422, detail="active category required before confirm")
   920|    if not payload.get("account_id"):
   921|        raise HTTPException(status_code=422, detail="account_id required before confirm")
   922|    if not row["transaction_date"]:
   923|        raise HTTPException(status_code=422, detail="transaction date required before confirm")
   924|    if not row["amount_original"]:
   925|        raise HTTPException(status_code=422, detail="amount required before confirm")
   926|    if not row["currency_original"]:
   927|        raise HTTPException(status_code=422, detail="currency required before confirm")
   928|    return {
   929|        "preview_id": new_id("preview"),
   930|        "summary": "Buchungskandidat übernehmen",
   931|        "warnings": ["Möglicher Duplikat-Treffer"] if row["duplicate_of_transaction_id"] else ([] if cat_id or tx_type == "transfer" else ["Kategorie fehlt"]),
   932|        "payload": {
   933|            "account_id": payload.get("account_id"),
   934|            "transaction_type": tx_type,
   935|

Use offset=935 to continue reading (showing 895-934 of 1596 lines)


    payload = payload or {}
    status = str(row["status"] or "")
    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES and not payload.get("duplicate_override"):
        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
    cat_id = payload.get("category_id") or row["proposed_category_id"]

    status = str(row["status"] or "")
    if status in DUPLICATE_CONFIRM_BLOCKED_STATUSES and not payload.get("duplicate_override"):
        raise HTTPException(status_code=409, detail=DUPLICATE_CONFIRM_BLOCK_MESSAGE)
    payload = payload or {}
    cat_id = payload.get("category_id") or row["proposed_category_id"]