A script to convert CSV exported from Sparebanken Sør to a format YNAB can import
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

251 рядки
7.6KB

  1. #!/usr/bin/env python3
  2. """
  3. Bank Statement to YNAB Converter
  4. Converts bank statements from various formats to YNAB-compatible CSV files
  5. """
  6. import os
  7. import sys
  8. import glob
  9. import pandas as pd
  10. from pathlib import Path
  11. def parse_norwegian_number(value):
  12. """Convert Norwegian number format (comma decimal) to float"""
  13. if pd.isna(value) or value == '':
  14. return 0.0
  15. # Convert to string and replace comma with dot
  16. str_value = str(value).replace(',', '.')
  17. try:
  18. return float(str_value)
  19. except ValueError:
  20. return 0.0
  21. def parse_norwegian_date(date_str):
  22. """Convert DD.MM.YYYY format to YYYY-MM-DD"""
  23. if pd.isna(date_str) or date_str == '':
  24. return ''
  25. try:
  26. # Parse DD.MM.YYYY and convert to date object
  27. return pd.to_datetime(date_str, format='%d.%m.%Y')
  28. except (ValueError, TypeError):
  29. print(f"Invalid date format: {date_str}")
  30. exit(1)
  31. def parse_bank_sparebank1(data):
  32. """
  33. Parse Sparebank 1 bank data
  34. Expected columns: Dato, Beskrivelse, Rentedato, Inn, Ut, Til konto, Fra konto
  35. """
  36. result = []
  37. for _, row in data.iterrows():
  38. inflow = parse_norwegian_number(row.get('Inn'))
  39. outflow = parse_norwegian_number(row.get('Ut'))
  40. # Convert outflow to positive if negative
  41. if outflow < 0:
  42. outflow = -outflow
  43. result.append({
  44. 'Date': parse_norwegian_date(row.get('Dato', '')),
  45. 'Payee': row.get('Til konto', ''),
  46. 'Memo': row.get('Beskrivelse', ''),
  47. 'Outflow': outflow,
  48. 'Inflow': inflow
  49. })
  50. return pd.DataFrame(result)
  51. def parse_bank_norwegian(data):
  52. """
  53. Parse Norwegian bank data
  54. Expected columns: TransactionDate, Text, Memo, Amount
  55. """
  56. result = []
  57. for _, row in data.iterrows():
  58. amount = row.get('Amount', 0)
  59. inflow = amount if amount > 0 else 0
  60. outflow = -amount if amount < 0 else 0 # Make outflow positive
  61. result.append({
  62. 'Date': row.get('TransactionDate', ''),
  63. 'Payee': row.get('Text', ''),
  64. 'Memo': row.get('Memo', ''),
  65. 'Outflow': outflow,
  66. 'Inflow': inflow
  67. })
  68. return pd.DataFrame(result)
  69. # Dictionary of banks, filename patterns, and parsing functions
  70. BANKS = {
  71. "Sparebank1": {
  72. "patterns": ["OversiktKonti*.csv"],
  73. "output_filename": "YNAB-{bank}-FROM-{first_date}-TO-{last_date}",
  74. "parse_function": parse_bank_sparebank1,
  75. "delimiter": ";"
  76. },
  77. "Norwegian": {
  78. "patterns": ["BankNorwegian*.xlsx", "Statement*.xlsx"],
  79. "output_filename": "YNAB-{bank}-FROM-{first_date}-TO-{last_date}",
  80. "parse_function": parse_bank_norwegian
  81. }
  82. # Add more banks and patterns as needed
  83. }
  84. def process_bank_statement(file_path, parse_function, delimiter):
  85. """
  86. Process a single bank statement file
  87. Args:
  88. file_path (str): Path to the bank statement file
  89. parse_function (callable): Function to parse the specific bank format
  90. delimiter (Optional<str>): Field delimiter
  91. Returns:
  92. pd.DataFrame: Processed YNAB-compatible data
  93. """
  94. file_extension = Path(file_path).suffix.lower()
  95. try:
  96. # Handle CSV files
  97. if file_extension == ".csv":
  98. data = pd.read_csv(file_path, delimiter=delimiter)
  99. # Handle Excel files
  100. elif file_extension in [".xlsx", ".xls"]:
  101. data = pd.read_excel(file_path)
  102. else:
  103. print(f"Skipping unsupported file type: {file_path}")
  104. return pd.DataFrame()
  105. # Call the appropriate bank-specific parsing function
  106. ynab_data = parse_function(data)
  107. return ynab_data
  108. except Exception as e:
  109. print(f"Error processing file {file_path}: {e}")
  110. raise e
  111. return pd.DataFrame()
  112. def find_bank_config(filename):
  113. """
  114. Find the appropriate bank configuration for a given filename
  115. Args:
  116. filename (str): Name of the file to match
  117. Returns:
  118. tuple: (bank_name, bank_config) or (None, None) if no match
  119. """
  120. import fnmatch
  121. for bank_name, bank_config in BANKS.items():
  122. for pattern in bank_config["patterns"]:
  123. if fnmatch.fnmatch(filename, pattern):
  124. return bank_name, bank_config
  125. return None, None
  126. def convert_bank_statements_to_ynab(input_files=None):
  127. """
  128. Convert bank statements to YNAB format
  129. Args:
  130. input_files (list): Optional list of specific files to process
  131. If None, processes all files in current directory
  132. """
  133. current_directory = Path.cwd()
  134. output_directory = current_directory / "YNAB_Outputs"
  135. # Create output directory if it doesn't exist
  136. output_directory.mkdir(exist_ok=True)
  137. # Get list of files to process
  138. if input_files:
  139. print(f"Processing {len(input_files)} dragged file(s)...")
  140. files_to_process = [Path(f) for f in input_files if Path(f).exists()]
  141. else:
  142. print("Processing all files in current directory...")
  143. files_to_process = []
  144. # Collect all files matching any bank pattern
  145. for bank_config in BANKS.values():
  146. for pattern in bank_config["patterns"]:
  147. matching_files = glob.glob(str(current_directory / pattern))
  148. files_to_process.extend([Path(f) for f in matching_files])
  149. files_processed = False
  150. # Process each file
  151. for file_path in files_to_process:
  152. if not file_path.exists():
  153. print(f"File not found: {file_path}")
  154. continue
  155. # Find matching bank configuration
  156. bank_name, bank_config = find_bank_config(file_path.name)
  157. if not bank_config:
  158. print(f"No bank configuration found for file: {file_path.name}")
  159. continue
  160. print(f"Processing file: {file_path} for {bank_name}")
  161. parse_function = bank_config["parse_function"]
  162. delimiter = bank_config.get("delimiter", ",")
  163. # Process the file
  164. ynab_data = process_bank_statement(str(file_path), parse_function, delimiter)
  165. if ynab_data.empty:
  166. print(f"No data processed for {file_path}")
  167. continue
  168. filename_placeholders = {
  169. 'bank': bank_name,
  170. 'first_date': ynab_data['Date'].min().date(),
  171. 'last_date': ynab_data['Date'].max().date(),
  172. }
  173. file_retry_count = 0
  174. while True:
  175. output_filename = bank_config["output_filename"].format(**filename_placeholders)
  176. if file_retry_count > 0:
  177. output_filename += f" ({file_retry_count})"
  178. output_filename += ".csv"
  179. output_file = output_directory / output_filename
  180. if not output_file.exists():
  181. break
  182. file_retry_count += 1
  183. # Export to CSV for YNAB import
  184. ynab_data.to_csv(output_file, index=False)
  185. print(f"Data saved to {output_file}")
  186. files_processed = True
  187. if not files_processed:
  188. print("No files were processed. Make sure your files match the expected patterns.")
  189. if __name__ == "__main__":
  190. # Check if files were dragged onto the script
  191. if len(sys.argv) > 1:
  192. # Files were dragged - process them
  193. files = sys.argv[1:]
  194. convert_bank_statements_to_ynab(files)
  195. else:
  196. # No files dragged - run normal directory processing
  197. convert_bank_statements_to_ynab()
  198. # Keep window open on Mac so user can see results
  199. input("\nPress Enter to close...")