Files
TimeTracker/app/services/peppol_service.py
T
Dries Peeters 1836cb3c2d chore(typing): resolve mypy errors and harden type checking
Drives ``mypy app/`` from 567 errors in 208 files to 0 errors across the
376 source files checked by ``./scripts/run-ci-local.sh code-quality``.

Configuration & dependencies
- pyproject.toml: enable implicit_optional (Flask-style ``x: str = None``
  defaults), silence truthy-function/truthy-bool (legitimate import-guard
  checks like ``KanbanColumn``), and disable warn_return_any (SQLAlchemy
  1.x ``Query`` API returns Any pervasively). Add module overrides for
  ``app.models.*``, repositories, base CRUD service, and known
  ``joinedload`` / ``Query.paginate`` callers where mypy cannot model the
  Flask-SQLAlchemy runtime API without a plugin.
- requirements-test.txt: pin ``types-requests``, ``types-bleach``,
  ``types-Markdown``, ``types-python-dateutil`` so mypy stops complaining
  about missing stubs.

Latent bugs fixed while driving mypy to zero
- app/utils/logger.py, app/utils/datetime_utils.py: drop imports of
  symbols that don't exist (``get_performance_metrics``,
  ``from_app_timezone``, ``to_app_timezone``) — these would have raised
  at import time on first use.
- app/services/currency_service.py: ``from typing import Decimal`` was a
  bug (typing has no Decimal); switch to ``decimal.Decimal`` and rename
  the ``D`` alias.
- app/utils/env_validation.py, app/utils/role_migration.py: ``Dict[str,
  any]`` → ``Dict[str, Any]`` (built-in ``any`` is not a type).
- app/utils/email.py: introduce ``send_template_email`` and update the
  three callers (``client_approval_service``,
  ``client_notification_service``, ``workflow_engine``) that were
  passing ``to=``/``template=``/etc. to ``send_email`` whose signature
  doesn't accept them — calls would have raised TypeError at runtime.
- app/services/permission_service.py: rewrite ``grant_permission`` /
  ``revoke_permission`` to use the actual ``Role`` ↔ ``Permission``
  many-to-many relationship; the old code referenced non-existent
  ``Permission.role_id`` / ``Permission.granted`` columns.
- app/services/gps_tracking_service.py: pass the required ``title`` and
  ``expense_date`` fields when creating mileage ``Expense`` rows.
- app/services/workflow_engine.py: ``_perform_action`` now forwards the
  ``rule`` argument to ``_action_log_time``, and ``_action_webhook``
  short-circuits when ``url`` is missing.
- app/services/time_tracking_service.py: validate ``start_time`` /
  ``end_time`` before comparing them.
- app/services/export_service.py: build CSV in a ``StringIO`` then wrap
  the bytes in ``BytesIO`` — ``csv.writer`` requires text I/O.
- app/integrations/peppol_smp.py: avoid attribute access on ``None`` in
  the SMP ``href`` fallback.
- app/integrations/{github,gitlab,slack}.py: coerce query-string params
  to strings so ``requests.get(params=...)`` matches the typed signature
  (and is what the HTTP layer expects anyway).
- app/integrations/{xero,quickbooks}.py: guard ``get_access_token()``
  returning ``None`` before calling private ``_api_request`` helpers.

Annotation-only changes
- Add ``Dict[str, Any]`` / ``list`` / ``Optional[...]`` annotations to
  service dict-literals that mypy could not infer from heterogeneous
  values (``ai_suggestion_service``, ``ai_categorization_service``,
  ``custom_report_service``, ``unpaid_hours_service``,
  ``integration_service``, ``invoice_service``, ``backup_service``,
  ``inventory_report_service``, ``analytics_service``, etc.).
- ``app/utils/event_bus.py``: ``emit_event`` accepts ``str |
  WebhookEvent`` and normalizes to ``str`` so all call-sites type-check.
- ``app/utils/api_responses.py``: introduce ``ApiResponse`` alias for
  ``Response | tuple[Response, int] | tuple[str, int]``.
- ``app/utils/budget_forecasting.py``: forecasting helpers return
  ``Optional[Dict]`` (they already returned ``None`` when the project
  was missing).
