euer-datev 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.
euer_datev/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """euer-datev: Offizieller DATEV-Export (EXTF 700) & Kanzlei-Schnittstelle für euer.
2
+
3
+ Ermöglicht den dateibasierten 1-Klick-Export von Buchungsdaten und Belegen
4
+ im offiziellen DATEV-Format zur nahtlosen Übernahme in DATEV Kanzlei-Rechnungswesen.
5
+ """
6
+
7
+ VERSION = "0.1.0"
8
+ __version__ = VERSION
9
+
10
+ __all__ = ["VERSION", "__version__"]
euer_datev/cli.py ADDED
@@ -0,0 +1,419 @@
1
+ """Standalone CLI Interface für euer-datev."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import VERSION
8
+ from .config import generate_default_skr_template, load_datev_config
9
+ from .constants import DEFAULT_BERATER_NUMMER, DEFAULT_MANDANTEN_NUMMER
10
+ from .exporter import write_extf_csv_file
11
+ from .license import save_license_token, verify_license
12
+ from .mapping import assign_unique_beleglinks, map_transaction_to_datev
13
+ from .packager import package_datev_export
14
+ from .reader import get_db_connection, read_transactions_from_db
15
+ from .validator import validate_bookings
16
+
17
+ if sys.platform == "win32":
18
+ try:
19
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
20
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
21
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
22
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
23
+ except Exception:
24
+ pass
25
+
26
+
27
+ def _safe_print(*args, **kwargs) -> None:
28
+ """Sicheres print für Plattformen mit eingeschränktem Konsolen-Encoding (z. B. Windows cp1252)."""
29
+ file = kwargs.get("file") or sys.stdout
30
+ sep = kwargs.get("sep", " ")
31
+ end = kwargs.get("end", "\n")
32
+ text = sep.join(str(a) for a in args) + end
33
+ try:
34
+ file.write(text)
35
+ file.flush()
36
+ except (UnicodeEncodeError, AttributeError):
37
+ encoding = getattr(file, "encoding", None) or "ascii"
38
+ sanitized = text.encode(encoding, errors="replace").decode(encoding)
39
+ file.write(sanitized)
40
+ file.flush()
41
+
42
+
43
+ print = _safe_print
44
+
45
+
46
+ def find_default_db_path() -> Path:
47
+ """Sucht nach der standardmaessigen euer.db."""
48
+ local_db = Path.cwd() / ".euer" / "euer.db"
49
+ if local_db.is_file():
50
+ return local_db
51
+
52
+ local_root_db = Path.cwd() / "euer.db"
53
+ if local_root_db.is_file():
54
+ return local_root_db
55
+
56
+ home_db = Path.home() / ".config" / "euer" / "euer.db"
57
+ return home_db
58
+
59
+
60
+ def cmd_export(args: argparse.Namespace) -> int:
61
+ """Fuehrt den DATEV-Export durch."""
62
+ db_path = Path(args.db) if args.db else find_default_db_path()
63
+ if not db_path.is_file():
64
+ print(f"Fehler: Datenbank '{db_path}' nicht gefunden.", file=sys.stderr)
65
+ return 1
66
+
67
+ config = load_datev_config(
68
+ config_path=args.config,
69
+ override_year=args.year,
70
+ override_skr=args.skr,
71
+ override_berater=args.berater,
72
+ override_mandant=args.mandant,
73
+ )
74
+
75
+ try:
76
+ conn = get_db_connection(db_path)
77
+ except Exception as exc:
78
+ print(f"Fehler beim Verbinden mit der Datenbank: {exc}", file=sys.stderr)
79
+ return 1
80
+
81
+ try:
82
+ raw_txs = read_transactions_from_db(
83
+ conn=conn,
84
+ year=args.year,
85
+ month=args.month,
86
+ quarter=args.quarter,
87
+ from_date=args.from_date,
88
+ to_date=args.to_date,
89
+ )
90
+ finally:
91
+ conn.close()
92
+
93
+ if not raw_txs:
94
+ print("Hinweis: Keine Buchungen im angegebenen Zeitraum gefunden.", file=sys.stderr)
95
+ return 0
96
+
97
+ # Tatsächliche Buchungsjahre aus den Transaktionen ermitteln
98
+ years_in_tx = sorted({int(tx.date[:4]) for tx in raw_txs if tx.date and len(tx.date) >= 4})
99
+ if not years_in_tx:
100
+ print("Fehler: Buchungsdaten enthalten kein gültiges Buchungsdatum.", file=sys.stderr)
101
+ return 1
102
+
103
+ # Dynamische Erkennung des Wirtschaftsjahres falls kein --year angegeben wurde
104
+ if not args.year and len(years_in_tx) == 1:
105
+ detected_year = years_in_tx[0]
106
+ config.wirtschaftsjahr_beginn = f"{detected_year}0101"
107
+
108
+ # Lizenzprüfung gegen jedes tatsächlich in den Buchungen vorkommende Steuerjahr
109
+ # Verhindert jede Umgehung durch Weglassen von --year (z. B. via --from-date / --to-date)
110
+ for yr in years_in_tx:
111
+ lic = verify_license(target_year=yr)
112
+ if not lic.is_valid:
113
+ print(f"❌ Lizenzfehler: {lic.message}", file=sys.stderr)
114
+ return 1
115
+
116
+ # Evaluierungs-Modus: Beschränkung auf maximal 5 Buchungssätze zur Probe
117
+ lic_status = verify_license(target_year=years_in_tx[0])
118
+ if lic_status.is_evaluation:
119
+ max_eval_bookings = 5
120
+ if len(raw_txs) > max_eval_bookings:
121
+ print(
122
+ f"\n❌ Evaluierungs-Modus: Im Evaluierungsmodus können maximal {max_eval_bookings} "
123
+ f"Buchungssätze zur Probe exportiert werden (gefunden: {len(raw_txs)}).\n"
124
+ f" Für den vollständigen Export des Steuerjahres {years_in_tx[0]} aktiviere bitte eine Lizenz:\n\n"
125
+ f" euer datev license activate <DEIN-LIZENZSCHLÜSSEL>\n\n"
126
+ f" Lizenzen erwerben unter: https://euer-buchhaltung.de/datev\n",
127
+ file=sys.stderr,
128
+ )
129
+ return 1
130
+ else:
131
+ print(
132
+ f"⚠️ PROBE-EXPORT (Evaluierungs-Modus: {len(raw_txs)}/{max_eval_bookings} Buchungssätze).\n"
133
+ f" Für den vollständigen Jahresabschluss bitte Lizenz aktivieren: https://euer-buchhaltung.de/datev\n",
134
+ file=sys.stderr,
135
+ )
136
+
137
+ # Transformation in DATEV-Buchungssätze
138
+ bookings = [map_transaction_to_datev(tx, config) for tx in raw_txs]
139
+ assign_unique_beleglinks(bookings)
140
+
141
+ # Validierung
142
+ val_res = validate_bookings(bookings, config)
143
+ if not val_res.is_valid and not args.force:
144
+ print("\nFehler bei der Vorab-Prüfung des DATEV-Exports:", file=sys.stderr)
145
+ for issue in val_res.issues:
146
+ if issue.severity == "ERROR":
147
+ print(f" ❌ [{issue.code}] {issue.message}", file=sys.stderr)
148
+ print(
149
+ "\nExport abgebrochen. Behebe die Fehler oder nutze '--force' zum Erzwingen.",
150
+ file=sys.stderr,
151
+ )
152
+ return 1
153
+
154
+ # Zeitraum ermitteln
155
+ all_dates = [tx.date[:10].replace("-", "") for tx in raw_txs if tx.date]
156
+ min_date = min(all_dates) if all_dates else f"{args.year or 2026}0101"
157
+ max_date = max(all_dates) if all_dates else f"{args.year or 2026}1231"
158
+
159
+ # Ausgabepfad festlegen
160
+ export_fmt = getattr(args, "format", "zip")
161
+ year_str = str(args.year) if args.year else config.wirtschaftsjahr_beginn[:4]
162
+ suffix = f"_{year_str}"
163
+ if args.quarter:
164
+ suffix += f"_Q{args.quarter}"
165
+ elif args.month:
166
+ suffix += f"_M{args.month:02d}"
167
+
168
+ if args.output:
169
+ out_path = Path(args.output)
170
+ if out_path.is_dir() or (not out_path.suffix and not out_path.exists()):
171
+ out_path.mkdir(parents=True, exist_ok=True)
172
+ filename = f"DATEV_EXTF{suffix}.{export_fmt}"
173
+ target_path = out_path / filename
174
+ else:
175
+ target_path = out_path
176
+ else:
177
+ out_dir = config.export_dir or (Path.cwd() / "exports")
178
+ out_dir.mkdir(parents=True, exist_ok=True)
179
+ target_path = out_dir / f"DATEV_EXTF{suffix}.{export_fmt}"
180
+
181
+ # Export ausfuehren
182
+ if export_fmt == "csv":
183
+ write_extf_csv_file(
184
+ target_path=target_path,
185
+ bookings=bookings,
186
+ config=config,
187
+ date_from=min_date,
188
+ date_to=max_date,
189
+ )
190
+ print(f"✅ DATEV EXTF-700 CSV erfolgreich exportiert: {target_path}")
191
+ else:
192
+ res = package_datev_export(
193
+ target_zip_path=target_path,
194
+ bookings=bookings,
195
+ config=config,
196
+ date_from=min_date,
197
+ date_to=max_date,
198
+ validation_result=val_res,
199
+ )
200
+ if not val_res.is_valid and not args.force:
201
+ print(
202
+ f"Exportpaket {target_path} enthält Fehler beim Verpacken der Belege. "
203
+ "Siehe PRUEFPROTOKOLL.txt im Archiv.",
204
+ file=sys.stderr,
205
+ )
206
+ return 1
207
+ print(f"✅ DATEV-Exportpaket erfolgreich erstellt: {res.zip_path}")
208
+ print(f" • {res.total_bookings} Buchungssätze ({res.total_volume_eur:,.2f} EUR)")
209
+ print(f" • {res.total_receipts_packaged} Belege im Archiv abgelegt (Beleglink-Referenz)")
210
+ if res.missing_receipts_count > 0:
211
+ print(
212
+ f" ⚠️ {res.missing_receipts_count} Belegdateien nicht gefunden (Details in PRÜFPROTOKOLL.txt)"
213
+ )
214
+
215
+ if val_res.warning_count > 0:
216
+ print(f" ℹ️ {val_res.warning_count} Warnungen bei der Validierung aufgetreten.")
217
+
218
+ return 0
219
+
220
+
221
+ def cmd_validate(args: argparse.Namespace) -> int:
222
+ """Prueft die Buchungsdaten auf DATEV-Konformitaet ohne Datei zu schreiben."""
223
+ db_path = Path(args.db) if args.db else find_default_db_path()
224
+ if not db_path.is_file():
225
+ print(f"Fehler: Datenbank '{db_path}' nicht gefunden.", file=sys.stderr)
226
+ return 1
227
+
228
+ config = load_datev_config(
229
+ config_path=args.config,
230
+ override_year=args.year,
231
+ override_skr=args.skr,
232
+ )
233
+
234
+ try:
235
+ conn = get_db_connection(db_path)
236
+ except Exception as exc:
237
+ print(f"Fehler beim Verbinden mit der Datenbank: {exc}", file=sys.stderr)
238
+ return 1
239
+
240
+ try:
241
+ raw_txs = read_transactions_from_db(conn=conn, year=args.year)
242
+ finally:
243
+ conn.close()
244
+
245
+ if not raw_txs:
246
+ print("Keine Buchungen gefunden.", file=sys.stderr)
247
+ return 0
248
+
249
+ if not args.year:
250
+ years_in_tx = {tx.date[:4] for tx in raw_txs if tx.date and len(tx.date) >= 4}
251
+ if len(years_in_tx) == 1:
252
+ detected_year = list(years_in_tx)[0]
253
+ config.wirtschaftsjahr_beginn = f"{detected_year}0101"
254
+
255
+ bookings = [map_transaction_to_datev(tx, config) for tx in raw_txs]
256
+ assign_unique_beleglinks(bookings)
257
+ val_res = validate_bookings(bookings, config)
258
+
259
+ print(f"\nDATEV-Validierungsbericht für SKR {config.skr} ({len(bookings)} Buchungen):")
260
+ print(f" Fehler (ERROR): {val_res.error_count}")
261
+ print(f" Warnungen (WARN): {val_res.warning_count}")
262
+ print(f" Hinweise (INFO): {val_res.info_count}\n")
263
+
264
+ for issue in val_res.issues:
265
+ icon = "❌" if issue.severity == "ERROR" else ("⚠️" if issue.severity == "WARNING" else "ℹ️")
266
+ print(f" {icon} [{issue.severity}] {issue.message}")
267
+
268
+ if not val_res.is_valid:
269
+ print("\n❌ Validierung fehlgeschlagen: Behebe die Fehler vor dem Export.", file=sys.stderr)
270
+ return 1
271
+
272
+ print("\n✅ Validierung erfolgreich: Buchungsstapel ist bereit für DATEV.")
273
+ return 0
274
+
275
+
276
+ def cmd_init_skr(args: argparse.Namespace) -> int:
277
+ """Gibt empfohlene Konfigurations-Templates für SKR03 oder SKR04 aus."""
278
+ template = generate_default_skr_template(skr=args.skr)
279
+ if args.write:
280
+ target = Path(args.write)
281
+ if target.is_file():
282
+ content = target.read_text(encoding="utf-8")
283
+ content += "\n\n" + template
284
+ target.write_text(content, encoding="utf-8")
285
+ print(f"✅ SKR-{args.skr}-Vorlage an {target} angehängt.")
286
+ else:
287
+ target.parent.mkdir(parents=True, exist_ok=True)
288
+ target.write_text(template, encoding="utf-8")
289
+ print(f"✅ SKR-{args.skr}-Vorlage in neue Datei {target} geschrieben.")
290
+ else:
291
+ print(template)
292
+ return 0
293
+
294
+
295
+ def cmd_license(args: argparse.Namespace) -> int:
296
+ """Verwaltet den Lizenzschlüssel."""
297
+ if args.action == "status":
298
+ status = verify_license()
299
+ print("\neuer-datev Lizenzstatus:")
300
+ print(f" Gültig: {'Ja' if status.is_valid else 'Nein'}")
301
+ print(f" Lizenztyp: {status.tier.capitalize()}")
302
+ print(f" Lizenznehmer: {status.licensee}")
303
+ if status.allowed_years:
304
+ print(f" Steuerjahr: {', '.join(str(y) for y in status.allowed_years)}")
305
+ elif status.tier in ("kanzlei", "pro"):
306
+ print(" Steuerjahr: Alle (Kanzlei-Lizenz)")
307
+ if status.active_tokens_count > 0:
308
+ print(f" Lizenzen: {status.active_tokens_count} aktiv hinterlegt")
309
+ print(f" Details: {status.message}\n")
310
+ return 0 if status.is_valid else 1
311
+ elif args.action == "activate":
312
+ if not args.key:
313
+ print(
314
+ "Fehler: Lizenzschlüssel erforderlich (z. B. euer datev license activate <SCHLÜSSEL>).",
315
+ file=sys.stderr,
316
+ )
317
+ return 1
318
+ clean_key = args.key.strip()
319
+ try:
320
+ path, status = save_license_token(clean_key)
321
+ print(f"✅ Lizenz erfolgreich aktiviert und hinzugefügt in: {path}")
322
+ print(f" Lizenznehmer: {status.licensee}")
323
+ if status.allowed_years:
324
+ print(
325
+ f" Freigeschaltete Steuerjahre: {', '.join(str(y) for y in status.allowed_years)}"
326
+ )
327
+ else:
328
+ print(" Freigeschaltete Steuerjahre: Alle (Kanzlei-Lizenz)")
329
+ return 0
330
+ except ValueError as exc:
331
+ print(f"❌ Ungültiger Lizenzschlüssel: {exc}", file=sys.stderr)
332
+ return 1
333
+ return 0
334
+
335
+
336
+ def build_parser() -> argparse.ArgumentParser:
337
+ """Baut den Standalone-Kommandozeilen-Parser."""
338
+ parser = argparse.ArgumentParser(
339
+ prog="euer-datev",
340
+ description="euer-datev: Offizieller DATEV-Export (EXTF 700) für euer Buchhaltung",
341
+ )
342
+ parser.add_argument("--version", action="version", version=f"euer-datev {VERSION}")
343
+
344
+ subparsers = parser.add_subparsers(dest="command", required=True)
345
+
346
+ # Subcommand: export
347
+ p_export = subparsers.add_parser("export", help="Exportiert Buchungsdaten im DATEV-Format")
348
+ p_export.add_argument("--db", help="Pfad zur euer.db")
349
+ p_export.add_argument("--config", help="Pfad zur config.toml")
350
+ p_export.add_argument("--year", type=int, help="Steuerjahr filtern (z. B. 2026)")
351
+ p_export.add_argument("--month", type=int, choices=range(1, 13), help="Monat filtern (1-12)")
352
+ p_export.add_argument("--quarter", type=int, choices=[1, 2, 3, 4], help="Quartal filtern (1-4)")
353
+ p_export.add_argument("--from-date", help="Von Datum (YYYY-MM-DD)")
354
+ p_export.add_argument("--to-date", help="Bis Datum (YYYY-MM-DD)")
355
+ p_export.add_argument(
356
+ "--skr", choices=["03", "04"], default=None, help="Kontenrahmen SKR 03 oder SKR 04"
357
+ )
358
+ p_export.add_argument(
359
+ "--berater", default=None, help=f"Beraternummer (default: {DEFAULT_BERATER_NUMMER})"
360
+ )
361
+ p_export.add_argument(
362
+ "--mandant", default=None, help=f"Mandantennummer (default: {DEFAULT_MANDANTEN_NUMMER})"
363
+ )
364
+ p_export.add_argument(
365
+ "--format",
366
+ choices=["zip", "csv"],
367
+ default="zip",
368
+ help="Ausgabeformat: zip (inkl. Belegen) oder csv",
369
+ )
370
+ p_export.add_argument("-o", "--output", help="Ausgabedatei oder Zielverzeichnis")
371
+ p_export.add_argument(
372
+ "--force", action="store_true", help="Export trotz Validierungsfehlern erzwingen"
373
+ )
374
+ p_export.set_defaults(func=cmd_export)
375
+
376
+ # Subcommand: validate
377
+ p_validate = subparsers.add_parser(
378
+ "validate", help="Prueft Daten auf DATEV-Konformitaet ohne Export"
379
+ )
380
+ p_validate.add_argument("--db", help="Pfad zur euer.db")
381
+ p_validate.add_argument("--config", help="Pfad zur config.toml")
382
+ p_validate.add_argument("--year", type=int, help="Steuerjahr filtern (z. B. 2026)")
383
+ p_validate.add_argument(
384
+ "--skr", choices=["03", "04"], default=None, help="Kontenrahmen SKR 03 oder SKR 04"
385
+ )
386
+ p_validate.set_defaults(func=cmd_validate)
387
+
388
+ # Subcommand: init-skr
389
+ p_init = subparsers.add_parser("init-skr", help="Erzeugt TOML-Vorlage für SKR 03 oder SKR 04")
390
+ p_init.add_argument(
391
+ "--skr", choices=["03", "04"], default="03", help="Kontenrahmen wählen (default: 03)"
392
+ )
393
+ p_init.add_argument("--write", help="In Zieldatei schreiben oder anhängen")
394
+ p_init.set_defaults(func=cmd_init_skr)
395
+
396
+ # Subcommand: license
397
+ p_lic = subparsers.add_parser("license", help="Lizenzschlüssel verwalten")
398
+ p_lic.add_argument(
399
+ "action", choices=["status", "activate"], help="Aktion (status oder activate)"
400
+ )
401
+ p_lic.add_argument("key", nargs="?", help="Lizenzschlüssel bei activate")
402
+ p_lic.set_defaults(func=cmd_license)
403
+
404
+ return parser
405
+
406
+
407
+ def main() -> None:
408
+ """Haupt-Einstiegspunkt für die CLI."""
409
+ parser = build_parser()
410
+ args = parser.parse_args()
411
+ if hasattr(args, "func"):
412
+ sys.exit(args.func(args))
413
+ else:
414
+ parser.print_help()
415
+ sys.exit(1)
416
+
417
+
418
+ if __name__ == "__main__":
419
+ main()
euer_datev/config.py ADDED
@@ -0,0 +1,262 @@
1
+ """Konfigurations-Lader und Standard-Templates für euer-datev."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .constants import (
8
+ DEFAULT_BERATER_NUMMER,
9
+ DEFAULT_MANDANTEN_NUMMER,
10
+ EXTF_ACCOUNT_LENGTH_DEFAULT,
11
+ SKR03_EXPENSE_FALLBACKS,
12
+ SKR03_FINANCIAL_ACCOUNTS,
13
+ SKR04_EXPENSE_FALLBACKS,
14
+ SKR04_FINANCIAL_ACCOUNTS,
15
+ )
16
+ from .models import DatevConfig
17
+
18
+ if sys.version_info >= (3, 11):
19
+ import tomllib
20
+ else:
21
+ import tomli as tomllib # type: ignore[no-redef]
22
+
23
+
24
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "euer"
25
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
26
+
27
+
28
+ def find_config_path(explicit_path: Path | str | None = None) -> Path:
29
+ """Ermittelt den Pfad zur config.toml (explizit > Projektverzeichnis > User-Config)."""
30
+ if explicit_path:
31
+ return Path(explicit_path)
32
+
33
+ # Projekt-lokale Config
34
+ local_config = Path.cwd() / ".euer" / "config.toml"
35
+ if local_config.is_file():
36
+ return local_config
37
+
38
+ return DEFAULT_CONFIG_PATH
39
+
40
+
41
+ def load_raw_toml(path: Path) -> dict[str, Any]:
42
+ """Laedt eine TOML-Datei sicher via tomllib."""
43
+ if not path.is_file():
44
+ return {}
45
+ try:
46
+ with open(path, "rb") as f:
47
+ return tomllib.load(f)
48
+ except Exception as exc:
49
+ raise ValueError(f"Fehler beim Parsen der Konfigurationsdatei {path}: {exc}") from exc
50
+
51
+
52
+ def load_datev_config(
53
+ config_path: Path | str | None = None,
54
+ override_year: int | None = None,
55
+ override_skr: str | None = None,
56
+ override_berater: str | None = None,
57
+ override_mandant: str | None = None,
58
+ ) -> DatevConfig:
59
+ """Laedt die DatevConfig aus der Datei und wendet ggf. CLI-Overrides an."""
60
+ resolved_path = find_config_path(config_path)
61
+ data = load_raw_toml(resolved_path)
62
+
63
+ datev_sec = data.get("datev", {})
64
+ if not isinstance(datev_sec, dict):
65
+ datev_sec = {}
66
+
67
+ # Grunddaten
68
+ skr = override_skr or str(datev_sec.get("skr", "03")).strip()
69
+ if skr not in ("03", "04"):
70
+ skr = "03"
71
+
72
+ berater = (
73
+ override_berater or str(datev_sec.get("berater_nummer", DEFAULT_BERATER_NUMMER)).strip()
74
+ )
75
+ mandant = (
76
+ override_mandant or str(datev_sec.get("mandanten_nummer", DEFAULT_MANDANTEN_NUMMER)).strip()
77
+ )
78
+ mandanten_name = str(
79
+ datev_sec.get("mandanten_name", data.get("user", {}).get("name", ""))
80
+ ).strip()
81
+
82
+ try:
83
+ acc_len = int(datev_sec.get("sachkonten_laenge", EXTF_ACCOUNT_LENGTH_DEFAULT))
84
+ except (ValueError, TypeError):
85
+ acc_len = EXTF_ACCOUNT_LENGTH_DEFAULT
86
+
87
+ # Wirtschaftsjahr Beginn
88
+ year = override_year or 2026
89
+ wj_beginn = f"{year}0101"
90
+
91
+ # Steuermodus (standard vs small_business)
92
+ tax_mode = (
93
+ str(datev_sec.get("tax_mode", data.get("tax", {}).get("mode", "standard"))).strip().lower()
94
+ )
95
+ if tax_mode not in ("standard", "small_business"):
96
+ tax_mode = "standard"
97
+
98
+ # Receipts Root
99
+ receipts_root: Path | None = None
100
+ receipts_cfg = data.get("receipts", {}).get("root")
101
+ if receipts_cfg:
102
+ receipts_root = Path(receipts_cfg).expanduser()
103
+ if not receipts_root.is_absolute() and resolved_path.is_file():
104
+ receipts_root = resolved_path.parent / receipts_root
105
+
106
+ # Export Dir (unterstützt 'exports.directory' aus euer sowie 'exports.dir')
107
+ export_dir: Path | None = None
108
+ export_cfg = data.get("exports", {}).get("directory") or data.get("exports", {}).get("dir")
109
+ if export_cfg:
110
+ export_dir = Path(export_cfg).expanduser()
111
+
112
+ # Kontenrahmen aus Spec 010 ([[ledger_accounts]]) sowie [datev.ledger_accounts]
113
+ ledger_mappings: dict[str, str] = {}
114
+ raw_datev_ledger = datev_sec.get("ledger_accounts", {})
115
+ if isinstance(raw_datev_ledger, dict):
116
+ for k, v in raw_datev_ledger.items():
117
+ if k and v:
118
+ ledger_mappings[str(k).strip().lower()] = str(v).strip()
119
+
120
+ raw_ledger = data.get("ledger_accounts", [])
121
+ if isinstance(raw_ledger, list):
122
+ for entry in raw_ledger:
123
+ if isinstance(entry, dict):
124
+ acc_num = entry.get("account_number")
125
+ if not acc_num:
126
+ continue
127
+ acc_str = str(acc_num).strip()
128
+ for field_name in ("key", "name"):
129
+ val = str(entry.get(field_name, "")).strip().lower()
130
+ if val and val not in ledger_mappings:
131
+ ledger_mappings[val] = acc_str
132
+
133
+ # Finanzkonten Mapping:
134
+ # 1. Prio: [datev.accounts]
135
+ # 2. Prio: [accounts.mapping] (SPEC-07 Konvention)
136
+ # 3. Prio: [accounts] (flache Tabelle)
137
+ fin_mappings: dict[str, str] = {}
138
+
139
+ raw_datev_fin = datev_sec.get("accounts", {})
140
+ if isinstance(raw_datev_fin, dict):
141
+ for acc_name, acc_num in raw_datev_fin.items():
142
+ if acc_name and acc_num:
143
+ fin_mappings[str(acc_name).strip().lower()] = str(acc_num).strip()
144
+
145
+ raw_accounts_mapping = data.get("accounts", {}).get("mapping", {})
146
+ if isinstance(raw_accounts_mapping, dict):
147
+ for acc_name, acc_num in raw_accounts_mapping.items():
148
+ key = str(acc_name).strip().lower()
149
+ acc_val = str(acc_num).strip()
150
+ if not key or not acc_val:
151
+ continue
152
+ # Sachkonten vs Finanzkonten unterscheiden:
153
+ # SKR03: Finanzkonten 1000-1999; SKR04: 1000-2999
154
+ is_fin = (skr == "03" and acc_val.startswith("1")) or (
155
+ skr == "04" and acc_val[:1] in ("1", "2")
156
+ )
157
+ if is_fin:
158
+ if key not in fin_mappings:
159
+ fin_mappings[key] = acc_val
160
+ else:
161
+ if key not in ledger_mappings:
162
+ ledger_mappings[key] = acc_val
163
+
164
+ raw_accounts = data.get("accounts", {})
165
+ if isinstance(raw_accounts, dict):
166
+ for acc_name, acc_num in raw_accounts.items():
167
+ if acc_name not in ("private", "mapping") and isinstance(acc_num, (str, int)):
168
+ key = str(acc_name).strip().lower()
169
+ acc_val = str(acc_num).strip()
170
+ if key and acc_val and key not in fin_mappings:
171
+ fin_mappings[key] = acc_val
172
+
173
+ # Fallback-Finanzkonten ergänzen falls leer
174
+ defaults_fin = SKR03_FINANCIAL_ACCOUNTS if skr == "03" else SKR04_FINANCIAL_ACCOUNTS
175
+ for default_name, default_num in defaults_fin.items():
176
+ if default_name not in fin_mappings:
177
+ fin_mappings[default_name] = default_num
178
+
179
+ return DatevConfig(
180
+ berater_nummer=berater,
181
+ mandanten_nummer=mandant,
182
+ skr=skr,
183
+ wirtschaftsjahr_beginn=wj_beginn,
184
+ sachkonten_laenge=acc_len,
185
+ mandanten_name=mandanten_name,
186
+ tax_mode=tax_mode,
187
+ financial_account_mappings=fin_mappings,
188
+ ledger_account_mappings=ledger_mappings,
189
+ receipts_root=receipts_root,
190
+ export_dir=export_dir,
191
+ )
192
+
193
+
194
+ def generate_default_skr_template(skr: str = "03") -> str:
195
+ """Erzeugt einen empfohlenen [datev]- und [[ledger_accounts]]-Block für config.toml."""
196
+ fallbacks = SKR03_EXPENSE_FALLBACKS if skr == "03" else SKR04_EXPENSE_FALLBACKS
197
+ fin_accs = SKR03_FINANCIAL_ACCOUNTS if skr == "03" else SKR04_FINANCIAL_ACCOUNTS
198
+
199
+ lines = [
200
+ "# --- DATEV-Konfiguration (euer-datev) ---",
201
+ "[datev]",
202
+ f'berater_nummer = "{DEFAULT_BERATER_NUMMER}"',
203
+ f'mandanten_nummer = "{DEFAULT_MANDANTEN_NUMMER}"',
204
+ f'skr = "{skr}"',
205
+ "sachkonten_laenge = 4",
206
+ 'mandanten_name = "Mein Unternehmen"',
207
+ "",
208
+ "[datev.accounts]",
209
+ ]
210
+ for name, num in fin_accs.items():
211
+ lines.append(f'"{name}" = "{num}"')
212
+
213
+ lines.extend(
214
+ [
215
+ "",
216
+ f"# --- Buchungskonten (SKR {skr} Vorlage für Spec 010) ---",
217
+ ]
218
+ )
219
+
220
+ sample_ledger = [
221
+ (
222
+ "saas",
223
+ "Software & SaaS-Lizenzen",
224
+ "Laufende EDV-Kosten",
225
+ fallbacks.get("laufende edv-kosten", "4930"),
226
+ ),
227
+ ("hosting", "Server & Hosting", "Laufende EDV-Kosten", "4940" if skr == "03" else "6820"),
228
+ (
229
+ "gwg",
230
+ "Geringwertige Wirtschaftsgüter",
231
+ "Arbeitsmittel",
232
+ fallbacks.get("arbeitsmittel", "0480"),
233
+ ),
234
+ (
235
+ "telefon",
236
+ "Telefon & Internet",
237
+ "Telefon / Internet",
238
+ fallbacks.get("telefon / internet", "4920"),
239
+ ),
240
+ ("reise", "Reisekosten Unternehmer", "Reisekosten", fallbacks.get("reisekosten", "4670")),
241
+ (
242
+ "bewirtung",
243
+ "Geschäftliche Bewirtung 70%",
244
+ "Bewirtungsaufwendungen",
245
+ fallbacks.get("bewirtungskosten", "4650"),
246
+ ),
247
+ ("werbung", "Marketing & Anzeigen", "Werbekosten", fallbacks.get("werbekosten", "4600")),
248
+ ]
249
+
250
+ for key, name, cat, num in sample_ledger:
251
+ lines.extend(
252
+ [
253
+ "[[ledger_accounts]]",
254
+ f'key = "{key}"',
255
+ f'name = "{name}"',
256
+ f'category = "{cat}"',
257
+ f'account_number = "{num}"',
258
+ "",
259
+ ]
260
+ )
261
+
262
+ return "\n".join(lines)