Repository: siddhantgoel/beancount-dkb

Issue:
New onlinebanking renamed CSV fields
DKBs new online banking renamed the fields in the CSV:

## Checking

Old columns

    "Buchungstag";"Wertstellung";"Buchungstext";"Auftraggeber / Beg�nstigter";"Verwendungszweck";"Kontonummer";"BLZ";"Betrag (EUR)";"Gl�ubiger-ID";"Mandatsreferenz";"Kundenreferenz";

New columns

    "Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"Betrag";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"

## Tagesgeld

Old columns

    "Umsatz abgerechnet und nicht im Saldo enthalten";"Wertstellung";"Belegdatum";"Beschreibung";"Betrag (EUR)";"Urspr�nglicher Betrag";

New columns

    "Belegdatum";"Wertstellung";"Status";"Beschreibung";"Umsatztyp";"Betrag";"Fremdwährungsbetrag"

Discussion:

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 85763ec..c0afccd 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
     steps:
     - uses: actions/checkout@v3
 
diff --git a/beancount_dkb/__init__.py b/beancount_dkb/__init__.py
index adeea8d..b1a0e33 100644
--- a/beancount_dkb/__init__.py
+++ b/beancount_dkb/__init__.py
@@ -1,3 +1,3 @@
 from .credit import CreditImporter  # NOQA
 from .ec import ECImporter  # NOQA
-from .helpers import InvalidFormatError  # NOQA
+from .exceptions import InvalidFormatError  # NOQA
diff --git a/beancount_dkb/credit.py b/beancount_dkb/credit.py
index 23818c4..61faa75 100644
--- a/beancount_dkb/credit.py
+++ b/beancount_dkb/credit.py
@@ -1,4 +1,5 @@
 import csv
+from typing import Dict
 from datetime import datetime, timedelta
 
 from beancount.core import data
@@ -6,16 +7,9 @@ from beancount.core.amount import Amount
 from beancount.core.number import Decimal
 from beancount.ingest import importer
 
-from .helpers import AccountMatcher, fmt_number_de, InvalidFormatError
-
-FIELDS = (
-    "Umsatz abgerechnet und nicht im Saldo enthalten",
-    "Wertstellung",
-    "Belegdatum",
-    "Beschreibung",
-    "Betrag (EUR)",
-    "Ursprünglicher Betrag",
-)
+from .exceptions import InvalidFormatError
+from .extractors.credit import V1Extractor, V2Extractor
+from .helpers import AccountMatcher, fmt_number_de
 
 
 class CreditImporter(importer.ImporterProtocol):
@@ -33,11 +27,8 @@ class CreditImporter(importer.ImporterProtocol):
         self.file_encoding = file_encoding
         self.description_matcher = AccountMatcher(description_patterns)
 
-        self._expected_headers = (
-            '"Kreditkarte:";"{} Kreditkarte";'.format(self.card_number),
-            '"Kreditkarte:";"{}";'.format(self.card_number),
-            f'"Kreditkarte:";"{card_number[:4]}********{card_number[-4:]}";',
-        )
+        self._v1_extractor = V1Extractor(self.card_number)
+        self._v2_extractor = V2Extractor(self.card_number)
 
         self._date_from = None
         self._date_to = None
@@ -62,6 +53,7 @@ class CreditImporter(importer.ImporterProtocol):
 
         self._balance_date = None
         self._balance_amount = None
+        self._closing_balance_index = -1
 
     def name(self):
         return "DKB {}".format(self.__class__.__name__)
@@ -69,8 +61,8 @@ class CreditImporter(importer.ImporterProtocol):
     def file_account(self, _):
         return self.account
 
-    def file_date(self, file_):
-        self.extract(file_)
+    def file_date(self, file):
+        self.extract(file)
 
         # in case the file contains start/end dates, return the end date
         # if not, then the file was based on a time period (Zeitraum), so we
@@ -78,56 +70,31 @@ class CreditImporter(importer.ImporterProtocol):
 
         return self._date_to or self._file_date
 
-    def is_valid_header(self, line):
-        return any(line.startswith(header) for header in self._expected_headers)
-
-    def identify(self, file_):
-        with open(file_.name, encoding=self.file_encoding) as fd:
+    def identify(self, file):
+        with open(file.name, encoding=self.file_encoding) as fd:
             line = fd.readline().strip()
 
-        return self.is_valid_header(line)
+        return self._v1_extractor.matches_header(
+            line
+        ) or self._v2_extractor.matches_header(line)
 
-    def extract(self, file_, existing_entries=None):
+    def extract(self, file, existing_entries=None):
         entries = []
         line_index = 0
-        closing_balance_index = -1
 
-        with open(file_.name, encoding=self.file_encoding) as fd:
-            self._extract_header(fd)
+        with open(file.name, encoding=self.file_encoding) as fd:
+            extractor = self._get_extractor(fd.readline().strip())
+
             line_index += 1
 
-            self._extract_empty_line(fd)
+            extractor.extract_empty_line(fd)
             line_index += 1
 
             # Read metadata lines until the next empty line
-            lines = []
-
-            for line in fd:
-                if not line.strip():
-                    break
-                lines.append(line)
-
-            # Meta
-            reader = csv.reader(
-                lines, delimiter=";", quoting=csv.QUOTE_MINIMAL, quotechar='"'
-            )
 
