| @@ -0,0 +1,235 @@ | |||||
| #!/usr/bin/env python3 | |||||
| """ | |||||
| Bank Statement to YNAB Converter | |||||
| Converts bank statements from various formats to YNAB-compatible CSV files | |||||
| """ | |||||
| import os | |||||
| import sys | |||||
| import glob | |||||
| import pandas as pd | |||||
| from pathlib import Path | |||||
| def parse_norwegian_number(value): | |||||
| """Convert Norwegian number format (comma decimal) to float""" | |||||
| if pd.isna(value) or value == '': | |||||
| return 0.0 | |||||
| # Convert to string and replace comma with dot | |||||
| str_value = str(value).replace(',', '.') | |||||
| try: | |||||
| return float(str_value) | |||||
| except ValueError: | |||||
| return 0.0 | |||||
| def parse_norwegian_date(date_str): | |||||
| """Convert DD.MM.YYYY format to YYYY-MM-DD""" | |||||
| if pd.isna(date_str) or date_str == '': | |||||
| return '' | |||||
| try: | |||||
| # Parse DD.MM.YYYY and convert to YYYY-MM-DD | |||||
| date_obj = pd.to_datetime(date_str, format='%d.%m.%Y') | |||||
| return date_obj.strftime('%Y-%m-%d') | |||||
| except (ValueError, TypeError): | |||||
| return str(date_str) # Return original if parsing fails | |||||
| def parse_bank_sparebank1(data): | |||||
| """ | |||||
| Parse Sparebank 1 bank data | |||||
| Expected columns: Dato, Beskrivelse, Rentedato, Inn, Ut, Til konto, Fra konto | |||||
| """ | |||||
| result = [] | |||||
| for _, row in data.iterrows(): | |||||
| inflow = parse_norwegian_number(row.get('Inn')) | |||||
| outflow = parse_norwegian_number(row.get('Ut')) | |||||
| # Convert outflow to positive if negative | |||||
| if outflow < 0: | |||||
| outflow = -outflow | |||||
| result.append({ | |||||
| 'Date': parse_norwegian_date(row.get('Dato', '')), | |||||
| 'Payee': row.get('Til konto', ''), | |||||
| 'Memo': row.get('Beskrivelse', ''), | |||||
| 'Outflow': outflow, | |||||
| 'Inflow': inflow | |||||
| }) | |||||
| return pd.DataFrame(result) | |||||
| def parse_bank_norwegian(data): | |||||
| """ | |||||
| Parse Norwegian bank data | |||||
| Expected columns: TransactionDate, Text, Memo, Amount | |||||
| """ | |||||
| result = [] | |||||
| for _, row in data.iterrows(): | |||||
| amount = row.get('Amount', 0) | |||||
| inflow = amount if amount > 0 else 0 | |||||
| outflow = -amount if amount < 0 else 0 # Make outflow positive | |||||
| result.append({ | |||||
| 'Date': row.get('TransactionDate', ''), | |||||
| 'Payee': row.get('Text', ''), | |||||
| 'Memo': row.get('Memo', ''), | |||||
| 'Outflow': outflow, | |||||
| 'Inflow': inflow | |||||
| }) | |||||
| return pd.DataFrame(result) | |||||
| # Dictionary of banks, filename patterns, and parsing functions | |||||
| BANKS = { | |||||
| "Sparebank1": { | |||||
| "patterns": ["OversiktKonti*.csv"], | |||||
| "parse_function": parse_bank_sparebank1, | |||||
| "delimiter": ";" | |||||
| }, | |||||
| "Norwegian": { | |||||
| "patterns": ["BankNorwegian*.xlsx", "Statement*.xlsx"], | |||||
| "parse_function": parse_bank_norwegian | |||||
| } | |||||
| # Add more banks and patterns as needed | |||||
| } | |||||
| def process_bank_statement(file_path, parse_function, delimiter): | |||||
| """ | |||||
| Process a single bank statement file | |||||
| Args: | |||||
| file_path (str): Path to the bank statement file | |||||
| parse_function (callable): Function to parse the specific bank format | |||||
| delimiter (Optional<str>): Field delimiter | |||||
| Returns: | |||||
| pd.DataFrame: Processed YNAB-compatible data | |||||
| """ | |||||
| file_extension = Path(file_path).suffix.lower() | |||||
| try: | |||||
| # Handle CSV files | |||||
| if file_extension == ".csv": | |||||
| data = pd.read_csv(file_path, delimiter=delimiter) | |||||
| # Handle Excel files | |||||
| elif file_extension in [".xlsx", ".xls"]: | |||||
| data = pd.read_excel(file_path) | |||||
| else: | |||||
| print(f"Skipping unsupported file type: {file_path}") | |||||
| return pd.DataFrame() | |||||
| # Call the appropriate bank-specific parsing function | |||||
| ynab_data = parse_function(data) | |||||
| return ynab_data | |||||
| except Exception as e: | |||||
| print(f"Error processing file {file_path}: {e}") | |||||
| raise e | |||||
| return pd.DataFrame() | |||||
| def find_bank_config(filename): | |||||
| """ | |||||
| Find the appropriate bank configuration for a given filename | |||||
| Args: | |||||
| filename (str): Name of the file to match | |||||
| Returns: | |||||
| tuple: (bank_name, bank_config) or (None, None) if no match | |||||
| """ | |||||
| import fnmatch | |||||
| for bank_name, bank_config in BANKS.items(): | |||||
| for pattern in bank_config["patterns"]: | |||||
| if fnmatch.fnmatch(filename, pattern): | |||||
| return bank_name, bank_config | |||||
| return None, None | |||||
| def convert_bank_statements_to_ynab(input_files=None): | |||||
| """ | |||||
| Convert bank statements to YNAB format | |||||
| Args: | |||||
| input_files (list): Optional list of specific files to process | |||||
| If None, processes all files in current directory | |||||
| """ | |||||
| current_directory = Path.cwd() | |||||
| output_directory = current_directory / "YNAB_Outputs" | |||||
| # Create output directory if it doesn't exist | |||||
| output_directory.mkdir(exist_ok=True) | |||||
| # Get list of files to process | |||||
| if input_files: | |||||
| print(f"Processing {len(input_files)} dragged file(s)...") | |||||
| files_to_process = [Path(f) for f in input_files if Path(f).exists()] | |||||
| else: | |||||
| print("Processing all files in current directory...") | |||||
| files_to_process = [] | |||||
| # Collect all files matching any bank pattern | |||||
| for bank_config in BANKS.values(): | |||||
| for pattern in bank_config["patterns"]: | |||||
| matching_files = glob.glob(str(current_directory / pattern)) | |||||
| files_to_process.extend([Path(f) for f in matching_files]) | |||||
| files_processed = False | |||||
| # Process each file | |||||
| for file_path in files_to_process: | |||||
| if not file_path.exists(): | |||||
| print(f"File not found: {file_path}") | |||||
| continue | |||||
| # Find matching bank configuration | |||||
| bank_name, bank_config = find_bank_config(file_path.name) | |||||
| if not bank_config: | |||||
| print(f"No bank configuration found for file: {file_path.name}") | |||||
| continue | |||||
| print(f"Processing file: {file_path} for {bank_name}") | |||||
| parse_function = bank_config["parse_function"] | |||||
| delimiter = bank_config.get("delimiter", ",") | |||||
| # Process the file | |||||
| ynab_data = process_bank_statement(str(file_path), parse_function, delimiter) | |||||
| if ynab_data.empty: | |||||
| print(f"No data processed for {file_path}") | |||||
| continue | |||||
| # Define output file with "ynab-" prefix | |||||
| output_filename = f"ynab-{file_path.name}" | |||||
| if not output_filename.endswith('.csv'): | |||||
| output_filename = Path(output_filename).stem + '.csv' | |||||
| output_file = output_directory / output_filename | |||||
| # Export to CSV for YNAB import | |||||
| ynab_data.to_csv(output_file, index=False) | |||||
| print(f"Data saved to {output_file}") | |||||
| files_processed = True | |||||
| if not files_processed: | |||||
| print("No files were processed. Make sure your files match the expected patterns.") | |||||
| if __name__ == "__main__": | |||||
| # Check if files were dragged onto the script | |||||
| if len(sys.argv) > 1: | |||||
| # Files were dragged - process them | |||||
| files = sys.argv[1:] | |||||
| convert_bank_statements_to_ynab(files) | |||||
| else: | |||||
| # No files dragged - run normal directory processing | |||||
| convert_bank_statements_to_ynab() | |||||
| # Keep window open on Mac so user can see results | |||||
| input("\nPress Enter to close...") | |||||