| @@ -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...") | |||