- ``app/utils/pdf_generator_reportlab.py``: ``_normalize_color`` is
  ``Optional[str]``.
- ``app/utils/pdfa3.py``: remove invalid ``force_version=None`` retry
  call.
- Narrow ``type: ignore`` markers on optional-dependency fallbacks
  (``redis``, ``bleach``, ``markdown``, ``babel``,
  ``powerpoint_export``) and on the documented ``requests.Session``
  / ``RotatingFileHandler`` typeshed limitations.
2026-05-13 10:32:06 +02:00

192 lines
8.5 KiB
Python

import os
from typing import Optional, Tuple
from flask import current_app
from app import db
from app.integrations.peppol import PeppolParty, build_peppol_ubl_invoice_xml, peppol_enabled
from app.integrations.peppol_transport import (
GenericTransport,
NativePeppolTransport,
PeppolTransportError,
PeppolTransportProtocol,
)
from app.models import InvoicePeppolTransmission, Settings
from app.utils.db import safe_commit
class PeppolService:
"""
Business-level Peppol service:
- reads config (env + client custom_fields)
- generates UBL
- sends via access point
- persists send attempts for audit/retry
"""
def _get_sender_party(self) -> PeppolParty:
settings = Settings.get_settings()
sender_endpoint_id = (
getattr(settings, "peppol_sender_endpoint_id", "") or os.getenv("PEPPOL_SENDER_ENDPOINT_ID") or ""
).strip()
sender_scheme_id = (
getattr(settings, "peppol_sender_scheme_id", "") or os.getenv("PEPPOL_SENDER_SCHEME_ID") or ""
).strip()
sender_country = (
getattr(settings, "peppol_sender_country", "") or os.getenv("PEPPOL_SENDER_COUNTRY") or ""
).strip() or None
if not sender_endpoint_id or not sender_scheme_id:
raise ValueError("Missing PEPPOL_SENDER_ENDPOINT_ID / PEPPOL_SENDER_SCHEME_ID")
return PeppolParty(
endpoint_id=sender_endpoint_id,
endpoint_scheme_id=sender_scheme_id,
name=(getattr(settings, "company_name", None) or "Company").strip(),
tax_id=(getattr(settings, "company_tax_id", None) or "").strip() or None,
address_line=(getattr(settings, "company_address", None) or "").strip() or None,
country_code=sender_country,
email=(getattr(settings, "company_email", None) or "").strip() or None,
phone=(getattr(settings, "company_phone", None) or "").strip() or None,
)
def _get_recipient_party(self, invoice) -> Tuple[PeppolParty, str, str]:
client = getattr(invoice, "client", None)
if not client:
raise ValueError("Invoice has no linked client")
# Store on Client.custom_fields to avoid schema changes on Client for now.
endpoint_id = (client.get_custom_field("peppol_endpoint_id", "") or "").strip()
scheme_id = (client.get_custom_field("peppol_scheme_id", "") or "").strip()
country = (client.get_custom_field("peppol_country", "") or "").strip() or None
if not endpoint_id or not scheme_id:
raise ValueError(
"Client is missing Peppol endpoint details (custom_fields.peppol_endpoint_id / peppol_scheme_id)"
)
party = PeppolParty(
endpoint_id=endpoint_id,
endpoint_scheme_id=scheme_id,
name=(getattr(client, "name", None) or getattr(invoice, "client_name", "") or "Customer").strip(),
tax_id=(client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip()
or None,
address_line=(getattr(client, "address", None) or getattr(invoice, "client_address", None) or "").strip()
or None,
country_code=country,
email=(getattr(client, "email", None) or getattr(invoice, "client_email", None) or "").strip() or None,
phone=(getattr(client, "phone", None) or "").strip() or None,
)
return party, endpoint_id, scheme_id
def send_invoice(
self, invoice, triggered_by_user_id: Optional[int] = None
) -> Tuple[bool, Optional[InvoicePeppolTransmission], str]:
if not peppol_enabled():
return False, None, "Peppol is not enabled"
try:
sender = self._get_sender_party()
recipient_party, recipient_endpoint_id, recipient_scheme_id = self._get_recipient_party(invoice)
except Exception as e:
return False, None, str(e)
try:
ubl_xml, sha256_hex = build_peppol_ubl_invoice_xml(
invoice=invoice, supplier=sender, customer=recipient_party
)
except Exception as e:
current_app.logger.exception("Failed to build Peppol UBL XML")
return False, None, f"Failed to build UBL XML: {e}"
tx = InvoicePeppolTransmission(
invoice_id=invoice.id,
provider=(
getattr(Settings.get_settings(), "peppol_provider", "") or os.getenv("PEPPOL_PROVIDER") or "generic"
).strip()
or "generic",
status="pending",
sender_endpoint_id=sender.endpoint_id,
sender_scheme_id=sender.endpoint_scheme_id,
recipient_endpoint_id=recipient_endpoint_id,
recipient_scheme_id=recipient_scheme_id,
document_id=getattr(invoice, "invoice_number", None) or str(invoice.id),
ubl_sha256=sha256_hex,
ubl_xml=ubl_xml,
)
db.session.add(tx)
if not safe_commit("peppol_create_transmission", {"invoice_id": invoice.id}):
return False, None, "Database error while creating Peppol transmission"
try:
settings = Settings.get_settings()
transport_mode = (
(getattr(settings, "peppol_transport_mode", None) or os.getenv("PEPPOL_TRANSPORT_MODE") or "generic")
.strip()
.lower()
)
transport: PeppolTransportProtocol
if transport_mode == "native":
sml_url = (getattr(settings, "peppol_sml_url", "") or os.getenv("PEPPOL_SML_URL") or "").strip() or None
cert_path = (
getattr(settings, "peppol_native_cert_path", "") or os.getenv("PEPPOL_NATIVE_CERT_PATH") or ""
).strip() or None
key_path = (
getattr(settings, "peppol_native_key_path", "") or os.getenv("PEPPOL_NATIVE_KEY_PATH") or ""
).strip() or None
try:
ap_timeout = int(getattr(settings, "peppol_access_point_timeout", 0) or 0) or 60
except Exception:
ap_timeout = 60
transport = NativePeppolTransport(
sml_url=sml_url, timeout_s=float(ap_timeout), cert_path=cert_path, key_path=key_path
)
else:
ap_url = (
getattr(settings, "peppol_access_point_url", "") or os.getenv("PEPPOL_ACCESS_POINT_URL") or ""
).strip()
ap_token_raw = getattr(settings, "peppol_access_point_token", None)
ap_token = (
(settings.get_secret("peppol_access_point_token") or "").strip()
if ap_token_raw is not None
else (os.getenv("PEPPOL_ACCESS_POINT_TOKEN") or "").strip()
)
try:
ap_timeout = int(getattr(settings, "peppol_access_point_timeout", 0) or 0) or 30
except Exception:
ap_timeout = 30
transport = GenericTransport(
access_point_url=ap_url, access_point_token=ap_token or None, timeout_s=float(ap_timeout)
)
resp = transport.send(
ubl_xml=ubl_xml,
recipient_endpoint_id=recipient_endpoint_id,
recipient_scheme_id=recipient_scheme_id,
sender_endpoint_id=sender.endpoint_id,
sender_scheme_id=sender.endpoint_scheme_id,
document_id=tx.document_id,
)
message_id = None
data = (resp or {}).get("data") or {}
if isinstance(data, dict):
message_id = data.get("message_id") or data.get("messageId") or data.get("id")
tx.mark_sent(message_id=message_id, response_payload=resp)
if not safe_commit("peppol_mark_sent", {"invoice_id": invoice.id, "tx_id": tx.id}):
return True, tx, "Sent via Peppol, but failed to persist send status"
return True, tx, "Invoice sent via Peppol"
except PeppolTransportError as e:
tx.mark_failed(str(e))
safe_commit("peppol_mark_failed", {"invoice_id": invoice.id, "tx_id": tx.id})
current_app.logger.exception("Peppol send failed")
return False, tx, f"Peppol send failed: {e}"
except Exception as e:
tx.mark_failed(str(e))
safe_commit("peppol_mark_failed", {"invoice_id": invoice.id, "tx_id": tx.id})
current_app.logger.exception("Peppol send failed")
return False, tx, f"Peppol send failed: {e}"