feat(lexware): add integration foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 13:10:27 +02:00
parent 8d37eb29b3
commit ffffb68898
28 changed files with 1150 additions and 11 deletions

View file

@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"}
SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "api_key", "lexware_api_key", "token", "access_token", "secret", "secret_key"}
def to_audit_data(value: Any, seen: set[int] | None = None) -> Any:
@ -207,6 +207,11 @@ def action_title(action: str) -> str:
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",
"system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert",
"lexware.settings.update": "Lexware Einstellungen geändert",
"lexware.connection.test_success": "Lexware Verbindungstest erfolgreich",
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
}
return labels.get(action, action)

View file

@ -0,0 +1,361 @@
import json
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest
from urllib.request import urlopen
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.config import settings
from app.models.lexware import LexwareSyncRecord
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.system_settings_repository import SystemSettingsRepository
from app.schemas.lexware import (
LexwareCustomerMapping,
LexwareInvoicePreparationResponse,
LexwareLineItemMapping,
LexwareSettingsResponse,
LexwareSettingsUpdate,
LexwareTestConnectionResponse,
)
from app.schemas.system_setting import SettingsSource
from app.services.audit_service import write_audit_log
from app.services.system_settings_service import parse_bool
LEXWARE_KEYS = (
"lexware.enabled",
"lexware.api_base_url",
"lexware.api_key",
"lexware.organization_name",
"lexware.default_tax_rate",
"lexware.default_payment_terms_days",
)
LEXWARE_SECRET_KEYS = {"lexware.api_key"}
DEFAULT_API_BASE_URL = "https://api.lexware.io"
@dataclass(frozen=True)
class LexwareRuntimeConfig:
enabled: bool
api_base_url: str
api_key: str
organization_name: str
default_tax_rate: Decimal
default_payment_terms_days: int
source: SettingsSource
@property
def api_key_is_set(self) -> bool:
return bool(self.api_key)
@property
def is_configured(self) -> bool:
return self.enabled and bool(self.api_base_url and self.api_key)
def _decimal(value: object, *, default: Decimal) -> Decimal:
try:
return Decimal(str(value or "").replace(",", "."))
except Exception:
return default
def _int(value: object, *, default: int) -> int:
try:
parsed = int(str(value or "").strip())
except ValueError:
return default
return parsed if 0 <= parsed <= 365 else default
def _euros(cents: int) -> Decimal:
return (Decimal(cents) / Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _safe_error_message(error: Exception) -> str:
if isinstance(error, HTTPError):
if error.code in {401, 403}:
return "Lexware hat den API-Key abgelehnt."
if error.code == 404:
return "Lexware-Endpunkt wurde nicht gefunden."
return "Lexware hat die Anfrage nicht erfolgreich beantwortet."
if isinstance(error, URLError):
return "Lexware ist momentan nicht erreichbar."
return "Lexware-Verbindungstest konnte nicht abgeschlossen werden."
class LexwareService:
@staticmethod
def get_settings(db: Session) -> LexwareSettingsResponse:
return LexwareService.settings_response(LexwareService.get_runtime_config(db))
@staticmethod
def get_runtime_config(db: Session) -> LexwareRuntimeConfig:
values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
db_enabled = parse_bool(values.get("lexware.enabled"), default=False)
db_base_url = (values.get("lexware.api_base_url") or "").rstrip("/")
db_api_key = values.get("lexware.api_key") or ""
db_has_config = db_enabled or db_base_url or db_api_key
if db_has_config:
return LexwareRuntimeConfig(
enabled=db_enabled,
api_base_url=db_base_url or DEFAULT_API_BASE_URL,
api_key=db_api_key,
organization_name=values.get("lexware.organization_name", ""),
default_tax_rate=_decimal(values.get("lexware.default_tax_rate"), default=Decimal("19.00")),
default_payment_terms_days=_int(values.get("lexware.default_payment_terms_days"), default=14),
source="database",
)
if settings.lexware_enabled or settings.lexware_api_key:
return LexwareRuntimeConfig(
enabled=settings.lexware_enabled,
api_base_url=(settings.lexware_api_base_url or DEFAULT_API_BASE_URL).rstrip("/"),
api_key=settings.lexware_api_key or "",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="environment",
)
return LexwareRuntimeConfig(
enabled=False,
api_base_url=DEFAULT_API_BASE_URL,
api_key="",
organization_name="",
default_tax_rate=Decimal("19.00"),
default_payment_terms_days=14,
source="missing",
)
@staticmethod
def update_settings(db: Session, payload: LexwareSettingsUpdate, *, actor: User, request: Request) -> LexwareSettingsResponse:
current_values = {key: setting.value.strip() for key, setting in SystemSettingsRepository.get_many(db, LEXWARE_KEYS).items()}
api_key = payload.api_key if payload.api_key else current_values.get("lexware.api_key", "")
updates = {
"lexware.enabled": "true" if payload.enabled else "false",
"lexware.api_base_url": payload.api_base_url.rstrip("/") or DEFAULT_API_BASE_URL,
"lexware.api_key": api_key,
"lexware.organization_name": payload.organization_name,
"lexware.default_tax_rate": str(payload.default_tax_rate),
"lexware.default_payment_terms_days": str(payload.default_payment_terms_days),
}
for key, value in updates.items():
SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in LEXWARE_SECRET_KEYS)
db.commit()
write_audit_log(
db,
action="lexware.settings.update",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={
"enabled": payload.enabled,
"api_base_url": payload.api_base_url,
"organization_name": payload.organization_name,
"default_tax_rate": str(payload.default_tax_rate),
"default_payment_terms_days": payload.default_payment_terms_days,
"api_key_changed": bool(payload.api_key),
},
)
return LexwareService.get_settings(db)
@staticmethod
def test_connection(db: Session, *, actor: User, request: Request) -> LexwareTestConnectionResponse:
config = LexwareService.get_runtime_config(db)
if not config.is_configured:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": "lexware_not_configured", "source": config.source},
)
return LexwareTestConnectionResponse(
success=False,
message="Lexware ist nicht vollständig konfiguriert.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
try:
profile = LexwareService._get_profile(config)
except Exception as exc:
write_audit_log(
db,
action="lexware.connection.test_failed",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"reason": exc.__class__.__name__, "source": config.source, "api_base_url": config.api_base_url},
)
return LexwareTestConnectionResponse(
success=False,
message=_safe_error_message(exc),
source=config.source,
api_base_url=config.api_base_url,
organization_name=config.organization_name,
)
organization_name = config.organization_name or str(profile.get("organizationName") or profile.get("companyName") or "")
write_audit_log(
db,
action="lexware.connection.test_success",
entity_type="system_settings",
entity_label="Lexware Office",
actor=actor,
request=request,
metadata={"source": config.source, "api_base_url": config.api_base_url, "organization_name": organization_name},
)
return LexwareTestConnectionResponse(
success=True,
message="Lexware-Verbindung erfolgreich geprüft.",
source=config.source,
api_base_url=config.api_base_url,
organization_name=organization_name,
)
@staticmethod
def prepare_invoice(
db: Session,
repair: Repair,
estimate: RepairEstimate,
*,
actor: User,
request: Request,
) -> LexwareInvoicePreparationResponse:
config = LexwareService.get_runtime_config(db)
if estimate.status != "approved":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur freigegebene Kostenvoranschläge können für Lexware vorbereitet werden")
warnings: list[str] = []
if not config.enabled:
warnings.append("Lexware ist noch nicht aktiviert. Der Export ist nur vorbereitet.")
if not config.api_key_is_set:
warnings.append("Lexware API-Key ist noch nicht gesetzt.")
if not repair.customer_email:
warnings.append("Beim Kunden ist keine E-Mail-Adresse hinterlegt.")
if not estimate.items:
warnings.append("Der Kostenvoranschlag enthält keine Positionen.")
customer_payload = {
"roles": {"customer": {}},
"company": {"name": repair.customer_name},
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
}
customer_mapping = LexwareCustomerMapping(
name=repair.customer_name,
email=repair.customer_email,
phone=repair.customer_phone,
search_strategy="email" if repair.customer_email else "name",
create_payload=customer_payload,
)
line_items = [
LexwareLineItemMapping(
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price=_euros(item.unit_price_cents),
tax_rate=estimate.tax_rate_percent,
total=_euros(item.total_cents),
)
for item in estimate.items
]
payload_summary = {
"type": "invoice",
"title": f"Rechnung zu Reparatur {repair.repair_number}",
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
"repair_number": repair.repair_number,
"estimate_number": estimate.estimate_number,
"currency": estimate.currency,
"payment_terms_days": config.default_payment_terms_days,
"subtotal": str(_euros(estimate.subtotal_cents)),
"tax": str(_euros(estimate.tax_cents)),
"total": str(_euros(estimate.total_cents)),
"line_item_count": len(line_items),
}
record = LexwareSyncRecord(
entity_type="repair_estimate",
entity_id=estimate.id,
lexware_resource_type="invoice",
status="pending" if not warnings else "skipped",
direction="push",
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
error_message="; ".join(warnings) if warnings else None,
)
db.add(record)
db.commit()
db.refresh(record)
write_audit_log(
db,
action="lexware.invoice.prepare",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=f"{estimate.estimate_number} · {estimate.title}",
actor=actor,
request=request,
metadata={
"repair_id": repair.id,
"repair_number": repair.repair_number,
"ready_for_export": not warnings,
"sync_record_id": record.id,
},
)
return LexwareInvoicePreparationResponse(
ready_for_export=not warnings,
payload_summary=payload_summary,
customer_mapping=customer_mapping,
line_item_mapping=line_items,
tax_mapping={
"source": "repair_estimate",
"tax_rate": str(estimate.tax_rate_percent or config.default_tax_rate),
"default_tax_rate": str(config.default_tax_rate),
"tax_amount": str(_euros(estimate.tax_cents)),
},
warnings=warnings,
sync_record_id=record.id,
)
@staticmethod
def settings_response(config: LexwareRuntimeConfig) -> LexwareSettingsResponse:
return LexwareSettingsResponse(
enabled=config.enabled,
api_base_url=config.api_base_url,
api_key_is_set=config.api_key_is_set,
organization_name=config.organization_name,
default_tax_rate=config.default_tax_rate,
default_payment_terms_days=config.default_payment_terms_days,
source=config.source,
)
@staticmethod
def _get_profile(config: LexwareRuntimeConfig) -> dict:
request = UrlRequest(
f"{config.api_base_url}/v1/profile",
headers={
"Authorization": f"Bearer {config.api_key}",
"Accept": "application/json",
},
method="GET",
)
with urlopen(request, timeout=15) as response:
body = response.read().decode("utf-8")
if not body:
return {}
data = json.loads(body)
return data if isinstance(data, dict) else {}