-            for line in reader:
-                key, value, _ = line
-                line_index += 1
-
-                if key.startswith("Von"):
-                    self._date_from = datetime.strptime(value, "%d.%m.%Y").date()
-                elif key.startswith("Bis"):
-                    self._date_to = datetime.strptime(value, "%d.%m.%Y").date()
-                elif key.startswith("Saldo"):
-                    self._balance_amount = Amount(
-                        Decimal(value.rstrip(" EUR")), self.currency
-                    )
-                    closing_balance_index = line_index
-                elif key.startswith("Datum"):
-                    self._file_date = datetime.strptime(value, "%d.%m.%Y").date()
-                    self._balance_date = self._file_date + timedelta(days=1)
+            meta = extractor.extract_meta(fd, line_index)
+            self._update_meta(meta)
+            line_index += len(meta) + 1
 
             # Data entries
             reader = csv.DictReader(
@@ -135,13 +102,15 @@ class CreditImporter(importer.ImporterProtocol):
             )
 
             for index, line in enumerate(reader):
-                meta = data.new_metadata(file_.name, index)
+                meta = data.new_metadata(file.name, index)
 
-                amount = Amount(fmt_number_de(line["Betrag (EUR)"]), self.currency)
+                amount = Amount(
+                    fmt_number_de(extractor.get_amount(line)), self.currency
+                )
 
-                date = datetime.strptime(line["Wertstellung"], "%d.%m.%Y").date()
+                date = extractor.get_valuation_date(line)
 
-                description = line["Beschreibung"]
+                description = extractor.get_description(line)
 
                 postings = [data.Posting(self.account, amount, None, None, None, None)]
 
@@ -171,7 +140,7 @@ class CreditImporter(importer.ImporterProtocol):
                 )
 
             # Closing Balance
