extf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
extf/_reader.py ADDED
@@ -0,0 +1,880 @@
1
+ """Readers of files in the DATEV format (EXTF) and of the CSV lists that come with them.
2
+
3
+ **DATEV format** (``EXTF``, format version 700): Windows-1252, fields separated by ``;``,
4
+ text in double quotes (``""`` inside a text is one quote), amounts with a decimal comma.
5
+ Line 1 is the header (category, fiscal year start, length of the general ledger account
6
+ numbers, period), line 2 names the columns, every further line is a record. Three
7
+ categories are read:
8
+
9
+ * ``21`` Buchungsstapel — booking entries (:func:`read_bookings`),
10
+ * ``20`` Kontenbeschriftungen — the labels of the general ledger accounts (:func:`read_account_labels`),
11
+ * ``16`` Debitoren/Kreditoren — the personal accounts (:func:`read_personal_accounts`;
12
+ the list Kanzlei-Rechnungswesen exports is read as well).
13
+
14
+ Columns are found by their name (DATEV writes the full list; only the ones needed here
15
+ are read), so the order of the columns does not matter.
16
+
17
+ **CSV lists** (``;``, decimal comma, UTF-8 or Windows-1252): the trial balance
18
+ (Summen- und Saldenliste, :func:`read_trial_balance`), the personal accounts as
19
+ Kanzlei-Rechnungswesen lists them (Debitoren-/Kreditorenstammdaten) and the fixed-asset register
20
+ (Anlagenverzeichnis, :func:`read_fixed_assets`). The header row is the first row that
21
+ names the column ``Konto``; lines before it (a title, the period) are skipped, within
22
+ :data:`MAX_HEADER_SEARCH` characters.
23
+
24
+ Every reader is strict: a file with a single bad line is refused as a whole, with every
25
+ bad line named (:class:`DatevFormatError`); reading stops at the 200th. Nothing is guessed.
26
+
27
+ **Input is hostile.** :data:`MAX_INPUT_BYTES`, :data:`MAX_LINES`, :data:`MAX_COLUMNS` and
28
+ :data:`MAX_FIELD_LENGTH` bound what is read at all, :data:`MAX_HEADER_SEARCH` what is searched
29
+ for the header row of a CSV list; nothing from the input is evaluated. On any bytes a reader
30
+ returns or raises :class:`DatevFormatError`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import csv
36
+ import io
37
+ import re
38
+ from collections.abc import Callable, Iterator
39
+ from dataclasses import dataclass
40
+ from datetime import date
41
+ from decimal import Decimal
42
+
43
+ from extf._errors import DatevFormatError, LineError
44
+ from extf._format import (
45
+ ACCOUNT_LABELS,
46
+ ACCOUNT_LENGTHS,
47
+ AMOUNT_LIMIT,
48
+ BOOKINGS,
49
+ CATEGORY_NAMES,
50
+ DATEV_ENCODING,
51
+ DEFAULT_ACCOUNT_LENGTH,
52
+ MONEY_CONTEXT,
53
+ PERSONAL_ACCOUNTS,
54
+ fiscal_year_end,
55
+ is_reversal_key,
56
+ is_utf8,
57
+ )
58
+
59
+ #: The size of an input in bytes above which it is refused unread.
60
+ MAX_INPUT_BYTES = 64 * 1024 * 1024
61
+ #: The lines, the columns of a line and the characters of a field a file may have.
62
+ MAX_LINES = 1_000_000
63
+ MAX_COLUMNS = 512
64
+ MAX_FIELD_LENGTH = 4096
65
+ #: The characters of a CSV list searched for its header row, the header row included. A
66
+ #: separator and a line end count as one each, so that empty cells use up the search as well.
67
+ MAX_HEADER_SEARCH = 1024 * 1024
68
+ #: The format versions read (the header's second field).
69
+ FORMAT_VERSIONS = (500, 700)
70
+ _MAX_ERRORS = 200
71
+ #: The characters of the longest row that passes both limits: every cell quoted, every character
72
+ #: of it a doubled quote, the separators and a line end of two. A longer row breaks one of them.
73
+ _MAX_ROW_CHARS = MAX_COLUMNS * (2 * MAX_FIELD_LENGTH + 3) + 1
74
+ _NO_LIST_HEADER = "no header row with the column Konto"
75
+
76
+ _AMOUNT = re.compile(r"^[0-9]{1,3}(?:\.?[0-9]{3})*(?:,[0-9]{1,2})?$")
77
+ _SIGNED_AMOUNT = re.compile(r"^-?[0-9]{1,3}(?:\.?[0-9]{3})*(?:,[0-9]{1,2})?$")
78
+ _DIGITS = re.compile(r"^[0-9]+$")
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Header:
83
+ """Line 1 of a file in the DATEV format."""
84
+
85
+ category: int
86
+ format_version: int
87
+ #: The first day of the fiscal year (WJ-Beginn).
88
+ fiscal_year_start: date
89
+ #: The length of a general ledger account number (Sachkontenlänge).
90
+ account_length: int
91
+ #: The period of a Buchungsstapel (Datum vom / Datum bis); ``None`` for the other categories.
92
+ date_from: date | None
93
+ date_to: date | None
94
+ #: The Buchungsstapel is locked (Festschreibung ``1``).
95
+ locked: bool
96
+
97
+ @property
98
+ def fiscal_year(self) -> int:
99
+ return self.fiscal_year_start.year
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class Booking:
104
+ """One record of a Buchungsstapel, with the sides resolved: ``amount`` (euros) debited
105
+ to ``debit_account`` and credited to ``credit_account``."""
106
+
107
+ file: str
108
+ line: int
109
+ amount: Decimal
110
+ debit_account: str
111
+ credit_account: str
112
+ #: The account the tax key belongs to as written: ``Gegenkonto``.
113
+ contra_account: str
114
+ #: BU-Schlüssel as written (``None``: none).
115
+ tax_key: str | None
116
+ booking_date: date
117
+ #: Belegfeld 1.
118
+ document_number: str | None
119
+ #: Belegfeld 2.
120
+ document_number_2: str | None
121
+ text: str | None
122
+ currency: str
123
+ foreign_amount: Decimal | None
124
+ fx_rate: str | None
125
+ #: Buchungs GUID, if DATEV wrote one: the entry's identity across deliveries.
126
+ guid: str | None
127
+ #: Festschreibung of the record (``None``: not stated).
128
+ locked: bool | None
129
+ #: Generalumkehr: the record cancels an entry. A record marked in the column
130
+ #: ``Generalumkehr (GU)`` carries its effective side in the export already; the sides
131
+ #: above are as written (see :func:`read_bookings`).
132
+ reversal: bool = False
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class BookingFile:
137
+ header: Header
138
+ bookings: list[Booking]
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class AccountLabel:
143
+ line: int
144
+ number: str
145
+ label: str
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class PersonalAccount:
150
+ line: int
151
+ number: str
152
+ name: str
153
+ #: The VAT id (country code and number, no blanks), if stated.
154
+ vat_id: str | None
155
+ #: The advisor's customer or supplier number of the account (Kunden-/Lief.-Nr.), if stated.
156
+ customer_number: str | None = None
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class Balance:
161
+ """One line of a trial balance: the balance of ``account``, debit positive."""
162
+
163
+ line: int
164
+ account: str
165
+ label: str | None
166
+ #: The balance at the end of the period, the opening balance included.
167
+ balance: Decimal
168
+ #: The opening balance (EB-Wert), debit positive, if the list states it. Part of
169
+ #: ``balance`` already: the opening entries are entries of the Buchungsstapel.
170
+ opening: Decimal | None = None
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class FixedAsset:
175
+ line: int
176
+ asset_number: str
177
+ name: str
178
+ account: str
179
+ acquired_on: date
180
+ acquisition_cost: Decimal
181
+ #: Useful life in years (``None``: not stated, e.g. land).
182
+ useful_life_years: int | None
183
+ method: str | None
184
+ #: The book value on the day of the register (``None``: not stated).
185
+ book_value: Decimal | None
186
+
187
+
188
+ # --- low level ---------------------------------------------------------------------------
189
+
190
+
191
+ def _norm(name: str) -> str:
192
+ """A column name as compared: lower case, umlauts spelled out, only letters and digits."""
193
+ lowered = name.casefold().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
194
+ return re.sub(r"[^a-z0-9]", "", lowered).replace("adressatentyp", "adressattyp")
195
+
196
+
197
+ class _Errors:
198
+ def __init__(self, file: str) -> None:
199
+ self.file = file
200
+ self.items: list[LineError] = []
201
+
202
+ def add(self, line: int, message: str) -> None:
203
+ """Note a bad line; the 200th ends the reading: the file is refused by then."""
204
+ self.items.append(LineError(self.file, line, message))
205
+ if len(self.items) >= _MAX_ERRORS:
206
+ raise DatevFormatError(self.items) from None
207
+
208
+ def raise_any(self) -> None:
209
+ if self.items:
210
+ raise DatevFormatError(self.items)
211
+
212
+
213
+ def _rows(text: str, file: str) -> Iterator[tuple[int, list[str]]]:
214
+ """The rows of ``text`` with the number of the line each starts on."""
215
+ start = 1
216
+ characters = 0
217
+
218
+ def lines() -> Iterator[str]:
219
+ """The lines of ``text``, refusing a row that passes the bound before csv.reader parses it."""
220
+ nonlocal characters
221
+ for line in io.StringIO(text, newline=""):
222
+ characters += len(line)
223
+ if characters > _MAX_ROW_CHARS:
224
+ message = f"the line has more than {MAX_COLUMNS} columns or a field of more than {MAX_FIELD_LENGTH} characters"
225
+ raise DatevFormatError([LineError(file, start, message)])
226
+ yield line
227
+
228
+ reader = csv.reader(lines(), delimiter=";", quotechar='"', strict=True)
229
+ try:
230
+ for row in reader:
231
+ if reader.line_num > MAX_LINES:
232
+ raise DatevFormatError([LineError(file, start, f"the file has more than {MAX_LINES} lines")])
233
+ if len(row) > MAX_COLUMNS:
234
+ raise DatevFormatError([LineError(file, start, f"the line has more than {MAX_COLUMNS} columns")])
235
+ if any(len(cell) > MAX_FIELD_LENGTH for cell in row):
236
+ raise DatevFormatError([LineError(file, start, f"a field has more than {MAX_FIELD_LENGTH} characters")])
237
+ yield start, row
238
+ start = reader.line_num + 1
239
+ characters = 0
240
+ except csv.Error as exc:
241
+ raise DatevFormatError([LineError(file, start, f"not readable as CSV: {exc}")]) from exc
242
+
243
+
244
+ def _records(rows: Iterator[tuple[int, list[str]]]) -> Iterator[tuple[int, list[str]]]:
245
+ """The rows left in ``rows`` without the blank ones."""
246
+ return ((line, row) for line, row in rows if any(cell.strip() for cell in row))
247
+
248
+
249
+ def _check_size(data: bytes, file: str) -> None:
250
+ if len(data) > MAX_INPUT_BYTES:
251
+ raise DatevFormatError([LineError(file, 1, f"the file has {len(data)} bytes, at most {MAX_INPUT_BYTES}")])
252
+
253
+
254
+ def _decode_datev(data: bytes, file: str) -> str:
255
+ _check_size(data, file)
256
+ if data.startswith(b"\xef\xbb\xbf"):
257
+ raise DatevFormatError(
258
+ [LineError(file, 1, "starts with a UTF-8 byte order mark: the DATEV format is Windows-1252; export it again")]
259
+ )
260
+ if is_utf8(data):
261
+ raise DatevFormatError([LineError(file, 1, "is UTF-8: the DATEV format is Windows-1252; export it again")])
262
+ try:
263
+ return data.decode(DATEV_ENCODING)
264
+ except UnicodeDecodeError as exc:
265
+ line = data[: exc.start].count(b"\n") + 1
266
+ raise DatevFormatError([LineError(file, line, "not Windows-1252, as the DATEV format is")]) from exc
267
+
268
+
269
+ def _decode_list(data: bytes) -> str:
270
+ """A CSV list: UTF-8 (with or without BOM), else Windows-1252."""
271
+ try:
272
+ return data.decode("utf-8-sig")
273
+ except UnicodeDecodeError:
274
+ return data.decode(DATEV_ENCODING, errors="strict")
275
+
276
+
277
+ def parse_amount(value: str, *, signed: bool = False) -> Decimal:
278
+ """``1234,56`` or ``1.234,56`` (``-`` in front if ``signed``) as a Decimal.
279
+
280
+ :class:`ValueError` for anything else, and for an amount of 15 digits or more before
281
+ the comma."""
282
+ text = value.strip()
283
+ if not (_SIGNED_AMOUNT if signed else _AMOUNT).match(text):
284
+ raise ValueError(f"not an amount with a decimal comma: {value[:40]!r}")
285
+ amount = Decimal(text.replace(".", "").replace(",", "."))
286
+ if abs(amount) >= AMOUNT_LIMIT:
287
+ raise ValueError(f"amount out of range: {text[:40]}")
288
+ return amount
289
+
290
+
291
+ def _parse_day(value: str, what: str) -> date:
292
+ text = value.strip()
293
+ try:
294
+ if re.fullmatch(r"[0-9]{8}", text):
295
+ return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
296
+ if re.fullmatch(r"[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{4}", text):
297
+ day, month, year = (int(part) for part in text.split("."))
298
+ return date(year, month, day)
299
+ except ValueError:
300
+ pass
301
+ raise ValueError(f"{what} is no date: {value[:40]!r}")
302
+
303
+
304
+ def _account(value: str, length: int) -> str:
305
+ """An account number padded to the length of the general ledger accounts."""
306
+ text = value.strip()
307
+ if not _DIGITS.match(text):
308
+ raise ValueError(f"account {value[:40]!r} is no number")
309
+ text = text.zfill(length)
310
+ if len(text) > length + 1 or int(text) == 0:
311
+ raise ValueError(f"account {value[:40]!r} is no account of {length} or {length + 1} digits")
312
+ return text
313
+
314
+
315
+ def _cut(value: str | None, limit: int, what: str) -> str | None:
316
+ if value is None or not value.strip():
317
+ return None
318
+ text = value.strip()
319
+ if len(text) > limit:
320
+ raise ValueError(f"{what} has more than {limit} characters")
321
+ return text
322
+
323
+
324
+ # --- the DATEV format --------------------------------------------------------------------
325
+
326
+
327
+ def _number(text: str) -> int:
328
+ """A number of the header, ``-1`` if it is none."""
329
+ return int(text) if re.fullmatch(r"[0-9]{1,9}", text) else -1
330
+
331
+
332
+ def _header(row: list[str], file: str, category: int) -> Header:
333
+ def field(index: int) -> str:
334
+ return row[index].strip() if index < len(row) else ""
335
+
336
+ marker = field(0)
337
+ if marker not in ("EXTF", "DTVF"):
338
+ raise DatevFormatError([LineError(file, 1, "no DATEV header: line 1 starts with neither EXTF nor DTVF")])
339
+ errors = _Errors(file)
340
+ if _number(field(2)) != category:
341
+ name = CATEGORY_NAMES[category]
342
+ errors.add(1, f"format category {field(2)[:40] or '(empty)'} ({field(3)[:40] or '?'}), expected {category} ({name})")
343
+ errors.raise_any()
344
+ format_version = _number(field(1))
345
+ if not FORMAT_VERSIONS[0] <= format_version <= FORMAT_VERSIONS[1]:
346
+ errors.add(1, f"format version {field(1)[:40] or '(empty)'} is not supported ({FORMAT_VERSIONS[0]} to {FORMAT_VERSIONS[1]} are)")
347
+ fiscal_year_start = date(1, 1, 1)
348
+ try:
349
+ fiscal_year_start = _parse_day(field(12), "the start of the fiscal year (WJ-Beginn)")
350
+ except ValueError as exc:
351
+ errors.add(1, str(exc))
352
+ account_length = _number(field(13))
353
+ if not ACCOUNT_LENGTHS[0] <= account_length <= ACCOUNT_LENGTHS[1]:
354
+ errors.add(
355
+ 1,
356
+ f"general ledger accounts have {field(13)[:40] or '(no)'} digits (Sachkontenlänge); "
357
+ f"{ACCOUNT_LENGTHS[0]} to {ACCOUNT_LENGTHS[1]} are possible",
358
+ )
359
+ date_from = date_to = None
360
+ if category == BOOKINGS:
361
+ try:
362
+ date_from = _parse_day(field(14), "the first day (Datum vom)")
363
+ date_to = _parse_day(field(15), "the last day (Datum bis)")
364
+ if date_to < date_from:
365
+ errors.add(1, "the period ends before it starts")
366
+ except ValueError as exc:
367
+ errors.add(1, str(exc))
368
+ errors.raise_any()
369
+ return Header(
370
+ category=category,
371
+ format_version=format_version,
372
+ fiscal_year_start=fiscal_year_start,
373
+ account_length=account_length,
374
+ date_from=date_from,
375
+ date_to=date_to,
376
+ locked=field(20) == "1",
377
+ )
378
+
379
+
380
+ class _Columns:
381
+ """The columns of a file by their normalised name."""
382
+
383
+ def __init__(self, names: list[str]) -> None:
384
+ self.names = [_norm(name) for name in names]
385
+ self.index: dict[str, int] = {}
386
+ for position, name in enumerate(self.names):
387
+ if name:
388
+ self.index.setdefault(name, position)
389
+
390
+ def find(self, *names: str) -> int | None:
391
+ return next((self.index[_norm(name)] for name in names if _norm(name) in self.index), None)
392
+
393
+ def marks(self, position: int | None) -> tuple[int, int] | None:
394
+ """The columns ``S`` and ``H`` right after the amount column at ``position``, which
395
+ mark the side of its amount (Kanzlei-Rechnungswesen: ``Saldo;S;H``); ``None``:
396
+ there are none."""
397
+ if position is None or self.names[position + 1 : position + 3] != ["s", "h"]:
398
+ return None
399
+ return position + 1, position + 2
400
+
401
+
402
+ def _datev_file(data: bytes, file: str, category: int) -> tuple[Header, _Columns, Iterator[tuple[int, list[str]]]]:
403
+ rows = _rows(_decode_datev(data, file), file)
404
+ first = next(rows, None)
405
+ if first is None:
406
+ raise DatevFormatError([LineError(file, 1, "the file is empty")])
407
+ header = _header(first[1], file, category)
408
+ second = next(rows, None)
409
+ if second is None:
410
+ raise DatevFormatError([LineError(file, 2, "line 2 (the column names) is missing")])
411
+ return header, _Columns(second[1]), _records(rows)
412
+
413
+
414
+ def _require(columns: _Columns, file: str, wanted: dict[str, tuple[str, ...]]) -> dict[str, int]:
415
+ found: dict[str, int] = {}
416
+ missing = []
417
+ for key, names in wanted.items():
418
+ position = columns.find(*names)
419
+ if position is None:
420
+ missing.append(names[0])
421
+ else:
422
+ found[key] = position
423
+ if missing:
424
+ raise DatevFormatError([LineError(file, 2, f"column(s) missing: {', '.join(missing)}")])
425
+ return found
426
+
427
+
428
+ def _cell(row: list[str], position: int | None) -> str:
429
+ if position is None or position >= len(row):
430
+ return ""
431
+ return row[position].strip()
432
+
433
+
434
+ def _record_day(value: str, header: Header) -> date:
435
+ """Belegdatum ``DDMM`` (or ``DMM``) in the fiscal year of the Buchungsstapel (WJ-Beginn
436
+ and the twelve months after it), not only in its period: a late entry of an earlier
437
+ month is booked in the batch of a later one. The year is the one that puts the day
438
+ into the fiscal year — across the turn of the year of a fiscal year that is not the
439
+ calendar year as well."""
440
+ text = value.strip()
441
+ if not re.fullmatch(r"[0-9]{3,4}", text):
442
+ raise ValueError(f"Belegdatum {value!r} is not DDMM")
443
+ text = text.zfill(4)
444
+ start = header.fiscal_year_start
445
+ end = fiscal_year_end(start)
446
+ for year in (start.year, start.year + 1):
447
+ try:
448
+ day = date(year, int(text[2:]), int(text[:2]))
449
+ except ValueError:
450
+ continue
451
+ if start <= day <= end:
452
+ return day
453
+ raise ValueError(f"Belegdatum {text} is no day of the fiscal year {start.isoformat()} – {end.isoformat()}")
454
+
455
+
456
+ def read_bookings(data: bytes, file: str = "Buchungsstapel") -> BookingFile:
457
+ """A Buchungsstapel (category 21). ``S``: ``Konto`` is debited and ``Gegenkonto``
458
+ credited; ``H`` the other way round. ``Umsatz`` is in ``WKZ Umsatz``; in another
459
+ currency than euros, ``Basis-Umsatz`` holds the euro amount.
460
+
461
+ A Generalumkehr cancels an entry. Marked in the column ``Generalumkehr (GU)`` = ``1``,
462
+ the record carries its effective side already — DATEV exports the cancellation with
463
+ the side it books — so the sides are read as written and ``reversal`` only marks it
464
+ (read so, the cancelled entries come out at the balances of the trial balance;
465
+ swapped again, they count twice). Marked the old way, by the correction digit ``2``
466
+ in front of the tax key (e.g. ``29``), the sides are swapped and the tax key without
467
+ the correction digit applies — unproven, the exports seen so far do not write it. The Belegdatum may be any day of the fiscal year (a late entry, see
468
+ :func:`_record_day`). A record with a ``Skonto`` amount is refused as a bad line: DATEV
469
+ splits the discount off automatically, which is not rebuilt here."""
470
+ header, columns, records = _datev_file(data, file, BOOKINGS)
471
+ need = _require(
472
+ columns,
473
+ file,
474
+ {
475
+ "amount": ("Umsatz (ohne Soll/Haben-Kz)", "Umsatz"),
476
+ "side": ("Soll/Haben-Kennzeichen", "Soll-/Haben-Kennzeichen"),
477
+ "account": ("Konto",),
478
+ "contra": ("Gegenkonto (ohne BU-Schlüssel)", "Gegenkonto"),
479
+ "day": ("Belegdatum",),
480
+ },
481
+ )
482
+ optional = {
483
+ "currency": columns.find("WKZ Umsatz"),
484
+ "rate": columns.find("Kurs"),
485
+ "base": columns.find("Basis-Umsatz"),
486
+ "base_currency": columns.find("WKZ Basis-Umsatz"),
487
+ "key": columns.find("BU-Schlüssel"),
488
+ "number": columns.find("Belegfeld 1"),
489
+ "number_2": columns.find("Belegfeld 2"),
490
+ "text": columns.find("Buchungstext"),
491
+ "locked": columns.find("Festschreibung"),
492
+ "guid": columns.find("Buchungs GUID"),
493
+ "reversal": columns.find("Generalumkehr (GU)", "Generalumkehr"),
494
+ "discount": columns.find("Skonto"),
495
+ }
496
+ errors = _Errors(file)
497
+ bookings: list[Booking] = []
498
+ for line, row in records:
499
+ try:
500
+ bookings.append(_booking(file, line, row, header, need, optional))
501
+ except ValueError as exc:
502
+ errors.add(line, str(exc))
503
+ errors.raise_any()
504
+ return BookingFile(header=header, bookings=bookings)
505
+
506
+
507
+ def _booking(
508
+ file: str, line: int, row: list[str], header: Header, need: dict[str, int], optional: dict[str, int | None]
509
+ ) -> Booking:
510
+ length = header.account_length
511
+ amount = parse_amount(_cell(row, need["amount"]))
512
+ if amount <= 0:
513
+ raise ValueError("Umsatz must be above zero")
514
+ side = _cell(row, need["side"]).upper()
515
+ if side not in ("S", "H"):
516
+ raise ValueError(f"Soll/Haben-Kennzeichen {side or '(empty)'} is neither S nor H")
517
+ account = _account(_cell(row, need["account"]), length)
518
+ contra = _account(_cell(row, need["contra"]), length)
519
+ if account == contra:
520
+ raise ValueError(f"Konto and Gegenkonto are both {account}")
521
+ key = _cell(row, optional["key"]) or None
522
+ if key is not None and not re.fullmatch(r"[0-9]{1,4}", key):
523
+ raise ValueError(f"BU-Schlüssel {key!r} is no key")
524
+ reversal_text = _cell(row, optional["reversal"])
525
+ if reversal_text not in ("", "0", "1"):
526
+ raise ValueError(f"Generalumkehr {reversal_text!r} is neither 0 nor 1")
527
+ reversal = reversal_text == "1"
528
+ swapped = False
529
+ if key is not None and is_reversal_key(key):
530
+ # The old way of writing a Generalumkehr: correction digit 2 in front of the tax key.
531
+ if reversal:
532
+ raise ValueError(f"BU-Schlüssel {key} and the column Generalumkehr both mark a Generalumkehr")
533
+ reversal, swapped, key = True, True, (None if key[1] == "0" else key[1])
534
+ discount = _cell(row, optional["discount"])
535
+ if discount and parse_amount(discount) != 0:
536
+ raise ValueError(
537
+ "Skonto is not supported (DATEV splits the discount off automatically); "
538
+ "export the discount as entries of their own"
539
+ )
540
+ currency = (_cell(row, optional["currency"]) or "EUR").upper()
541
+ foreign_amount: Decimal | None = None
542
+ fx_rate: str | None = None
543
+ if currency != "EUR":
544
+ if not re.fullmatch(r"[A-Z]{3}", currency):
545
+ raise ValueError(f"WKZ Umsatz {currency!r} is no currency")
546
+ base_currency = (_cell(row, optional["base_currency"]) or "EUR").upper()
547
+ base = _cell(row, optional["base"])
548
+ if base_currency != "EUR" or not base:
549
+ raise ValueError(f"an amount in {currency} needs its euro amount (Basis-Umsatz in EUR)")
550
+ foreign_amount, amount = amount, parse_amount(base)
551
+ if amount <= 0:
552
+ raise ValueError("Basis-Umsatz must be above zero")
553
+ rate = _cell(row, optional["rate"])
554
+ fx_rate = rate.replace(",", ".") if rate else None
555
+ if fx_rate is not None and not re.fullmatch(r"[0-9]{1,6}(\.[0-9]{1,6})?", fx_rate):
556
+ raise ValueError(f"Kurs {rate!r} is no rate")
557
+ debit, credit = (account, contra) if (side == "S") != swapped else (contra, account)
558
+ locked = _cell(row, optional["locked"])
559
+ return Booking(
560
+ file=file,
561
+ line=line,
562
+ amount=amount,
563
+ debit_account=debit,
564
+ credit_account=credit,
565
+ contra_account=contra,
566
+ tax_key=key,
567
+ booking_date=_record_day(_cell(row, need["day"]), header),
568
+ document_number=_cut(_cell(row, optional["number"]), 36, "Belegfeld 1"),
569
+ document_number_2=_cut(_cell(row, optional["number_2"]), 12, "Belegfeld 2"),
570
+ text=_cut(_cell(row, optional["text"]), 60, "Buchungstext"),
571
+ currency=currency,
572
+ foreign_amount=foreign_amount,
573
+ fx_rate=fx_rate,
574
+ guid=_cut(_cell(row, optional["guid"]), 36, "Buchungs GUID"),
575
+ locked=None if not locked else locked == "1",
576
+ reversal=reversal,
577
+ )
578
+
579
+
580
+ def read_account_labels(data: bytes, file: str = "Kontenbeschriftungen") -> tuple[Header, list[AccountLabel]]:
581
+ """Kontenbeschriftungen (category 20): general ledger accounts and their labels (the
582
+ long label where there is one)."""
583
+ header, columns, records = _datev_file(data, file, ACCOUNT_LABELS)
584
+ need = _require(columns, file, {"account": ("Konto",), "label": ("Kontenbeschriftung",)})
585
+ long_label = columns.find("Kontenbeschriftung lang")
586
+ errors = _Errors(file)
587
+ labels: list[AccountLabel] = []
588
+ seen: dict[str, int] = {}
589
+ for line, row in records:
590
+ try:
591
+ number = _account(_cell(row, need["account"]), header.account_length)
592
+ if len(number) != header.account_length:
593
+ raise ValueError(f"account {number} is no general ledger account ({header.account_length} digits)")
594
+ label = _cut(_cell(row, long_label) or _cell(row, need["label"]), 300, "the label")
595
+ if label is None:
596
+ raise ValueError(f"account {number} has no label")
597
+ if number in seen:
598
+ raise ValueError(f"account {number} is labelled in line {seen[number]} already")
599
+ seen[number] = line
600
+ labels.append(AccountLabel(line=line, number=number, label=label))
601
+ except ValueError as exc:
602
+ errors.add(line, str(exc))
603
+ errors.raise_any()
604
+ return header, labels
605
+
606
+
607
+ def is_datev_format(data: bytes) -> bool:
608
+ """Whether ``data`` starts with the header of the DATEV format (``EXTF`` / ``DTVF``)."""
609
+ first = data[:16].lstrip(b"\xef\xbb\xbf")[:6].lstrip(b'"')
610
+ return first.startswith((b"EXTF", b"DTVF"))
611
+
612
+
613
+ def read_personal_accounts(
614
+ data: bytes, file: str = "Debitoren/Kreditoren", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
615
+ ) -> tuple[Header | None, list[PersonalAccount]]:
616
+ """Debitoren/Kreditoren: the personal accounts with name and VAT id — the DATEV format
617
+ (category 16), or the list Kanzlei-Rechnungswesen exports (Debitoren-/Kreditoren-
618
+ stammdaten: a title line, then ``Konto;K/I;Beschriftung;…;USt-IdNr.;…;Kunden-/Lief.-Nr.``;
619
+ no header of the DATEV format, ``None`` for it, so ``account_length`` says how long
620
+ a general ledger account is there). A list names a person as ``Nachname, Vorname``;
621
+ the name is kept as written."""
622
+ if not is_datev_format(data):
623
+ return None, _read_personal_list(data, file, account_length)
624
+ header, columns, records = _datev_file(data, file, PERSONAL_ACCOUNTS)
625
+ need = _require(columns, file, {"account": ("Konto",)})
626
+ company = columns.find("Name (Adressattyp Unternehmen)")
627
+ surname = columns.find("Name (Adressattyp natürl. Person)")
628
+ first_name = columns.find("Vorname (Adressattyp natürl. Person)")
629
+ unspecified = columns.find("Name (Adressattyp keine Angabe)")
630
+ short = columns.find("Kurzbezeichnung")
631
+ country = columns.find("EU-Land", "EU-Mitgliedstaat")
632
+ vat_number = columns.find("EU-UStID", "EU-USt-IdNr.", "EU-UStIdNr")
633
+ errors = _Errors(file)
634
+ accounts: list[PersonalAccount] = []
635
+ seen: dict[str, int] = {}
636
+ for line, row in records:
637
+ try:
638
+ number = _account(_cell(row, need["account"]), header.account_length)
639
+ if len(number) != header.account_length + 1:
640
+ raise ValueError(f"account {number} is no personal account ({header.account_length + 1} digits)")
641
+ person = " ".join(part for part in (_cell(row, first_name), _cell(row, surname)) if part)
642
+ name = _cell(row, company) or person or _cell(row, unspecified) or _cell(row, short)
643
+ if not name:
644
+ raise ValueError(f"account {number} has no name")
645
+ if number in seen:
646
+ raise ValueError(f"account {number} is listed in line {seen[number]} already")
647
+ seen[number] = line
648
+ vat = re.sub(r"\s", "", _cell(row, country) + _cell(row, vat_number)).upper() or None
649
+ if vat is not None and len(vat) > 20:
650
+ raise ValueError("the VAT id has more than 20 characters")
651
+ if len(name) > 200:
652
+ raise ValueError("the name has more than 200 characters")
653
+ accounts.append(PersonalAccount(line=line, number=number, name=name, vat_id=vat))
654
+ except ValueError as exc:
655
+ errors.add(line, str(exc))
656
+ errors.raise_any()
657
+ return header, accounts
658
+
659
+
660
+ # --- CSV lists ---------------------------------------------------------------------------
661
+
662
+
663
+ def _list_rows(data: bytes, file: str) -> tuple[_Columns, Iterator[tuple[int, list[str]]]]:
664
+ _check_size(data, file)
665
+ try:
666
+ text = _decode_list(data)
667
+ except UnicodeDecodeError as exc:
668
+ raise DatevFormatError([LineError(file, 1, "neither UTF-8 nor Windows-1252")]) from exc
669
+ rows = _rows(text, file)
670
+ searched = 0
671
+ for _line, row in rows:
672
+ searched += len(";".join(row)) + 1
673
+ if searched > MAX_HEADER_SEARCH:
674
+ break
675
+ if any(_norm(cell) == "konto" for cell in row):
676
+ return _Columns(row), _records(rows)
677
+ raise DatevFormatError([LineError(file, 1, _NO_LIST_HEADER)])
678
+
679
+
680
+ def _read_list[T](data: bytes, file: str, parse: Callable[[_Columns, str], Callable[[int, list[str]], T]]) -> list[T]:
681
+ columns, records = _list_rows(data, file)
682
+ one = parse(columns, file)
683
+ errors = _Errors(file)
684
+ result: list[T] = []
685
+ for line, row in records:
686
+ try:
687
+ result.append(one(line, row))
688
+ except ValueError as exc:
689
+ errors.add(line, str(exc))
690
+ errors.raise_any()
691
+ return result
692
+
693
+
694
+ def _marked(row: list[str], position: int, marks: tuple[int, int], what: str) -> Decimal:
695
+ """The amount at ``position`` with its side from the columns ``S`` and ``H`` after it:
696
+ exactly one of them marks a non-zero amount; an empty amount is zero and has no mark."""
697
+ text = _cell(row, position)
698
+ debit_mark, credit_mark = _cell(row, marks[0]).upper(), _cell(row, marks[1]).upper()
699
+ if debit_mark not in ("", "S") or credit_mark not in ("", "H"):
700
+ raise ValueError(f"{what}: the side marks {debit_mark!r} / {credit_mark!r} are not S / H")
701
+ if not text:
702
+ if debit_mark or credit_mark:
703
+ raise ValueError(f"{what}: a side mark without an amount")
704
+ return Decimal(0)
705
+ amount = parse_amount(text)
706
+ if debit_mark and credit_mark:
707
+ raise ValueError(f"{what} {text} is marked S and H")
708
+ if not debit_mark and not credit_mark:
709
+ if amount == 0:
710
+ return amount
711
+ raise ValueError(f"{what} {text} is marked neither S nor H")
712
+ return amount if debit_mark else amount.copy_negate()
713
+
714
+
715
+ def read_trial_balance(
716
+ data: bytes, file: str = "Summen- und Saldenliste", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
717
+ ) -> list[Balance]:
718
+ """A trial balance (Summen- und Saldenliste) as CSV: ``Konto``, optionally
719
+ ``Beschriftung``, and the balance as ``Saldo Soll`` / ``Saldo Haben`` or as ``Saldo``
720
+ — signed, with an ``S/H`` column, or with its side in the columns ``S`` and ``H``
721
+ right after it, as Kanzlei-Rechnungswesen exports it (``EB-Wert;S;H;Saldo;S;H;…``,
722
+ the opening balance ``EB-Wert`` marked the same way). Debit is positive. ``Saldo``
723
+ is the balance at the end of the period, the opening balance included. Rows without
724
+ an account number are the totals of the export (per account class, the grand total)
725
+ and are skipped. The list has no header: ``account_length`` says how long a general
726
+ ledger account is, and shorter numbers are padded to it."""
727
+
728
+ def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], Balance | None]:
729
+ account = columns.find("Konto")
730
+ label = columns.find("Beschriftung", "Kontenbeschriftung", "Bezeichnung")
731
+ debit, credit = columns.find("Saldo Soll"), columns.find("Saldo Haben")
732
+ signed, side = columns.find("Saldo"), columns.find("S/H", "Soll/Haben")
733
+ marks = columns.marks(signed)
734
+ opening = columns.find("EB-Wert")
735
+ opening_marks = columns.marks(opening)
736
+ if (debit is None or credit is None) and signed is None:
737
+ raise DatevFormatError([LineError(file, 1, "columns missing: Saldo Soll and Saldo Haben, or Saldo")])
738
+ seen: dict[str, int] = {}
739
+
740
+ def one(line: int, row: list[str]) -> Balance | None:
741
+ if not _cell(row, account):
742
+ # A total row (account class, grand total) of the export: no account of its own.
743
+ return None
744
+ number = _account(_cell(row, account), account_length)
745
+ if debit is not None and credit is not None:
746
+ debit_text, credit_text = _cell(row, debit), _cell(row, credit)
747
+ if debit_text and credit_text and parse_amount(debit_text) and parse_amount(credit_text):
748
+ raise ValueError(f"account {number} has a debit and a credit balance")
749
+ balance = MONEY_CONTEXT.subtract(
750
+ parse_amount(debit_text) if debit_text else Decimal(0),
751
+ parse_amount(credit_text) if credit_text else Decimal(0),
752
+ )
753
+ elif marks is not None:
754
+ assert signed is not None
755
+ balance = _marked(row, signed, marks, f"Saldo of account {number}")
756
+ else:
757
+ balance = parse_amount(_cell(row, signed) or "0", signed=True)
758
+ marker = _cell(row, side).upper()
759
+ if marker not in ("", "S", "H"):
760
+ raise ValueError(f"S/H {marker!r} is neither S nor H")
761
+ if marker == "H":
762
+ balance = balance.copy_negate()
763
+ opening_balance = None
764
+ if opening is not None and opening_marks is not None:
765
+ opening_balance = _marked(row, opening, opening_marks, f"EB-Wert of account {number}")
766
+ if number in seen:
767
+ raise ValueError(f"account {number} is listed in line {seen[number]} already")
768
+ seen[number] = line
769
+ return Balance(
770
+ line=line,
771
+ account=number,
772
+ label=_cut(_cell(row, label), 200, "the label"),
773
+ balance=balance,
774
+ opening=opening_balance,
775
+ )
776
+
777
+ return one
778
+
779
+ return [balance for balance in _read_list(data, file, parse) if balance is not None]
780
+
781
+
782
+ def _read_personal_list(data: bytes, file: str, account_length: int) -> list[PersonalAccount]:
783
+ """The personal accounts as a list (see :func:`read_personal_accounts`)."""
784
+
785
+ def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], PersonalAccount]:
786
+ need = _require(columns, file, {"account": ("Konto",), "name": ("Beschriftung", "Name")})
787
+ vat_id = columns.find("USt-IdNr.", "USt-IdNr", "EU-UStID")
788
+ short = columns.find("Kurzbezeichnung")
789
+ number_column = columns.find("Kunden-/Lief.-Nr.", "Kunden-/Lieferantennummer")
790
+ seen: dict[str, int] = {}
791
+
792
+ def one(line: int, row: list[str]) -> PersonalAccount:
793
+ number = _account(_cell(row, need["account"]), account_length)
794
+ if len(number) != account_length + 1:
795
+ raise ValueError(f"account {number} is no personal account ({account_length + 1} digits)")
796
+ name = _cell(row, need["name"]) or _cell(row, short)
797
+ if not name:
798
+ raise ValueError(f"account {number} has no name")
799
+ if number in seen:
800
+ raise ValueError(f"account {number} is listed in line {seen[number]} already")
801
+ seen[number] = line
802
+ vat = re.sub(r"\s", "", _cell(row, vat_id)).upper() or None
803
+ if vat is not None and len(vat) > 20:
804
+ raise ValueError("the VAT id has more than 20 characters")
805
+ if len(name) > 200:
806
+ raise ValueError("the name has more than 200 characters")
807
+ return PersonalAccount(
808
+ line=line,
809
+ number=number,
810
+ name=name,
811
+ vat_id=vat,
812
+ customer_number=_cut(_cell(row, number_column), 50, "Kunden-/Lief.-Nr."),
813
+ )
814
+
815
+ return one
816
+
817
+ try:
818
+ return _read_list(data, file, parse)
819
+ except DatevFormatError as exc:
820
+ if [error.message for error in exc.errors] == [_NO_LIST_HEADER]:
821
+ message = f"no DATEV header (line 1 starts with neither EXTF nor DTVF) and {_NO_LIST_HEADER}"
822
+ raise DatevFormatError([LineError(file, 1, message)]) from exc
823
+ raise
824
+
825
+
826
+ def read_fixed_assets(
827
+ data: bytes, file: str = "Anlagenverzeichnis", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
828
+ ) -> list[FixedAsset]:
829
+ """A fixed-asset register (Anlagenverzeichnis) as CSV: ``Inventarnummer``,
830
+ ``Bezeichnung``, ``Konto``, ``Anschaffungsdatum`` (``DD.MM.YYYY``),
831
+ ``Anschaffungskosten``, optionally ``Nutzungsdauer`` (years), ``AfA-Art`` and
832
+ ``Buchwert``. The list has no header: ``account_length`` says how long a general ledger
833
+ account is."""
834
+
835
+ def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], FixedAsset]:
836
+ wanted = {
837
+ "number": ("Inventarnummer", "Anlagennummer", "Anlage"),
838
+ "name": ("Bezeichnung", "Anlagenbezeichnung"),
839
+ "account": ("Konto",),
840
+ "acquired": ("Anschaffungsdatum", "AHK-Datum", "Zugangsdatum"),
841
+ "cost": ("Anschaffungskosten", "AHK", "Anschaffungs-/Herstellungskosten"),
842
+ }
843
+ need = _require(columns, file, wanted)
844
+ life = columns.find("Nutzungsdauer", "ND")
845
+ method = columns.find("AfA-Art", "AfA-Methode")
846
+ book_value = columns.find("Buchwert", "Restbuchwert")
847
+ seen: dict[str, int] = {}
848
+
849
+ def one(line: int, row: list[str]) -> FixedAsset:
850
+ number = _cut(_cell(row, need["number"]), 50, "the asset number")
851
+ if number is None:
852
+ raise ValueError("the asset has no number")
853
+ if number in seen:
854
+ raise ValueError(f"asset {number} is listed in line {seen[number]} already")
855
+ name = _cut(_cell(row, need["name"]), 200, "the name")
856
+ if name is None:
857
+ raise ValueError(f"asset {number} has no name")
858
+ account = _account(_cell(row, need["account"]), account_length)
859
+ if len(account) != account_length:
860
+ raise ValueError(f"account {account} is no general ledger account")
861
+ life_text = _cell(row, life)
862
+ if life_text and not re.fullmatch(r"[0-9]{1,3}", life_text):
863
+ raise ValueError(f"Nutzungsdauer {life_text!r} is no number of years")
864
+ value_text = _cell(row, book_value)
865
+ seen[number] = line
866
+ return FixedAsset(
867
+ line=line,
868
+ asset_number=number,
869
+ name=name,
870
+ account=account,
871
+ acquired_on=_parse_day(_cell(row, need["acquired"]), "Anschaffungsdatum"),
872
+ acquisition_cost=parse_amount(_cell(row, need["cost"])),
873
+ useful_life_years=int(life_text) if life_text else None,
874
+ method=_cut(_cell(row, method), 50, "AfA-Art"),
875
+ book_value=parse_amount(value_text) if value_text else None,
876
+ )
877
+
878
+ return one
879
+
880
+ return _read_list(data, file, parse)