A script to convert CSV exported from Sparebanken Sør to a format YNAB can import
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

190 lines
6.5KB

  1. import glob
  2. import logging
  3. import pandas as pd
  4. from pathlib import Path
  5. from bank_parsers.sparebank1 import parse_sparebank1
  6. from bank_parsers.bank_norwegian import parse_bank_norwegian
  7. from bank_parsers.sparebanken_norge import parse_sparebanken_norge
  8. # Dictionary of banks, filename patterns, and parsing functions
  9. BANKS = {
  10. "SparebankenNorge": {
  11. "patterns": ["Transaksjoner*.csv"],
  12. "encoding": "latin1",
  13. "output_filename": "YNAB-{bank}-FROM-{first_date}-TO-{last_date}.csv",
  14. "parse_function": parse_sparebanken_norge,
  15. "delimiter": ";"
  16. },
  17. "Sparebank1": {
  18. "patterns": ["OversiktKonti*.csv"],
  19. "output_filename": "YNAB-{bank}-FROM-{first_date}-TO-{last_date}.csv",
  20. "parse_function": parse_sparebank1,
  21. "delimiter": ";"
  22. },
  23. "Norwegian": {
  24. "patterns": ["BankNorwegian*.xlsx", "Statement*.xlsx"],
  25. "output_filename": "YNAB-{bank}-FROM-{first_date}-TO-{last_date}.csv",
  26. "parse_function": parse_bank_norwegian
  27. }
  28. # Add more banks and patterns as needed
  29. }
  30. def find_bank_config(filename):
  31. """
  32. Find the appropriate bank configuration for a given filename
  33. Args:
  34. filename (str): Name of the file to match
  35. Returns:
  36. tuple: (bank_name, bank_config) or (None, None) if no match
  37. """
  38. import fnmatch
  39. for bank_name, bank_config in BANKS.items():
  40. for pattern in bank_config["patterns"]:
  41. if fnmatch.fnmatch(filename, pattern):
  42. return bank_name, bank_config
  43. return None, None
  44. def process_bank_statement(file_path, parse_function, delimiter, encoding):
  45. """
  46. Process a single bank statement file
  47. Args:
  48. file_path (str): Path to the bank statement file
  49. parse_function (callable): Function to parse the specific bank format
  50. delimiter (Optional<str>): Field delimiter
  51. Returns:
  52. pd.DataFrame: Processed YNAB-compatible data
  53. """
  54. file_extension = Path(file_path).suffix.lower()
  55. try:
  56. # Handle CSV files
  57. if file_extension == ".csv":
  58. data = pd.read_csv(file_path, delimiter=delimiter, encoding=encoding)
  59. # Handle Excel files
  60. elif file_extension in [".xlsx", ".xls"]:
  61. data = pd.read_excel(file_path)
  62. else:
  63. logging.warning(f"Skipping unsupported file type: {file_path}")
  64. return pd.DataFrame()
  65. # Call the appropriate bank-specific parsing function
  66. ynab_data = parse_function(data)
  67. return ynab_data
  68. except Exception as e:
  69. logging.error(f"Error processing file {file_path}: {e}")
  70. raise e
  71. return pd.DataFrame()
  72. def get_unique_filename(original_path):
  73. file_retry_count = 0
  74. while True:
  75. result = Path(original_path)
  76. if file_retry_count > 0:
  77. result = result.with_stem(result.stem + f"({file_retry_count})")
  78. if result.exists():
  79. logging.debug(f"File {result} exists. Looking for available alternative")
  80. file_retry_count += 1
  81. continue
  82. else:
  83. return result
  84. def convert_bank_statements_to_ynab(input_paths, output_directory, archive_directory, on_success, overwrite):
  85. """
  86. Convert bank statements to YNAB format
  87. Args:
  88. input_paths (list): List of specific files or directories to process
  89. """
  90. # Create output directory if it doesn't exist
  91. output_directory.mkdir(exist_ok=True, parents=True)
  92. # Get list of files to process
  93. files_to_process = []
  94. for path in input_paths:
  95. if not path.exists():
  96. logging.warning(f"Path does not exist: {file_path}")
  97. elif path.is_file():
  98. files_to_process.append(path)
  99. elif path.is_dir():
  100. logging.debug(f"Looking for matching files in {path}")
  101. for bank_config in BANKS.values():
  102. for pattern in bank_config["patterns"]:
  103. matching_files = glob.glob(str(path / pattern))
  104. files_to_process.extend([Path(f) for f in matching_files])
  105. files_processed = False
  106. # Process each file
  107. logging.info(f"Processing {len(files_to_process)} file(s)...")
  108. for file_path in files_to_process:
  109. logging.debug(f"Processing {file_path}")
  110. if not file_path.exists():
  111. logging.warning(f"File not found: {file_path}")
  112. continue
  113. # Find matching bank configuration
  114. bank_name, bank_config = find_bank_config(file_path.name)
  115. if not bank_config:
  116. logging.warning(f"No bank configuration found for file: {file_path.name}")
  117. continue
  118. logging.info(f"Processing file: {file_path} for {bank_name}")
  119. parse_function = bank_config["parse_function"]
  120. delimiter = bank_config.get("delimiter", ",")
  121. encoding = bank_config.get("encoding", "utf-8")
  122. # Process the file
  123. ynab_data = process_bank_statement(str(file_path), parse_function, delimiter, encoding)
  124. if ynab_data.empty:
  125. logging.warning(f"No data processed for {file_path}")
  126. continue
  127. filename_placeholders = {
  128. 'bank': bank_name,
  129. 'first_date': ynab_data['Date'].min().date(),
  130. 'last_date': ynab_data['Date'].max().date(),
  131. }
  132. output_file = output_directory / Path(bank_config["output_filename"].format(**filename_placeholders))
  133. if not overwrite:
  134. output_file = get_unique_filename(output_file)
  135. # Export to CSV for YNAB import
  136. ynab_data.to_csv(output_file, index=False)
  137. logging.info(f"Data saved to {output_file}")
  138. files_processed = True
  139. if on_success == 'delete':
  140. logging.info(f"Deleting {file_path}")
  141. file_path.unlink()
  142. elif on_success == 'archive':
  143. archive_directory.mkdir(exist_ok=True, parents=True)
  144. file_archive_path = archive_directory / file_path.name
  145. if not overwrite:
  146. file_archive_path = get_unique_filename(file_archive_path)
  147. logging.debug(f"Archiving {file_path} to {file_archive_path}")
  148. file_path.rename(file_archive_path)
  149. elif on_success and on_success != 'nothing':
  150. logger.warning(f"Invalid operation after conversion: {on_success}")
  151. if not files_processed:
  152. logging.warning("No files were processed. Make sure your files match the expected patterns.")