-            meta = data.new_metadata(file_.name, closing_balance_index)
+            meta = data.new_metadata(file.name, self._closing_balance_index)
             entries.append(
                 data.Balance(
                     meta,
@@ -185,14 +154,30 @@ class CreditImporter(importer.ImporterProtocol):
 
         return entries
 
-    def _extract_header(self, fd):
-        line = fd.readline().strip()
-
-        if not self.is_valid_header(line):
-            raise InvalidFormatError()
-
-    def _extract_empty_line(self, fd):
-        line = fd.readline().strip()
-
-        if line:
-            raise InvalidFormatError()
+    def _get_extractor(self, line: str):
+        if self._v1_extractor.matches_header(line):
+            return self._v1_extractor
+        elif self._v2_extractor.matches_header(line):
+            return self._v2_extractor
+
+        raise InvalidFormatError()
+
+    def _update_meta(self, meta: Dict[str, str]):
+        for key, value in meta.items():
+            if key.startswith("Von"):
+                self._date_from = datetime.strptime(value.value, "%d.%m.%Y").date()
+            elif key.startswith("Bis"):
+                self._date_to = datetime.strptime(value.value, "%d.%m.%Y").date()
+            elif key.startswith("Saldo"):
+                self._balance_amount = Amount(
+                    Decimal(value.value.rstrip(" EUR")), self.currency
+                )
+                self._closing_balance_index = value.line_index
+                if key.startswith("Saldo vom"):
+                    self._balance_date = datetime.strptime(
+                        key.replace("Saldo vom ", "").replace(":", ""),
+                        "%d.%m.%Y",
+                    ).date()
+            elif key.startswith("Datum"):
+                self._file_date = datetime.strptime(value.value, "%d.%m.%Y").date()
+                self._balance_date = self._file_date + timedelta(days=1)
diff --git a/beancount_dkb/ec.py b/beancount_dkb/ec.py
index cfab0c4..9506733 100644
--- a/beancount_dkb/ec.py
+++ b/beancount_dkb/ec.py
@@ -1,7 +1,6 @@
 import csv
-import re
 from datetime import datetime, timedelta
-from typing import Optional, Sequence
+from typing import Optional, Sequence, Dict
 from functools import partial
 import warnings
 
@@ -9,28 +8,13 @@ from beancount.core import data
 from beancount.core.amount import Amount
 from beancount.ingest import importer
 
-from .helpers import AccountMatcher, fmt_number_de, InvalidFormatError
-
-
-FIELDS = (
-    "Buchungstag",
-    "Wertstellung",
-    "Buchungstext",
-    "Auftraggeber / Begünstigter",
-    "Verwendungszweck",
-    "Kontonummer",
-    "BLZ",
-    "Betrag (EUR)",
-    "Gläubiger-ID",
-    "Mandatsreferenz",
-    "Kundenreferenz",
-)
+from .exceptions import InvalidFormatError
+from .helpers import AccountMatcher, fmt_number_de
+from .extractors.ec import V1Extractor, V2Extractor
 
 
 new_posting = partial(data.Posting, cost=None, price=None, flag=None, meta=None)
 
-META_LINE_COUNT = 3
-
 
 class ECImporter(importer.ImporterProtocol):
     def __init__(
@@ -43,6 +27,7 @@ class ECImporter(importer.ImporterProtocol):
         payee_patterns: Optional[Sequence] = None,
         description_patterns: Optional[Sequence] = None,
     ):
+        self.iban = iban
         self.account = account
         self.currency = currency
         self.file_encoding = file_encoding
@@ -50,12 +35,9 @@ class ECImporter(importer.ImporterProtocol):
         self.payee_matcher = AccountMatcher(payee_patterns)
         self.description_matcher = AccountMatcher(description_patterns)
 
-        self._expected_header_regex = re.compile(
-            r'^"Kontonummer:";"'
-            + re.escape(re.sub(r"\s+", "", iban, flags=re.UNICODE))
-            + r"\s",
-            re.IGNORECASE,
-        )
+        self._v1_extractor = V1Extractor(iban, meta_code)
+        self._v2_extractor = V2Extractor(iban, meta_code)
+
         self._date_from = None
         self._date_to = None
         self._balance_amount = None
@@ -68,34 +50,39 @@ class ECImporter(importer.ImporterProtocol):
     def file_account(self, _):
         return self.account
 
-    def file_date(self, file_):
-        self.extract(file_)
+    def file_date(self, file):
+        self.extract(file)
 
         return self._date_to
 
-    def identify(self, file_):
-        with open(file_.name, encoding=self.file_encoding) as fd:
+    def identify(self, file):
+        with open(file.name, encoding=self.file_encoding) as fd:
             line = fd.readline().strip()
 
-        return self._expected_header_regex.match(line)
+        return self._v1_extractor.matches_header(
+            line
+        ) or self._v2_extractor.matches_header(line)
 
-    def extract(self, file_, existing_entries=None):
+    def extract(self, file, existing_entries=None):
         entries = []
         line_index = 0
 
-        with open(file_.name, encoding=self.file_encoding) as fd:
-            self._extract_header(fd)
-            line_index += 1
+        with open(file.name, encoding=self.file_encoding) as fd:
+            extractor = self._get_extractor(fd.readline().strip())
 
-            self._extract_empty_line(fd)
             line_index += 1
 
-            self._extract_meta(fd, line_index)
-            line_index += META_LINE_COUNT
+            # empty line
 
-            self._extract_empty_line(fd)
+            extractor.extract_empty_line(fd)
             line_index += 1
 
+            # Read metadata lines until the next empty line
+
+            meta = extractor.extract_meta(fd, line_index)
+            self._update_meta(meta)
+            line_index += len(meta) + 1
+
             # Data entries
             reader = csv.DictReader(
                 fd, delimiter=";", quoting=csv.QUOTE_MINIMAL, quotechar='"'
@@ -104,14 +91,17 @@ class ECImporter(importer.ImporterProtocol):
             for line in reader:
                 line_index += 1
 
-                meta = data.new_metadata(file_.name, line_index)
+                meta = data.new_metadata(file.name, line_index)
 
                 amount = None
-                if line["Betrag (EUR)"]:
-                    amount = Amount(fmt_number_de(line["Betrag (EUR)"]), self.currency)
-                date = datetime.strptime(line["Buchungstag"], "%d.%m.%Y").date()
+                if extractor.get_amount(line):
+                    amount = Amount(
+                        fmt_number_de(extractor.get_amount(line)), self.currency
+                    )
+
+                date = extractor.get_booking_date(line)
 
-                if line["Verwendungszweck"] == "Tagessaldo":
+                if extractor.get_purpose(line) == "Tagessaldo":
                     if amount:
                         entries.append(
                             data.Balance(
@@ -124,18 +114,11 @@ class ECImporter(importer.ImporterProtocol):
                             )
                         )
                 else:
-                    verwendungszweck = line["Verwendungszweck"] or line["Kontonummer"]
-                    buchungstext = line["Buchungstext"]
-                    payee = line["Auftraggeber / Begünstigter"]
-
                     if self.meta_code:
-                        meta[self.meta_code] = buchungstext
-                        description = verwendungszweck
-                    else:
-                        description = "{} {}".format(
-                            buchungstext,
-                            verwendungszweck,
-                        )
+                        meta[self.meta_code] = extractor.get_booking_text(line)
+
+                    description = extractor.get_description(line)
+                    payee = extractor.get_payee(line)
 
                     postings = [
                         new_posting(account=self.account, units=amount),
@@ -191,7 +174,7 @@ class ECImporter(importer.ImporterProtocol):
             # Closing Balance
             entries.append(
                 data.Balance(
-                    data.new_metadata(file_.name, self._closing_balance_index),
+                    data.new_metadata(file.name, self._closing_balance_index),
                     self._balance_date,
                     self.account,
                     self._balance_amount,
@@ -202,32 +185,20 @@ class ECImporter(importer.ImporterProtocol):
 
         return entries
 
-    def _extract_header(self, fd):
-        line = fd.readline().strip()
-
-        if not self._expected_header_regex.match(line):
-            raise InvalidFormatError()
-
-    def _extract_empty_line(self, fd):
-        line = fd.readline().strip()
-
-        if line:
-            raise InvalidFormatError()
-
-    def _extract_meta(self, fd, line_index):
-        lines = [fd.readline().strip() for _ in range(META_LINE_COUNT)]
-
-        reader = csv.reader(
-            lines, delimiter=";", quoting=csv.QUOTE_MINIMAL, quotechar='"'
-        )
+    def _get_extractor(self, line: str):
+        if self._v1_extractor.matches_header(line):
+            return self._v1_extractor
+        elif self._v2_extractor.matches_header(line):
+            return self._v2_extractor
 
-        for index, line in enumerate(reader):
-            key, value, _ = line
+        raise InvalidFormatError()
 
+    def _update_meta(self, meta: Dict[str, str]):
+        for key, value in meta.items():
             if key.startswith("Von"):
-                self._date_from = datetime.strptime(value, "%d.%m.%Y").date()
+                self._date_from = datetime.strptime(value.value, "%d.%m.%Y").date()
             elif key.startswith("Bis"):
-                self._date_to = datetime.strptime(value, "%d.%m.%Y").date()
+                self._date_to = datetime.strptime(value.value, "%d.%m.%Y").date()
             elif key.startswith("Kontostand vom"):
                 # Beancount expects the balance amount to be from the
                 # beginning of the day, while the Tagessaldo entries in
@@ -237,9 +208,9 @@ class ECImporter(importer.ImporterProtocol):
                 # assertions work.
 
                 self._balance_amount = Amount(
-                    fmt_number_de(value.rstrip(" EUR")), self.currency
+                    fmt_number_de(value.value.rstrip(" EUR")), self.currency
                 )
                 self._balance_date = datetime.strptime(
                     key.lstrip("Kontostand vom ").rstrip(":"), "%d.%m.%Y"
                 ).date() + timedelta(days=1)
-                self._closing_balance_index = index + line_index
+                self._closing_balance_index = value.line_index
diff --git a/beancount_dkb/exceptions.py b/beancount_dkb/exceptions.py
new file mode 100644
index 0000000..bcc115b
--- /dev/null
+++ b/beancount_dkb/exceptions.py
@@ -0,0 +1,2 @@
+class InvalidFormatError(Exception):
+    pass
diff --git a/beancount_dkb/extractors/__init__.py b/beancount_dkb/extractors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/beancount_dkb/extractors/credit.py b/beancount_dkb/extractors/credit.py
new file mode 100644
index 0000000..53e9a59
--- /dev/null
+++ b/beancount_dkb/extractors/credit.py
@@ -0,0 +1,129 @@
+from datetime import date, datetime
+from collections import namedtuple
+import csv
+from typing import Dict, IO
+
+from ..exceptions import InvalidFormatError
+
+
+Meta = namedtuple("Meta", ["value", "line_index"])
+
+
+class BaseExtractor:
+    def matches_header(self, line: str) -> bool:
+        """Return true if the line matches the expected header for this extractor"""
+
+        raise NotImplementedError()
+
+    def is_empty_line(self, fd: IO) -> bool:
+        """Return true if the line is an empty line"""
+
+        raise NotImplementedError()
+
+    def extract_header(self, fd: IO):
+        line = fd.readline().strip()
+
+        if not self.matches_header(line):
+            raise InvalidFormatError()
+
+    def extract_empty_line(self, fd: IO):
+        line = fd.readline().strip()
+
+        if not self.is_empty_line(line):
+            raise InvalidFormatError("Expected empty line")
+
+    def extract_meta(self, fd: IO, line_index: int) -> Dict[str, Meta]:
+        lines = []
+
+        for line in fd:
+            if self.is_empty_line(line.strip()):
+                break
+            lines.append(line)
+
+        meta = {}
+        reader = csv.reader(
+            lines, delimiter=";", quoting=csv.QUOTE_MINIMAL, quotechar='"'
+        )
+
+        for index, line in enumerate(reader):
+            key, value, *_ = line
+
+            meta[key] = Meta(value, line_index + index)
+
+        return meta
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_valuation_date(self, line: Dict[str, str]) -> date:
+        raise NotImplementedError()
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+
+class V1Extractor(BaseExtractor):
+    """Extractor for DKB online banking interface available before 2023"""
+
+    FIELDS = (
+        "Umsatz abgerechnet und nicht im Saldo enthalten",
+        "Wertstellung",
+        "Belegdatum",
+        "Beschreibung",
+        "Betrag (EUR)",
+        "Ursprünglicher Betrag",
+    )
+
+    def __init__(self, card_number: str):
+        self._expected_header_prefixes = (
+            '"Kreditkarte:";"{} Kreditkarte";'.format(card_number),
+            '"Kreditkarte:";"{}";'.format(card_number),
+            f'"Kreditkarte:";"{card_number[:4]}********{card_number[-4:]}";',
+        )
+
+    def is_empty_line(self, line: str) -> bool:
+        return line == ""
+
+    def matches_header(self, line: str) -> bool:
+        return any(line.startswith(header) for header in self._expected_header_prefixes)
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        return line["Betrag (EUR)"]
+
+    def get_valuation_date(self, line: Dict[str, str]) -> date:
+        return datetime.strptime(line["Wertstellung"], "%d.%m.%Y").date()
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        return line["Beschreibung"]
+
+
+class V2Extractor(BaseExtractor):
+    """Extractor for DKB online banking interface available before 2023"""
+
+    FIELDS = (
+        "Belegdatum",
+        "Wertstellung",
+        "Status",
+        "Beschreibung",
+        "Umsatztyp",
+        "Betrag",
+        "Fremdwährungsbetrag",
+    )
+
+    def __init__(self, card_number: str):
+        self._expected_header_prefix = f'"Karte";"Visa-Kreditkarte {card_number[:4]}'
+
+    def is_empty_line(self, line: str) -> bool:
+        return line == '""'
+
+    def matches_header(self, line: str) -> bool:
+        return line.startswith(self._expected_header_prefix)
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        return line["Betrag"].rstrip(" €")
+
+    def get_valuation_date(self, line: Dict[str, str]) -> date:
+        return datetime.strptime(line["Wertstellung"], "%d.%m.%y").date()
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        return line["Beschreibung"]
diff --git a/beancount_dkb/extractors/ec.py b/beancount_dkb/extractors/ec.py
new file mode 100644
index 0000000..0827cfa
--- /dev/null
+++ b/beancount_dkb/extractors/ec.py
@@ -0,0 +1,195 @@
+from datetime import date, datetime
+import csv
+from collections import namedtuple
+import re
+from typing import Optional, IO, Dict
+
+from ..exceptions import InvalidFormatError
+
+
+Meta = namedtuple("Meta", ["value", "line_index"])
+
+
+class BaseExtractor:
+    def matches_header(self, line: str) -> bool:
+        """Return true if the line matches the expected header for this extractor"""
+
+        raise NotImplementedError()
+
+    def is_empty_line(self, line: str) -> bool:
+        """Return true if the line is an empty line"""
+
+        raise NotImplementedError()
+
+    def extract_header(self, fd: IO):
+        line = fd.readline().strip()
+
+        if not self.matches_header(line):
+            raise InvalidFormatError()
+
+    def extract_empty_line(self, fd: IO):
+        line = fd.readline().strip()
+
+        if not self.is_empty_line(line):
+            raise InvalidFormatError("Expected empty line")
+
+    def extract_meta(self, fd: IO, line_index: int) -> Dict[str, Meta]:
+        lines = []
+
+        for line in fd:
+            if self.is_empty_line(line.strip()):
+                break
+            lines.append(line)
+
+        meta = {}
+        reader = csv.reader(
+            lines, delimiter=";", quoting=csv.QUOTE_MINIMAL, quotechar='"'
+        )
+
+        for index, line in enumerate(reader):
+            key, value, *_ = line
+
+            meta[key] = Meta(value, line_index + index)
+
+        return meta
+
+    def get_account_number(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_booking_date(self, line: Dict[str, str]) -> date:
+        raise NotImplementedError()
+
+    def get_booking_text(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_payee(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+    def get_purpose(self, line: Dict[str, str]) -> str:
+        raise NotImplementedError()
+
+
+class V1Extractor(BaseExtractor):
+    """Extractor for DKB online banking interface available before 2023"""
+
+    FIELDS = (
+        "Buchungstag",
+        "Wertstellung",
+        "Buchungstext",
+        "Auftraggeber / Begünstigter",
+        "Verwendungszweck",
+        "Kontonummer",
+        "BLZ",
+        "Betrag (EUR)",
+        "Gläubiger-ID",
+        "Mandatsreferenz",
+        "Kundenreferenz",
+    )
+
+    def __init__(self, iban: str, meta_code: Optional[str] = None):
+        self._expected_header_regex = re.compile(
+            r'^"Kontonummer:";"'
+            + re.escape(re.sub(r"\s+", "", iban, flags=re.UNICODE))
+            + r"\s",
+            re.IGNORECASE,
+        )
+        self.meta_code = meta_code
+
+    def matches_header(self, line: str) -> bool:
+        return self._expected_header_regex.match(line)
+
+    def is_empty_line(self, line: str) -> bool:
+        return line == ""
+
+    def get_account_number(self, line: Dict[str, str]) -> str:
+        return line["Kontonummer"]
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        return line["Betrag (EUR)"]
+
+    def get_booking_date(self, line: Dict[str, str]) -> date:
+        return datetime.strptime(line["Buchungstag"], "%d.%m.%Y").date()
+
+    def get_booking_text(self, line: Dict[str, str]) -> str:
+        return line["Buchungstext"]
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        purpose = self.get_purpose(line) or self.get_account_number(line)
+        booking_text = self.get_booking_text(line)
+
+        return f"{booking_text} {purpose}" if not self.meta_code else purpose
+
+    def get_payee(self, line: Dict[str, str]) -> str:
+        return line["Auftraggeber / Begünstigter"]
+
+    def get_purpose(self, line: Dict[str, str]) -> str:
+        return line["Verwendungszweck"]
+
+
+class V2Extractor(BaseExtractor):
+    """Extractor for DKB online banking interface introduced in 2023"""
+
+    FIELDS = (
+        "Buchungsdatum",
+        "Wertstellung",
+        "Status",
+        "Zahlungspflichtige*r",
+        "Zahlungsempfänger*in",
+        "Verwendungszweck",
+        "Umsatztyp",
+        "Betrag",
+        "Gläubiger-ID",
+        "Mandatsreferenz",
+        "Kundenreferenz",
+    )
+
+    def __init__(self, iban: str, meta_code: Optional[str] = None):
+        self._expected_header_regex = re.compile(
+            r'^"Konto";"Girokonto '
+            + re.escape(re.sub(r"\s+", "", iban, flags=re.UNICODE)),
+            re.IGNORECASE,
+        )
+        self.meta_code = meta_code
+
+    def matches_header(self, line: str) -> bool:
+        return self._expected_header_regex.match(line)
+
+    def is_empty_line(self, line: str) -> bool:
+        return line == '""'
+
+    def get_account_number(self, line: Dict[str, str]) -> str:
+        return line["Gläubiger-ID"]
+
+    def get_amount(self, line: Dict[str, str]) -> str:
+        return line["Betrag"].rstrip(" €")
+
+    def get_booking_date(self, line: Dict[str, str]) -> date:
+        return datetime.strptime(line["Buchungsdatum"], "%d.%m.%y").date()
+
+    def get_booking_text(self, line: Dict[str, str]) -> str:
+        return line["Umsatztyp"]
+
+    def get_description(self, line: Dict[str, str]) -> str:
+        return self.get_purpose(line)
+
+    def get_payee(self, line: Dict[str, str]) -> str:
+        type_ = line["Umsatztyp"]
+
+        # if money is going out then payee should be the receiver
+        # otherwise if money is coming in then payee should be the sender
+
+        if type_ == "Ausgang":
+            return line["Zahlungsempfänger*in"]
+        elif type_ == "Eingang":
+            return line["Zahlungspflichtige*r"]
+
+        raise InvalidFormatError(f"Unknown Umsatztyp: {type_}")
+
+    def get_purpose(self, line: Dict[str, str]) -> str:
+        return line["Verwendungszweck"]
diff --git a/beancount_dkb/helpers.py b/beancount_dkb/helpers.py
index 0188986..65cb0e2 100644
--- a/beancount_dkb/helpers.py
+++ b/beancount_dkb/helpers.py
@@ -5,10 +5,6 @@ from typing import Optional, Sequence
 from beancount.core.number import Decimal
 
 
-class InvalidFormatError(Exception):
-    pass
-
-
 def fmt_number_de(value: str) -> Decimal:
     thousands_sep = "."
     decimal_sep = ","
diff --git a/pyproject.toml b/pyproject.toml
index e5a9247..b5a9aee 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,7 @@ black = "^23.3"
 taskipy = "^1.12.0"
 
 [tool.taskipy.tasks]
-fmt-black = "black beancount_dkb/ tests/"
+fmt = "black beancount_dkb/ tests/"
 
 lint-black  = "black --check beancount_dkb/ tests/"
 lint-flake8 = "flake8 beancount_dkb/ tests/"


Test patch:
diff --git a/tests/test_credit.py b/tests/test_credit_v1.py
similarity index 98%
rename from tests/test_credit.py
rename to tests/test_credit_v1.py
index 6252286..177ec8b 100644
--- a/tests/test_credit.py
+++ b/tests/test_credit_v1.py
@@ -6,11 +6,11 @@ from beancount.core.data import Amount, Balance
 import pytest
 
 from beancount_dkb import CreditImporter
-from beancount_dkb.credit import FIELDS
+from beancount_dkb.extractors.credit import V1Extractor
 
 CARD_NUMBER = "1234********5678"
 
-HEADER = ";".join('"{}"'.format(field) for field in FIELDS)
+HEADER = ";".join('"{}"'.format(field) for field in V1Extractor.FIELDS)
 
 
 def _format(string, kwargs):
diff --git a/tests/test_credit_v2.py b/tests/test_credit_v2.py
new file mode 100644
index 0000000..9fcc758
--- /dev/null
+++ b/tests/test_credit_v2.py
@@ -0,0 +1,201 @@
+import datetime
+from decimal import Decimal
+from textwrap import dedent
+
+from beancount.core.data import Amount, Balance
+import pytest
+
+from beancount_dkb import CreditImporter
+from beancount_dkb.extractors.credit import V2Extractor
+
+CARD_NUMBER = "1234 •••• •••• 5678"
+
+HEADER = ";".join('"{}"'.format(field) for field in V2Extractor.FIELDS)
+
+
+def _format(string, kwargs):
+    return dedent(string).format(**kwargs).lstrip()
+
+
+@pytest.fixture
+def tmp_file(tmp_path):
+    return tmp_path / f"{CARD_NUMBER}.csv"
+
+
+def test_identify_correct(tmp_file):
+    importer = CreditImporter(CARD_NUMBER, "Assets:DKB:Credit")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}"
+            ""
+            "Saldo vom:";"5000.01 EUR"
+            ""
+            {header};
+            """,
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        assert importer.identify(fd)
+
+
+def test_identify_prefixes(tmp_file):
+    importer = CreditImporter(CARD_NUMBER, "Assets:DKB:Credit")
+
+    prefix = CARD_NUMBER[:4]
+    suffix = CARD_NUMBER[-4:]
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {prefix} •••• •••• {suffix}"
+            ""
+            "Saldo:";"5000.01 EUR"
+            ""
+            {header};
+            """,
+            dict(prefix=prefix, suffix=suffix, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        assert importer.identify(fd)
+
+
+def test_identify_invalid_iban(tmp_file):
+    other_iban = "5678 •••• •••• 1234"
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}"
+            ""
+            "Saldo:";"5000.01 EUR"
+            ""
+            {header};
+            """,
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    importer = CreditImporter(other_iban, "Assets:DKB:Credit")
+
+    with tmp_file.open() as fd:
+        assert not importer.identify(fd)
+
+
+def test_extract_no_transactions(tmp_file):
+    importer = CreditImporter(CARD_NUMBER, "Assets:DKB:Credit")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}"
+            ""
+            "Saldo vom 31.01.2023:";"5000.01 EUR"
+            ""
+            {header};
+            """,
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 1
+    assert isinstance(directives[0], Balance)
+    assert directives[0].date == datetime.date(2023, 1, 31)
+    assert directives[0].amount == Amount(Decimal("5000.01"), currency="EUR")
+
+
+def test_extract_transactions(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}"
+            ""
+            "Saldo:";"5000.01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"REWE Filiale Muenchen";"Im Geschäft";"-10,80 €";""
+            """,  # NOQA
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    importer = CreditImporter(CARD_NUMBER, "Assets:DKB:Credit", file_encoding="utf-8")
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert directives[0].date == datetime.date(2023, 1, 15)
+
+    assert len(directives[0].postings) == 1
+    assert directives[0].postings[0].account == "Assets:DKB:Credit"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-10.80")
+
+
+def test_emits_closing_balance_directive(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}"
+            ""
+            "Saldo vom 31.01.2023:";"5000.01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"REWE Filiale Muenchen";"Im Geschäft";"-10,80 €";""
+            """,  # NOQA
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    importer = CreditImporter(CARD_NUMBER, "Assets:DKB:Credit", file_encoding="utf-8")
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert isinstance(directives[1], Balance)
+    assert directives[1].date == datetime.date(2023, 1, 31)
+    assert directives[1].amount == Amount(Decimal("5000.01"), currency="EUR")
+
+
+def test_extract_with_description_patterns(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Karte";"Visa-Kreditkarte {card_number}";
+            ""
+            "Saldo:";"5000.01 EUR";
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"REWE Filiale Muenchen";"Im Geschäft";"-10,80 €";""
+            """,  # NOQA
+            dict(card_number=CARD_NUMBER, header=HEADER),
+        )
+    )
+
+    importer = CreditImporter(
+        CARD_NUMBER,
+        "Assets:DKB:Credit",
+        file_encoding="utf-8",
+        description_patterns=[("REWE Filiale", "Expenses:Supermarket:REWE")],
+    )
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert len(directives[0].postings) == 2
+    assert directives[0].postings[0].account == "Assets:DKB:Credit"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-10.80")
+
+    assert directives[0].postings[1].account == "Expenses:Supermarket:REWE"
+    assert directives[0].postings[1].units is None
diff --git a/tests/test_ec.py b/tests/test_ec_v1.py
similarity index 99%
rename from tests/test_ec.py
rename to tests/test_ec_v1.py
index 1899b18..7af3595 100644
--- a/tests/test_ec.py
+++ b/tests/test_ec_v1.py
@@ -6,14 +6,14 @@ from beancount.core.data import Amount, Balance
 import pytest
 
 from beancount_dkb import ECImporter
-from beancount_dkb.ec import FIELDS
+from beancount_dkb.extractors.ec import V1Extractor
 
 
 FORMATTED_IBAN = "DE99 9999 9999 9999 9999 99"
 
 IBAN = FORMATTED_IBAN.replace(" ", "")
 
-HEADER = ";".join('"{}"'.format(field) for field in FIELDS)
+HEADER = ";".join('"{}"'.format(field) for field in V1Extractor.FIELDS)
 
 
 def _format(string, kwargs):
diff --git a/tests/test_ec_v2.py b/tests/test_ec_v2.py
new file mode 100644
index 0000000..7adc332
--- /dev/null
+++ b/tests/test_ec_v2.py
@@ -0,0 +1,388 @@
+import datetime
+from decimal import Decimal
+from textwrap import dedent
+
+from beancount.core.data import Amount, Balance
+import pytest
+
+from beancount_dkb import ECImporter
+from beancount_dkb.ec import V2Extractor
+
+
+FORMATTED_IBAN = "DE99 9999 9999 9999 9999 99"
+
+IBAN = FORMATTED_IBAN.replace(" ", "")
+
+HEADER = ";".join('"{}"'.format(field) for field in V2Extractor.FIELDS)
+
+
+def _format(string, kwargs):
+    return dedent(string).format(**kwargs).lstrip()
+
+
+@pytest.fixture
+def tmp_file(tmp_path):
+    return tmp_path / f"{IBAN}.csv"
+
+
+def test_identify_correct(tmp_file):
+    importer = ECImporter(IBAN, "Assets:DKB:EC")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}";
+            ""
+            "Von:";"01.01.2023";
+            "Bis:";"31.01.2023";
+            "Kontostand vom 31.01.2023:";"5.000,01 EUR";
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"2023-01-15T10:10";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        assert importer.identify(fd)
+
+
+def test_identify_with_nonstandard_account_name(tmp_file):
+    importer = ECImporter(IBAN, "Assets:DKB:EC")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}";
+            ""
+            "Von:";"01.01.2023";
+            "Bis:";"31.01.2023";
+            "Kontostand vom 31.01.2023:";"5.000,01 EUR";
+            ""
+            {header};
+            """,
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        assert importer.identify(fd)
+
+
+def test_identify_with_formatted_iban(tmp_file):
+    importer = ECImporter(FORMATTED_IBAN, "Assets:DKB:EC")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}";
+            ""
+            "Von:";"01.01.2023";
+            "Bis:";"31.01.2023";
+            "Kontostand vom 31.01.2023:";"5.000,01 EUR";
+            ""
+            {header};
+            """,
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        assert importer.identify(fd)
+
+
+def test_identify_invalid_iban(tmp_file):
+    other_iban = "DE00000000000000000000"
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}";
+            ""
+            "Von:";"01.01.2023";
+            "Bis:";"31.01.2023";
+            "Kontostand vom 31.01.2023:";"5.000,01 EUR";
+            ""
+            {header};
+            """,
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(other_iban, "Assets:DKB:EC")
+
+    with tmp_file.open() as fd:
+        assert not importer.identify(fd)
+
+
+def test_extract_no_transactions(tmp_file):
+    importer = ECImporter(IBAN, "Assets:DKB:EC")
+
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}";
+            ""
+            "Von:";"01.01.2023";
+            "Bis:";"31.01.2023";
+            "Kontostand vom 31.01.2023:";"5.000,01 EUR";
+            ""
+            {header};
+            """,
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 1
+    assert isinstance(directives[0], Balance)
+    assert directives[0].date == datetime.date(2023, 2, 1)
+    assert directives[0].amount == Amount(Decimal("5000.01"), currency="EUR")
+
+
+def test_extract_transactions(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header}
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            "20.01.23";"20.01.23";"Gebucht";"COMPANY INC";"MAX MUSTERMANN";"Money";"Eingang";"100,00 €";"";"";""
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(IBAN, "Assets:DKB:EC", file_encoding="utf-8")
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 3
+
+    # first directive
+
+    assert directives[0].date == datetime.date(2023, 1, 15)
+    assert directives[0].payee == "EDEKA//MUENCHEN/DE"
+    assert directives[0].narration == "EDEKA SAGT DANKE"
+
+    assert len(directives[0].postings) == 1
+    assert directives[0].postings[0].account == "Assets:DKB:EC"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-9.56")
+
+    # second directive
+
+    assert directives[1].date == datetime.date(2023, 1, 20)
+    assert directives[1].payee == "COMPANY INC"
+    assert directives[1].narration == "Money"
+
+    assert len(directives[1].postings) == 1
+    assert directives[1].postings[0].account == "Assets:DKB:EC"
+    assert directives[1].postings[0].units.currency == "EUR"
+    assert directives[1].postings[0].units.number == Decimal("100.00")
+
+
+def test_extract_sets_timestamps(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(IBAN, "Assets:DKB:EC", file_encoding="utf-8")
+
+    assert not importer._date_from
+    assert not importer._date_to
+    assert not importer._balance_amount
+    assert not importer._balance_date
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert directives
+    assert importer._balance_amount == Amount(Decimal("5001.01"), currency="EUR")
+    assert importer._balance_date == datetime.date(2023, 2, 1)
+
+
+def test_emits_closing_balance_directive(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(IBAN, "Assets:DKB:EC", file_encoding="utf-8")
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert isinstance(directives[1], Balance)
+    assert directives[1].date == datetime.date(2023, 2, 1)
+    assert directives[1].amount == Amount(Decimal("5001.01"), currency="EUR")
+    assert directives[1].meta["lineno"] == 2
+
+
+def test_meta_code_is_added(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(
+        IBAN, "Assets:DKB:EC", file_encoding="utf-8", meta_code="code"
+    )
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert directives[0].date == datetime.date(2023, 1, 15)
+    assert directives[0].payee == "EDEKA//MUENCHEN/DE"
+    assert directives[0].narration == "EDEKA SAGT DANKE"
+    assert directives[0].meta["code"] == "Ausgang"
+
+
+def test_extract_with_payee_patterns(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(
+        IBAN,
+        "Assets:DKB:EC",
+        file_encoding="utf-8",
+        payee_patterns=[("EDEKA", "Expenses:Supermarket:EDEKA")],
+    )
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert len(directives[0].postings) == 2
+    assert directives[0].postings[0].account == "Assets:DKB:EC"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-9.56")
+
+    assert directives[0].postings[1].account == "Expenses:Supermarket:EDEKA"
+    assert directives[0].postings[1].units is None
+
+
+def test_extract_with_description_patterns(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(
+        IBAN,
+        "Assets:DKB:EC",
+        file_encoding="utf-8",
+        description_patterns=[("SAGT DANKE", "Expenses:Supermarket:EDEKA")],
+    )
+
+    with tmp_file.open() as fd:
+        directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert len(directives[0].postings) == 2
+    assert directives[0].postings[0].account == "Assets:DKB:EC"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-9.56")
+
+    assert directives[0].postings[1].account == "Expenses:Supermarket:EDEKA"
+    assert directives[0].postings[1].units is None
+
+
+def test_extract_with_payee_and_description_patterns(tmp_file):
+    tmp_file.write_text(
+        _format(
+            """
+            "Konto";"Girokonto {iban}"
+            ""
+            "Kontostand vom 31.01.2023:";"5001,01 EUR"
+            ""
+            {header};
+            "15.01.23";"15.01.23";"Gebucht";"ISSUER";"EDEKA//MUENCHEN/DE";"EDEKA SAGT DANKE";"Ausgang";"-9,56 €";"DE0000000000000000";"";"00000000000000000000000000"
+            """,  # NOQA
+            dict(iban=IBAN, header=HEADER),
+        )
+    )
+
+    importer = ECImporter(
+        IBAN,
+        "Assets:DKB:EC",
+        file_encoding="utf-8",
+        payee_patterns=[("EDEKA", "Expenses:Supermarket:EDEKA")],
+        description_patterns=[("SAGT DANKE", "Expenses:Supermarket:EDEKA")],
+    )
+
+    with tmp_file.open() as fd:
+        with pytest.warns(UserWarning) as user_warnings:
+            directives = importer.extract(fd)
+
+    assert len(directives) == 2
+    assert len(directives[0].postings) == 2
+    assert directives[0].postings[0].account == "Assets:DKB:EC"
+    assert directives[0].postings[0].units.currency == "EUR"
+    assert directives[0].postings[0].units.number == Decimal("-9.56")
+
+    assert directives[0].postings[1].account == "Expenses:Supermarket:EDEKA"
+    assert directives[0].postings[1].units is None
+
+    assert len(user_warnings) == 1
+    assert user_warnings[0].message.args[0] == (
+        "Line 6 matches both payee_patterns and description_patterns. "
+        "Picking payee_pattern."
+    )