diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js index bdad264d4f0..0f01e2dfb65 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.js @@ -70,7 +70,7 @@ frappe.ui.form.on("Bank Statement Import", { frm.get_field("import_file").df.options = { restrictions: { - allowed_file_types: [".csv", ".xls", ".xlsx"], + allowed_file_types: [".csv", ".xls", ".xlsx", ".TXT", ".txt"], }, }; @@ -81,6 +81,7 @@ frappe.ui.form.on("Bank Statement Import", { refresh(frm) { frm.page.hide_icon_group(); + frm.trigger("toggle_mt940_note"); frm.trigger("update_indicators"); frm.trigger("import_file"); frm.trigger("show_import_log"); @@ -192,6 +193,24 @@ frappe.ui.form.on("Bank Statement Import", { }); }, + import_mt940_fromat(frm) { + frm.trigger("toggle_mt940_note"); + frm.save(); + }, + + toggle_mt940_note(frm) { + if (!frm.doc.import_mt940_fromat) { + frm.set_df_property("custom_delimiters", "hidden", 0); + frm.set_df_property("google_sheets_url", "hidden", 0); + frm.set_df_property("html_5", "hidden", 0); + } else { + frm.set_df_property("custom_delimiters", "hidden", 1); + frm.set_df_property("google_sheets_url", "hidden", 1); + frm.set_df_property("html_5", "hidden", 1); + } + frm.set_value("import_mt940_fromat", frm.doc.import_mt940_fromat); + }, + show_report_error_button(frm) { if (frm.doc.status === "Error") { frappe.db @@ -290,23 +309,45 @@ frappe.ui.form.on("Bank Statement Import", { .html(__("Loading import file...")) .appendTo(frm.get_field("import_preview").$wrapper); - frm.call({ - method: "get_preview_from_template", - args: { - data_import: frm.doc.name, - import_file: frm.doc.import_file, - google_sheets_url: frm.doc.google_sheets_url, + frappe.run_serially([ + // Convert MT940 to CSV if .txt file + () => { + if (frm.doc.import_file && frm.doc.import_file.toLowerCase().endsWith(".txt")) { + return frm + .call({ + method: "convert_mt940_to_csv", + args: { + data_import: frm.doc.name, + mt940_file_path: frm.doc.import_file, + }, + }) + .then((r) => { + const file_url = r.message; + frm.set_value("import_file", file_url); + frm.save(); + }); + } }, - error_handlers: { - TimestampMismatchError() { - // ignore this error - }, + () => { + frm.call({ + method: "get_preview_from_template", + args: { + data_import: frm.doc.name, + import_file: frm.doc.import_file, + google_sheets_url: frm.doc.google_sheets_url, + }, + error_handlers: { + TimestampMismatchError() { + // ignore this error + }, + }, + }).then((r) => { + let preview_data = r.message; + frm.events.show_import_preview(frm, preview_data); + frm.events.show_import_warnings(frm, preview_data); + }); }, - }).then((r) => { - let preview_data = r.message; - frm.events.show_import_preview(frm, preview_data); - frm.events.show_import_warnings(frm, preview_data); - }); + ]); }, // method: 'frappe.core.doctype.data_import.data_import.get_preview_from_template', diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json index 500e36a8782..c70092b765e 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.json @@ -11,6 +11,7 @@ "bank_account", "bank", "column_break_4", + "import_mt940_fromat", "custom_delimiters", "delimiter_options", "google_sheets_url", @@ -20,6 +21,7 @@ "download_template", "status", "template_options", + "use_csv_sniffer", "import_warnings_section", "template_warnings", "import_warnings", @@ -207,14 +209,28 @@ "fieldname": "delimiter_options", "fieldtype": "Data", "label": "Delimiter options" + }, + { + "default": "0", + "fieldname": "use_csv_sniffer", + "fieldtype": "Check", + "hidden": 1, + "label": "Use CSV Sniffer" + }, + { + "default": "0", + "fieldname": "import_mt940_fromat", + "fieldtype": "Check", + "label": "Import MT940 Fromat" } ], "hide_toolbar": 1, "links": [], - "modified": "2024-06-25 17:32:07.658250", + "modified": "2025-06-11 02:23:22.159961", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Statement Import", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -230,8 +246,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index 8046e81528d..2d5422f0d16 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -3,15 +3,19 @@ import csv +import io import json import re +from datetime import date, datetime import frappe +import mt940 import openpyxl from frappe import _ from frappe.core.doctype.data_import.data_import import DataImport from frappe.core.doctype.data_import.importer import Importer, ImportFile from frappe.utils.background_jobs import enqueue +from frappe.utils.file_manager import get_file, save_file from frappe.utils.xlsxutils import ILLEGAL_CHARACTERS_RE, handle_html from openpyxl.styles import Font from openpyxl.utils import get_column_letter @@ -35,6 +39,7 @@ class BankStatementImport(DataImport): delimiter_options: DF.Data | None google_sheets_url: DF.Data | None import_file: DF.Attach | None + import_mt940_fromat: DF.Check import_type: DF.Literal["", "Insert New Records", "Update Existing Records"] mute_emails: DF.Check reference_doctype: DF.Link @@ -43,6 +48,7 @@ class BankStatementImport(DataImport): submit_after_import: DF.Check template_options: DF.Code | None template_warnings: DF.Code | None + use_csv_sniffer: DF.Check # end: auto-generated types def __init__(self, *args, **kwargs): @@ -65,8 +71,9 @@ class BankStatementImport(DataImport): self.template_warnings = "" - self.validate_import_file() - self.validate_google_sheets_url() + if self.import_file and not self.import_file.lower().endswith(".txt"): + self.validate_import_file() + self.validate_google_sheets_url() def start_import(self): preview = frappe.get_doc("Bank Statement Import", self.name).get_preview_from_template( @@ -104,6 +111,68 @@ class BankStatementImport(DataImport): return None +@frappe.whitelist() +def convert_mt940_to_csv(data_import, mt940_file_path): + doc = frappe.get_doc("Bank Statement Import", data_import) + + file_doc, content = get_file(mt940_file_path) + + if not is_mt940_format(content): + frappe.throw(_("The uploaded file does not appear to be in valid MT940 format.")) + + if is_mt940_format(content) and not doc.import_mt940_fromat: + frappe.throw(_("MT940 file detected. Please enable 'Import MT940 Format' to proceed.")) + + try: + transactions = mt940.parse(content) + except Exception as e: + frappe.throw(_("Failed to parse MT940 format. Error: {0}").format(str(e))) + + if not transactions: + frappe.throw(_("Parsed file is not in valid MT940 format or contains no transactions.")) + + # Use in-memory file buffer instead of writing to temp file + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer) + + headers = ["Date", "Deposit", "Withdrawal", "Description", "Reference Number", "Bank Account", "Currency"] + writer.writerow(headers) + + for txn in transactions: + txn_date = getattr(txn, "date", None) + raw_date = txn.data.get("date", "") + + if txn_date: + date_str = txn_date.strftime("%Y-%m-%d") + elif isinstance(raw_date, date | datetime): + date_str = raw_date.strftime("%Y-%m-%d") + else: + date_str = str(raw_date) + + raw_amount = str(txn.data.get("amount", "")) + parts = raw_amount.strip().split() + amount_value = float(parts[0]) if parts else 0.0 + + deposit = amount_value if amount_value > 0 else "" + withdrawal = abs(amount_value) if amount_value < 0 else "" + description = txn.data.get("extra_details") or "" + reference = txn.data.get("transaction_reference") or "" + currency = txn.data.get("currency", "") + + writer.writerow([date_str, deposit, withdrawal, description, reference, doc.bank_account, currency]) + + # Prepare in-memory CSV for upload + csv_content = csv_buffer.getvalue().encode("utf-8") + csv_buffer.close() + + filename = f"{frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}_converted_mt940.csv" + + # Save to File Manager + saved_file = save_file(filename, csv_content, doc.doctype, doc.name, is_private=True, df="import_file") + + return saved_file.file_url + + @frappe.whitelist() def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template( @@ -128,6 +197,12 @@ def download_import_log(data_import_name): return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log() +def is_mt940_format(content: str) -> bool: + """Check if the content has key MT940 tags""" + required_tags = [":20:", ":25:", ":28C:", ":61:"] + return all(tag in content for tag in required_tags) + + def parse_data_from_template(raw_data): data = [] diff --git a/pyproject.toml b/pyproject.toml index 979a9259048..5fb1a0ad272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ dependencies = [ # Not used directly - required by PyQRCode for PNG generation "pypng~=0.20220715.0", + + # MT940 parser for bank statements + "mt-940>=4.26.0" ] [build-system]