From 3278664909a102086f29e841552ad0599c78c41f Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:25:07 +0200 Subject: [PATCH 1/6] fix(#181-#183): extract billing_helpers; fix already-billed guard and cancelled-invoice exclusion - New utils/billing_helpers.py: build_service_type_map(), resolve_session_rate(), is_session_already_billed(), create_invoice_item_for_session() - invoice_views: both add_sessions_to_invoice and create_invoice_with_sessions now use the helper, giving create_invoice_with_sessions the already-billed guard it was missing (#181) and consistent therapy_free zero-rating - calendar_views: approval-flow duplicate guard now excludes cancelled invoices (#182); inline rate logic replaced with resolve_session_rate(); Decimal("1.00") and total= field set consistently - 19 unit tests in test_billing_helpers.py Closes #181, #182, #183 Co-Authored-By: Claude Sonnet 4.6 --- app/my_practice/tests/test_billing_helpers.py | 250 ++++++++++++++++++ app/my_practice/utils/__init__.py | 10 + app/my_practice/utils/billing_helpers.py | 97 +++++++ app/my_practice/views/calendar_views.py | 31 +-- app/my_practice/views/invoice_views.py | 75 ++---- 5 files changed, 392 insertions(+), 71 deletions(-) create mode 100644 app/my_practice/tests/test_billing_helpers.py create mode 100644 app/my_practice/utils/billing_helpers.py diff --git a/app/my_practice/tests/test_billing_helpers.py b/app/my_practice/tests/test_billing_helpers.py new file mode 100644 index 0000000..505d2f2 --- /dev/null +++ b/app/my_practice/tests/test_billing_helpers.py @@ -0,0 +1,250 @@ +"""Tests for billing_helpers: session-to-InvoiceItem creation utilities.""" + +from datetime import date +from decimal import Decimal + +from django.test import TestCase + +from ..models import Client, Invoice, InvoiceItem, Practice, ServiceType, Session +from ..utils.billing_helpers import ( + build_service_type_map, + create_invoice_item_for_session, + is_session_already_billed, + resolve_session_rate, +) + + +def _make_practice(): + return Practice.objects.create( + name="Billing Test Practice", + slug="billing-helpers-test", + email="billing@example.com", + ) + + +def _make_service_types(practice): + st60 = ServiceType.objects.create( + code="therapy_60", + name="60-Min Therapy", + default_duration=60, + practice=practice, + ) + st90 = ServiceType.objects.create( + code="therapy_90", + name="90-Min Therapy", + default_duration=90, + practice=practice, + ) + st_free = ServiceType.objects.create( + code="therapy_free", + name="Free Consultation", + default_duration=20, + practice=practice, + ) + return st60, st90, st_free + + +def _make_client(practice): + return Client.objects.create( + client_code="BH", + full_name="Max Mustermann", + practice=practice, + hourly_rate_60=Decimal("100.00"), + hourly_rate_90=Decimal("150.00"), + ) + + +def _make_invoice(client, status=Invoice.Status.DRAFT): + return Invoice.objects.create( + practice=client.practice, + client=client, + invoice_number="BH-1", + invoice_date=date.today(), + status=status, + ) + + +def _make_session(client, duration=60): + return Session.objects.create( + client=client, + session_date=date.today(), + duration=duration, + ) + + +class BuildServiceTypeMapTests(TestCase): + def setUp(self): + self.practice = _make_practice() + self.st60, self.st90, self.st_free = _make_service_types(self.practice) + + def test_maps_duration_to_service_type(self): + m = build_service_type_map(self.practice) + self.assertEqual(m[60], self.st60) + self.assertEqual(m[90], self.st90) + self.assertEqual(m[20], self.st_free) + + def test_deterministic_on_duplicate_duration(self): + # Dict is built in ascending code order; the last entry (higher code) wins. + ServiceType.objects.create( + code="aaa_therapy_60", + name="Earlier code", + default_duration=60, + practice=self.practice, + ) + m = build_service_type_map(self.practice) + # "therapy_60" > "aaa_therapy_60" alphabetically → it wins. + self.assertEqual(m[60].code, "therapy_60") + + def test_includes_global_service_types(self): + global_st = ServiceType.objects.create( + code="zzz_global", + name="Global Type", + default_duration=45, + practice=None, + ) + m = build_service_type_map(self.practice) + self.assertEqual(m[45], global_st) + + +class ResolveSessionRateTests(TestCase): + def setUp(self): + self.practice = _make_practice() + self.st60, self.st90, self.st_free = _make_service_types(self.practice) + self.client = _make_client(self.practice) + + def test_therapy_free_returns_zero(self): + self.assertEqual(resolve_session_rate(self.client, self.st_free), Decimal("0")) + + def test_60min_uses_hourly_rate_60(self): + self.assertEqual(resolve_session_rate(self.client, self.st60), Decimal("100.00")) + + def test_90min_uses_hourly_rate_90(self): + self.assertEqual(resolve_session_rate(self.client, self.st90), Decimal("150.00")) + + def test_90min_falls_back_to_60_when_90_is_zero(self): + # hourly_rate_90 = 0 is falsy → falls back to hourly_rate_60. + self.client.hourly_rate_90 = Decimal("0") + self.client.save() + self.assertEqual(resolve_session_rate(self.client, self.st90), Decimal("100.00")) + + +class IsSessionAlreadyBilledTests(TestCase): + def setUp(self): + self.practice = _make_practice() + self.st60, _, _ = _make_service_types(self.practice) + self.client = _make_client(self.practice) + + def test_unbilled_session_returns_false(self): + session = _make_session(self.client) + self.assertFalse(is_session_already_billed(session)) + + def test_session_on_active_invoice_returns_true(self): + session = _make_session(self.client) + invoice = _make_invoice(self.client) + InvoiceItem.objects.create( + invoice=invoice, + session=session, + service_type=self.st60, + rate=Decimal("100.00"), + quantity=Decimal("1.00"), + total=Decimal("100.00"), + ) + self.assertTrue(is_session_already_billed(session)) + + def test_session_on_cancelled_invoice_returns_false(self): + session = _make_session(self.client) + invoice = _make_invoice(self.client, status=Invoice.Status.CANCELLED) + InvoiceItem.objects.create( + invoice=invoice, + session=session, + service_type=self.st60, + rate=Decimal("100.00"), + quantity=Decimal("1.00"), + total=Decimal("100.00"), + ) + # Cancelled invoice does not count as billed. + self.assertFalse(is_session_already_billed(session)) + + +class CreateInvoiceItemForSessionTests(TestCase): + def setUp(self): + self.practice = _make_practice() + self.st60, self.st90, self.st_free = _make_service_types(self.practice) + self.client = _make_client(self.practice) + self.invoice = _make_invoice(self.client) + self.service_type_map = {60: self.st60, 90: self.st90, 20: self.st_free} + + def test_creates_item_for_unbilled_session(self): + session = _make_session(self.client, duration=60) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNotNone(item) + self.assertEqual(item.rate, Decimal("100.00")) + self.assertEqual(item.service_type, self.st60) + + def test_returns_none_for_already_billed_session(self): + session = _make_session(self.client, duration=60) + InvoiceItem.objects.create( + invoice=self.invoice, + session=session, + service_type=self.st60, + rate=Decimal("100.00"), + quantity=Decimal("1.00"), + total=Decimal("100.00"), + ) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNone(item) + + def test_session_on_cancelled_invoice_can_be_rebilled(self): + session = _make_session(self.client, duration=60) + cancelled_invoice = Invoice.objects.create( + practice=self.client.practice, + client=self.client, + invoice_number="BH-99", + invoice_date=date.today(), + status=Invoice.Status.CANCELLED, + ) + InvoiceItem.objects.create( + invoice=cancelled_invoice, + session=session, + service_type=self.st60, + rate=Decimal("100.00"), + quantity=Decimal("1.00"), + total=Decimal("100.00"), + ) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNotNone(item) + + def test_returns_none_when_no_service_type(self): + session = _make_session(self.client, duration=75) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNone(item) + + def test_uses_fallback_service_type(self): + session = _make_session(self.client, duration=75) + item = create_invoice_item_for_session( + self.invoice, session, self.service_type_map, fallback_service_type=self.st60 + ) + self.assertIsNotNone(item) + self.assertEqual(item.service_type, self.st60) + + def test_therapy_free_creates_zero_rate_item(self): + session = _make_session(self.client, duration=20) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNotNone(item) + self.assertEqual(item.rate, Decimal("0")) + + def test_90min_session_uses_90min_rate(self): + session = _make_session(self.client, duration=90) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertIsNotNone(item) + self.assertEqual(item.rate, Decimal("150.00")) + + def test_item_quantity_is_one(self): + session = _make_session(self.client, duration=60) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertEqual(item.quantity, Decimal("1.00")) + + def test_item_total_equals_rate(self): + session = _make_session(self.client, duration=60) + item = create_invoice_item_for_session(self.invoice, session, self.service_type_map) + self.assertEqual(item.total, item.rate) diff --git a/app/my_practice/utils/__init__.py b/app/my_practice/utils/__init__.py index 437e6d0..f15dfbd 100644 --- a/app/my_practice/utils/__init__.py +++ b/app/my_practice/utils/__init__.py @@ -43,6 +43,12 @@ from .financial_list_context_builder import FinancialListContextBuilder from .import_helpers import build_client_map from .invoice_filter_helper import InvoiceFilterHelper +from .billing_helpers import ( + build_service_type_map, + create_invoice_item_for_session, + is_session_already_billed, + resolve_session_rate, +) from .invoice_helpers import get_next_invoice_number from .practice_days import FahrtkostenResult, PracticeDayCalculator from .practice_helpers import ( @@ -67,6 +73,10 @@ "count_sessions_rounded", "apply_remainder_distribution", "get_next_invoice_number", + "build_service_type_map", + "create_invoice_item_for_session", + "is_session_already_billed", + "resolve_session_rate", "RevenueCalculator", "DateRangeHelper", "format_month_key", diff --git a/app/my_practice/utils/billing_helpers.py b/app/my_practice/utils/billing_helpers.py new file mode 100644 index 0000000..286a1f7 --- /dev/null +++ b/app/my_practice/utils/billing_helpers.py @@ -0,0 +1,97 @@ +""" +Shared helpers for session → InvoiceItem creation. + +Three call sites need consistent service-type resolution, rate computation, and +already-billed detection: + 1. invoice_views.add_sessions_to_invoice + 2. invoice_views.create_invoice_with_sessions + 3. calendar_views (approval flow) + +Keeping this logic in one place means bugs fixed here fix all three. +""" + +from decimal import Decimal + +from django.db.models import Q + +from ..models import Invoice, InvoiceItem, ServiceType + + +def build_service_type_map(practice) -> dict[int, ServiceType]: + """ + Return a {default_duration: ServiceType} dict for a practice. + + Includes global service types (practice=None). When two types share the + same default_duration the one with the higher code (alphabetically) wins — + the dict is built in ascending code order so later entries overwrite earlier + ones, and iteration order is stable. + """ + return { + st.default_duration: st + for st in ServiceType.objects.filter( + Q(practice=practice) | Q(practice__isnull=True) + ).order_by("code") + } + + +def resolve_session_rate(client, service_type) -> Decimal: + """ + Return the billing rate for a session of this service type for the client. + + Returns Decimal("0") for free consultations (service_type.code == "therapy_free"). + Otherwise uses the client's 90-min rate for types with default_duration >= 90, + and the 60-min rate for shorter types. + """ + if service_type.code == "therapy_free": + return Decimal("0") + if service_type.default_duration >= 90: + return Decimal(str(client.hourly_rate_90 or client.hourly_rate_60 or 0)) + return Decimal(str(client.hourly_rate_60 or 0)) + + +def is_session_already_billed(session) -> bool: + """ + Return True if the session is attached to any non-cancelled InvoiceItem. + + Cancelled invoices are excluded so that a re-billed session (after + cancellation) is not refused. + """ + return ( + InvoiceItem.objects.filter(session=session) + .exclude(invoice__status=Invoice.Status.CANCELLED) + .exists() + ) + + +def create_invoice_item_for_session( + invoice, + session, + service_type_map: dict[int, ServiceType], + fallback_service_type: ServiceType | None = None, +) -> InvoiceItem | None: + """ + Create an InvoiceItem for a session on a draft invoice. + + Returns the created InvoiceItem, or None when: + - the session is already billed on a non-cancelled invoice, or + - no service type could be resolved. + + Does NOT call recalculate_invoice_total — the caller is responsible for + that after all items have been created. + """ + if is_session_already_billed(session): + return None + + service_type = service_type_map.get(session.duration, fallback_service_type) + if service_type is None: + return None + + rate = resolve_session_rate(invoice.client, service_type) + return InvoiceItem.objects.create( + invoice=invoice, + session=session, + service_type=service_type, + rate=rate, + quantity=Decimal("1.00"), + total=rate, + ) diff --git a/app/my_practice/views/calendar_views.py b/app/my_practice/views/calendar_views.py index 61fc995..dd9ddd2 100644 --- a/app/my_practice/views/calendar_views.py +++ b/app/my_practice/views/calendar_views.py @@ -5,6 +5,7 @@ import json from datetime import datetime +from decimal import Decimal from django.contrib import messages from django.db.models import Count, Exists, OuterRef @@ -15,6 +16,7 @@ from django.views.decorators.http import require_POST from ..models import Client, Invoice, InvoiceItem, PendingCalendarEvent, ServiceType +from ..utils.billing_helpers import resolve_session_rate from ..utils.calendar_event_processor import ( CalendarImportProcessor, build_user_overrides, @@ -422,7 +424,6 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: For "ignore", marks the event as SKIPPED. Redirects back to the client detail page. """ - from decimal import Decimal from datetime import datetime as dt from django.contrib import messages @@ -479,24 +480,23 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: messages.error(request, "Kein passender Leistungstyp gefunden.") return redirect_target - # Determine rate - if service_type.code == "therapy_free": - rate = Decimal("0") - elif service_type.default_duration >= 90: - rate = Decimal(str(client.hourly_rate_90 or client.hourly_rate_60 or 0)) - else: - rate = Decimal(str(client.hourly_rate_60 or 0)) + rate = resolve_session_rate(client, service_type) if rate == Decimal("0") and service_type.code != "therapy_free": messages.error(request, f"Kein Stundensatz für {client.client_code} hinterlegt.") return redirect_target - # Guard against duplicates - if InvoiceItem.objects.filter( - invoice__client=client, - session__session_date=event.event_date, - service_type=service_type, - ).exists(): + # Guard against duplicates — exclude cancelled invoices so re-import after + # cancellation works correctly. + if ( + InvoiceItem.objects.filter( + invoice__client=client, + session__session_date=event.event_date, + service_type=service_type, + ) + .exclude(invoice__status=Invoice.Status.CANCELLED) + .exists() + ): messages.warning( request, f"Sitzung am {event.event_date.strftime('%d.%m.%Y')} ist bereits abgerechnet." ) @@ -529,8 +529,9 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: InvoiceItem.objects.create( invoice=invoice, service_type=service_type, - quantity=1, + quantity=Decimal("1.00"), rate=rate, + total=rate, session=session, ) event.status = PendingCalendarEvent.Status.IMPORTED diff --git a/app/my_practice/views/invoice_views.py b/app/my_practice/views/invoice_views.py index e3a78e8..a3195ad 100644 --- a/app/my_practice/views/invoice_views.py +++ b/app/my_practice/views/invoice_views.py @@ -4,14 +4,13 @@ import logging from datetime import date -from decimal import Decimal from typing import Any from dateutil.relativedelta import relativedelta from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db import transaction -from django.db.models import Case, Count, DecimalField, Exists, F, OuterRef, Q, QuerySet, Sum, When +from django.db.models import Case, Count, DecimalField, Exists, F, OuterRef, QuerySet, Sum, When from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils.html import format_html @@ -19,9 +18,10 @@ from django.views.generic import DetailView from ..invoice_forms import InvoiceForm -from ..models import Client, Invoice, InvoiceItem, PendingCalendarEvent, ServiceType, Session +from ..models import Client, Invoice, InvoiceItem, PendingCalendarEvent, Session from ..signals import recalculate_invoice_total from ..utils import RevenueCalculator, get_next_invoice_number +from ..utils.billing_helpers import build_service_type_map, create_invoice_item_for_session from ..utils.calendar_preflight import CalendarPreflightChecker from ..utils.gebueh_helpers import build_gebueh_blocks, get_arbeitsdiagnose from ..utils.invoice_filter_helper import InvoiceFilterHelper @@ -461,45 +461,15 @@ def add_sessions_to_invoice(request, pk): cancelled=False, ) - # Pick service type by duration: prefer exact match on default_duration, fall back - # to the first available type for the practice. - service_types = { - st.default_duration: st - for st in ServiceType.objects.filter(Q(practice=practice) | Q(practice__isnull=True)) - } - fallback_service_type = next(iter(service_types.values())) if service_types else None + service_type_map = build_service_type_map(practice) + fallback_service_type = next(iter(service_type_map.values()), None) added = 0 for session in sessions: - # Skip if already on a non-cancelled invoice (race condition guard). - already_billed = ( - InvoiceItem.objects.filter( - session=session, - ) - .exclude(invoice__status=Invoice.Status.CANCELLED) - .exists() - ) - if already_billed: - continue - - service_type = service_types.get(session.duration, fallback_service_type) - if service_type is None: - continue - - rate = ( - invoice.client.hourly_rate_90 - if session.duration >= 90 - else invoice.client.hourly_rate_60 - ) - InvoiceItem.objects.create( - invoice=invoice, - session=session, - service_type=service_type, - rate=rate, - quantity=Decimal("1.00"), - total=rate, - ) - added += 1 + if create_invoice_item_for_session( + invoice, session, service_type_map, fallback_service_type + ): + added += 1 if added: recalculate_invoice_total(invoice) @@ -546,11 +516,8 @@ def create_invoice_with_sessions(request): next_url = request.POST.get("next") return redirect(next_url or "invoice_list") - service_types = { - st.default_duration: st - for st in ServiceType.objects.filter(Q(practice=practice) | Q(practice__isnull=True)) - } - fallback_service_type = next(iter(service_types.values())) if service_types else None + service_type_map = build_service_type_map(practice) + fallback_service_type = next(iter(service_type_map.values()), None) with transaction.atomic(): invoice = Invoice.objects.create( @@ -560,23 +527,19 @@ def create_invoice_with_sessions(request): status=Invoice.Status.DRAFT, invoice_date=date.today(), ) - for session in sessions: - service_type = service_types.get(session.duration, fallback_service_type) - rate = client.hourly_rate_90 if session.duration >= 90 else client.hourly_rate_60 - InvoiceItem.objects.create( - invoice=invoice, - session=session, - service_type=service_type, - rate=rate, - quantity=Decimal("1.00"), - total=rate, + added = sum( + 1 + for session in sessions + if create_invoice_item_for_session( + invoice, session, service_type_map, fallback_service_type ) + ) recalculate_invoice_total(invoice) msg_template = ngettext( 'Invoice {} with {} session created.', 'Invoice {} with {} sessions created.', - len(sessions), + added, ) messages.success( request, @@ -584,7 +547,7 @@ def create_invoice_with_sessions(request): msg_template, reverse("invoice_detail", kwargs={"pk": invoice.pk}), invoice.invoice_number, - len(sessions), + added, ), ) next_url = request.POST.get("next") From 01244a5f50a54a2cb0ace222dc71bda8f0496854 Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:26:01 +0200 Subject: [PATCH 2/6] =?UTF-8?q?refactor(#184):=20bank=5Fimport=5Fviews=20?= =?UTF-8?q?=E2=80=94=20stored=20total,=20shared=20invoice-map=20helper,=20?= =?UTF-8?q?Decimal=20tolerance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace calculate_total() (DB aggregate per call) with stored invoice.total - Extract _fetch_invoice_maps(practice, numbers, statuses) → eliminates the duplicated batch-fetch + split loop in get_queryset and _handle_bulk_ignore_paid - Amount comparison tolerance changed from float 0.01 to Decimal("0.01") Closes #184 Co-Authored-By: Claude Sonnet 4.6 --- app/my_practice/views/bank_import_views.py | 61 +++++++++++++--------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/app/my_practice/views/bank_import_views.py b/app/my_practice/views/bank_import_views.py index 4aba51d..dab017d 100644 --- a/app/my_practice/views/bank_import_views.py +++ b/app/my_practice/views/bank_import_views.py @@ -4,6 +4,8 @@ Handles CSV upload, automatic matching, and manual review of unmatched transactions. """ +from decimal import Decimal + from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -108,23 +110,14 @@ def get_queryset(self): transactions_list = list(transactions) - # Batch-fetch all potentially matching invoices in two queries instead of 2N invoice_numbers = { t.extracted_invoice_number for t in transactions_list if t.extracted_invoice_number } - paid_map: dict[str, Invoice] = {} - sent_map: dict[str, Invoice] = {} - if invoice_numbers: - practice = self.request.current_practice - for inv in Invoice.objects.filter( - practice=practice, - invoice_number__in=invoice_numbers, - status__in=["paid", "sent"], - ): - if inv.status == "paid": - paid_map[inv.invoice_number] = inv - else: - sent_map[inv.invoice_number] = inv + maps = self._fetch_invoice_maps( + self.request.current_practice, invoice_numbers, ["paid", "sent"] + ) + paid_map = maps["paid"] + sent_map = maps["sent"] for trans in transactions_list: trans.matching_paid_invoice = None @@ -132,10 +125,10 @@ def get_queryset(self): num = trans.extracted_invoice_number if num: paid = paid_map.get(num) - if paid and abs(paid.calculate_total() - trans.amount) < 0.01: + if paid and abs(paid.total - trans.amount) < Decimal("0.01"): trans.matching_paid_invoice = paid sent = sent_map.get(num) - if sent and abs(sent.calculate_total() - trans.amount) < 0.01: + if sent and abs(sent.total - trans.amount) < Decimal("0.01"): trans.matching_unpaid_invoice = sent return transactions_list @@ -245,6 +238,28 @@ def post(self, request, *args, **kwargs): # ── helpers ─────────────────────────────────────────────────────────────── + @staticmethod + def _fetch_invoice_maps( + practice, invoice_numbers: set[str], statuses: list[str] + ) -> dict[str, dict[str, Invoice]]: + """ + Batch-fetch invoices by number and split by status. + + Returns {status_string: {invoice_number: Invoice}} for each requested + status. Replaces the duplicated per-status query loops in get_queryset + and _handle_bulk_ignore_paid. + """ + result: dict[str, dict[str, Invoice]] = {s: {} for s in statuses} + if invoice_numbers: + for inv in Invoice.objects.filter( + practice=practice, + invoice_number__in=invoice_numbers, + status__in=statuses, + ): + if inv.status in result: + result[inv.status][inv.invoice_number] = inv + return result + def _next_redirect_url(self, request, exclude_transaction) -> str: """Build review URL anchored to the next unmatched transaction.""" next_transaction = ( @@ -273,25 +288,19 @@ def _handle_bulk_ignore_paid(self, request): ).select_related("matched_invoice") ) - # Batch-fetch paid invoices for all extracted numbers in one query invoice_numbers = { t.extracted_invoice_number for t in transactions if t.extracted_invoice_number } - paid_map: dict[str, Invoice] = {} - if invoice_numbers: - for inv in Invoice.objects.filter( - practice=request.current_practice, - invoice_number__in=invoice_numbers, - status="paid", - ): - paid_map[inv.invoice_number] = inv + paid_map = self._fetch_invoice_maps(request.current_practice, invoice_numbers, ["paid"])[ + "paid" + ] ignored_count = 0 for trans in transactions: if not trans.extracted_invoice_number: continue paid_invoice = paid_map.get(trans.extracted_invoice_number) - if paid_invoice and abs(paid_invoice.calculate_total() - trans.amount) < 0.01: + if paid_invoice and abs(paid_invoice.total - trans.amount) < Decimal("0.01"): trans.match_confidence = "ignored" trans.processed = True trans.notes = ( From 3081858981e6e7e4ead51a608fa2d52df171d3f2 Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:27:10 +0200 Subject: [PATCH 3/6] refactor(#185): extract available_data_years() helper; remove three hand-rolled copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add available_data_years(practice, include_expenses=True) to tax_context_builder.py. Use it in: - TaxYearContextBuilder._build_available_years (was 10 lines, now 1) - tax_workday_audit (Invoice ∪ Expense years, with practice guard) - tax_quarter_overview (Invoice-only years, include_expenses=False) Closes #185 Co-Authored-By: Claude Sonnet 4.6 --- app/my_practice/utils/__init__.py | 3 +- app/my_practice/utils/tax_context_builder.py | 32 +++++++++++--------- app/my_practice/views/tax_views.py | 24 ++------------- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/app/my_practice/utils/__init__.py b/app/my_practice/utils/__init__.py index f15dfbd..e8e05ed 100644 --- a/app/my_practice/utils/__init__.py +++ b/app/my_practice/utils/__init__.py @@ -7,7 +7,7 @@ from .action_queue_builder import ActionQueueBuilder from .client_detail_builder import ClientDetailContextBuilder from .dashboard_context_builder import DashboardContextAssembler -from .tax_context_builder import TaxYearContextBuilder +from .tax_context_builder import TaxYearContextBuilder, available_data_years from .bank_import import BankStatementImporter from .calculations import ( apply_remainder_distribution, @@ -100,6 +100,7 @@ "ClientDetailContextBuilder", "DashboardContextAssembler", "TaxYearContextBuilder", + "available_data_years", "FinancialListContextBuilder", "InvoiceFilterHelper", "AgendaWidgetBuilder", diff --git a/app/my_practice/utils/tax_context_builder.py b/app/my_practice/utils/tax_context_builder.py index 337a3d5..3caea3a 100644 --- a/app/my_practice/utils/tax_context_builder.py +++ b/app/my_practice/utils/tax_context_builder.py @@ -13,6 +13,23 @@ from .revenue_helpers import RevenueCalculator +def available_data_years(practice, include_expenses: bool = True) -> list[int]: + """ + Return a descending list of years that have Invoice data for the practice. + + If include_expenses is True (the default), years from CompanyExpense records + are merged in too — useful for tax views that cover both income and expenses. + """ + years = { + d.year for d in Invoice.objects.filter(practice=practice).dates("invoice_date", "year") + } + if include_expenses: + years |= { + d.year for d in CompanyExpense.objects.filter(practice=practice).dates("date", "year") + } + return sorted(years, reverse=True) + + @dataclass class PracticeSplitCalc: """Revenue and session-day split ratios for this practice in a multi-practice context.""" @@ -148,20 +165,7 @@ def _build_deductions(self) -> dict: } def _build_available_years(self) -> dict: - years = sorted( - { - d.year - for d in Invoice.objects.filter(practice=self.practice).dates( - "invoice_date", "year" - ) - } - | { - d.year - for d in CompanyExpense.objects.filter(practice=self.practice).dates("date", "year") - }, - reverse=True, - ) - return {"available_years": years} + return {"available_years": available_data_years(self.practice)} def _build_split_context(self) -> dict: ps = self._practice_split diff --git a/app/my_practice/views/tax_views.py b/app/my_practice/views/tax_views.py index ea9620b..48c68d8 100644 --- a/app/my_practice/views/tax_views.py +++ b/app/my_practice/views/tax_views.py @@ -15,6 +15,7 @@ from ..models import CompanyExpense, CompanyWithdrawal, Invoice, TaxYearNote from ..utils import DateRangeHelper, RevenueCalculator, TaxYearContextBuilder +from ..utils.tax_context_builder import available_data_years from ..utils.practice_days import WorkdayAuditCalculator from ..utils.view_helpers import get_year_from_request @@ -73,21 +74,7 @@ def tax_workday_audit(request: HttpRequest) -> HttpResponse: audit = WorkdayAuditCalculator(practice, year).calculate() if practice else None - available_years = ( - sorted( - { - d.year - for d in Invoice.objects.filter(practice=practice).dates("invoice_date", "year") - } - | { - d.year - for d in CompanyExpense.objects.filter(practice=practice).dates("date", "year") - }, - reverse=True, - ) - if practice - else [] - ) + available_years = available_data_years(practice) if practice else [] return render( request, @@ -163,12 +150,7 @@ def tax_quarter_overview(request: HttpRequest) -> HttpResponse: total_expenses: Decimal = sum((cast(Decimal, q["expenses"]) for q in quarters), Decimal("0")) total_tax_paid: Decimal = sum((cast(Decimal, q["tax_paid"]) for q in quarters), Decimal("0")) - available_years = sorted( - {d.year for d in Invoice.objects.filter(practice=practice).dates("invoice_date", "year")}, - reverse=True, - ) - if not available_years: - available_years = [today.year] + available_years = available_data_years(practice, include_expenses=False) or [today.year] return render( request, From 880d18bc7908d1225132d5782ec921a6160a9c2f Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:27:35 +0200 Subject: [PATCH 4/6] cleanup(#186): delete dead client_detail_sidebar and client_detail_tabs templates Both templates have been unreferenced since the P-094 cockpit migration. client_detail_tabs.html was fully i18n-wrapped (v0.2.4), so its ~30 msgids inflated the P-039 translation surface. Ran ./dev.py i18n to drop the stale references from both .po files. Closes #186 Co-Authored-By: Claude Sonnet 4.6 --- app/locale/de/LC_MESSAGES/django.po | 92 +---- app/locale/en/LC_MESSAGES/django.po | 92 +---- .../my_practice/client_detail_sidebar.html | 224 ------------ .../my_practice/client_detail_tabs.html | 334 ------------------ 4 files changed, 34 insertions(+), 708 deletions(-) delete mode 100644 app/templates/my_practice/client_detail_sidebar.html delete mode 100644 app/templates/my_practice/client_detail_tabs.html diff --git a/app/locale/de/LC_MESSAGES/django.po b/app/locale/de/LC_MESSAGES/django.po index 2f5da67..e38aa8a 100644 --- a/app/locale/de/LC_MESSAGES/django.po +++ b/app/locale/de/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: my-practice\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 09:24+0200\n" +"POT-Creation-Date: 2026-07-06 13:27+0200\n" "PO-Revision-Date: 2026-06-22 23:01+0200\n" "Language-Team: German\n" "Language: de\n" @@ -149,7 +149,6 @@ msgstr "Abschluss" #: my_practice/utils/dashboard_widgets.py:29 #: templates/includes/client_card.html:11 #: templates/my_practice/client_detail.html:276 -#: templates/my_practice/client_detail_tabs.html:186 msgid "Log missing" msgstr "Protokoll fehlt" @@ -213,7 +212,7 @@ msgstr[0] "Rechnung versandbereit" msgstr[1] "Rechnungen versandbereit" #: my_practice/utils/dashboard_widgets.py:375 -#: my_practice/views/invoice_views.py:783 +#: my_practice/views/invoice_views.py:746 #: templates/my_practice/dashboard.html:136 #: templates/my_practice/invoice_detail.html:40 msgid "Draft" @@ -583,58 +582,58 @@ msgid "Invoice %(num)s for %(code)s was deleted successfully." msgstr "Rechnung %(num)s für %(code)s wurde erfolgreich gelöscht." #: my_practice/views/invoice_views.py:455 -#: my_practice/views/invoice_views.py:545 +#: my_practice/views/invoice_views.py:515 msgid "No sessions specified." msgstr "Keine Sitzungen angegeben." -#: my_practice/views/invoice_views.py:509 +#: my_practice/views/invoice_views.py:479 #, python-format msgid "%(count)s session added to %(invoice)s." msgid_plural "%(count)s sessions added to %(invoice)s." msgstr[0] "%(count)s Sitzung zu %(invoice)s hinzugefügt." msgstr[1] "%(count)s Sitzungen zu %(invoice)s hinzugefügt." -#: my_practice/views/invoice_views.py:516 +#: my_practice/views/invoice_views.py:486 msgid "No new sessions added (already billed?)." msgstr "Keine neuen Sitzungen hinzugefügt (bereits abgerechnet?)." -#: my_practice/views/invoice_views.py:530 -#: my_practice/views/invoice_views.py:803 -#: my_practice/views/invoice_views.py:917 +#: my_practice/views/invoice_views.py:500 +#: my_practice/views/invoice_views.py:766 +#: my_practice/views/invoice_views.py:880 msgid "No active practice found." msgstr "Keine aktive Praxis gefunden." -#: my_practice/views/invoice_views.py:577 +#: my_practice/views/invoice_views.py:540 msgid "Invoice {} with {} session created." msgid_plural "Invoice {} with {} sessions created." msgstr[0] "Rechnung {} mit {} Sitzung erstellt." msgstr[1] "Rechnung {} mit {} Sitzungen erstellt." -#: my_practice/views/invoice_views.py:777 +#: my_practice/views/invoice_views.py:740 msgid "Cancelled session billed" msgstr "Stornierte Sitzung abgerechnet" -#: my_practice/views/invoice_views.py:779 +#: my_practice/views/invoice_views.py:742 msgid "Appointments pending" msgstr "Termine ausstehend" -#: my_practice/views/invoice_views.py:781 +#: my_practice/views/invoice_views.py:744 msgid "Not billed" msgstr "Nicht abgerechnet" -#: my_practice/views/invoice_views.py:785 +#: my_practice/views/invoice_views.py:748 #: templates/my_practice/dashboard.html:141 #: templates/my_practice/invoice_detail.html:41 msgid "Sent" msgstr "Versendet" -#: my_practice/views/invoice_views.py:787 +#: my_practice/views/invoice_views.py:750 #: templates/my_practice/dashboard.html:146 #: templates/my_practice/invoice_detail.html:42 msgid "Paid" msgstr "Bezahlt" -#: my_practice/views/invoice_views.py:788 +#: my_practice/views/invoice_views.py:751 msgid "OK" msgstr "OK" @@ -674,11 +673,11 @@ msgstr "" msgid "Praxis '%(name)s' deaktiviert." msgstr "" -#: my_practice/views/tax_views.py:44 +#: my_practice/views/tax_views.py:45 msgid "No practice selected" msgstr "Keine Praxis ausgewählt" -#: my_practice/views/tax_views.py:49 my_practice/views/tax_views.py:52 +#: my_practice/views/tax_views.py:50 my_practice/views/tax_views.py:53 msgid "Invalid year" msgstr "Ungültiges Jahr" @@ -1287,12 +1286,10 @@ msgid "Overview" msgstr "Überblick" #: templates/my_practice/client_detail.html:46 -#: templates/my_practice/client_detail_tabs.html:7 msgid "Protocol" msgstr "Protokoll" #: templates/my_practice/client_detail.html:49 -#: templates/my_practice/client_detail_tabs.html:6 msgid "Profile" msgstr "Profil" @@ -1306,7 +1303,6 @@ msgstr "Dokumente" #: templates/my_practice/client_detail.html:62 #: templates/my_practice/client_detail.html:435 -#: templates/my_practice/client_detail_tabs.html:30 msgid "Working diagnosis" msgstr "Arbeitsdiagnose" @@ -1349,7 +1345,6 @@ msgstr "Noch keine Sitzungen mit Protokoll." #: templates/my_practice/client_detail.html:125 #: templates/my_practice/client_detail.html:451 -#: templates/my_practice/client_detail_tabs.html:46 msgid "Case notes" msgstr "Fallnotizen" @@ -1364,92 +1359,73 @@ msgstr "Details im Profil-Tab →" #: templates/my_practice/client_detail.html:153 #: templates/my_practice/client_detail.html:172 -#: templates/my_practice/client_detail_tabs.html:66 msgid "+ Session log" msgstr "+ Sitzungsprotokoll" #: templates/my_practice/client_detail.html:156 #: templates/my_practice/client_detail.html:173 -#: templates/my_practice/client_detail_tabs.html:70 msgid "+ Note" msgstr "+ Notiz" #: templates/my_practice/client_detail.html:160 #: templates/my_practice/client_detail.html:174 -#: templates/my_practice/client_detail_tabs.html:74 msgid "+ Supervision" msgstr "+ Supervision" #: templates/my_practice/client_detail.html:182 #: templates/my_practice/client_detail.html:194 #: templates/my_practice/client_detail.html:344 -#: templates/my_practice/client_detail_tabs.html:84 -#: templates/my_practice/client_detail_tabs.html:98 -#: templates/my_practice/client_detail_tabs.html:267 #: templates/my_practice/gebueh_leistung_form.html:41 msgid "Save" msgstr "Speichern" #: templates/my_practice/client_detail.html:184 -#: templates/my_practice/client_detail_tabs.html:87 msgid "Note (Markdown supported)…" msgstr "Notiz (Markdown unterstützt)…" #: templates/my_practice/client_detail.html:196 -#: templates/my_practice/client_detail_tabs.html:101 msgid "Supervision feedback and recommendations (Markdown)…" msgstr "Supervisionsfeedback und Empfehlungen (Markdown)…" #: templates/my_practice/client_detail.html:201 -#: templates/my_practice/client_detail_tabs.html:106 msgid "▴ Collapse" msgstr "▴ Einklappen" #: templates/my_practice/client_detail.html:202 -#: templates/my_practice/client_detail_tabs.html:107 msgid "▾ Show older entries" msgstr "▾ Ältere Einträge anzeigen" #: templates/my_practice/client_detail.html:203 -#: templates/my_practice/client_detail_tabs.html:108 msgid "more" msgstr "mehr" #: templates/my_practice/client_detail.html:217 -#: templates/my_practice/client_detail_tabs.html:122 msgid "🧾 To invoice" msgstr "🧾 Zur Rechnung" #: templates/my_practice/client_detail.html:218 -#: templates/my_practice/client_detail_tabs.html:123 msgid "🚷 Non-billable" msgstr "🚷 Nicht abrechenbar" #: templates/my_practice/client_detail.html:219 -#: templates/my_practice/client_detail_tabs.html:124 msgid "✅ Billable" msgstr "✅ Abrechenbar" #: templates/my_practice/client_detail.html:220 -#: templates/my_practice/client_detail_tabs.html:125 msgid "🚫 No-show" msgstr "🚫 Ausfall" #: templates/my_practice/client_detail.html:221 -#: templates/my_practice/client_detail_tabs.html:126 msgid "Delete session log?" msgstr "Sitzungsprotokoll löschen?" #: templates/my_practice/client_detail.html:222 -#: templates/my_practice/client_detail_tabs.html:127 msgid "Delete session?" msgstr "Sitzung löschen?" #: templates/my_practice/client_detail.html:228 #: templates/my_practice/client_detail.html:274 #: templates/my_practice/client_detail.html:633 -#: templates/my_practice/client_detail_tabs.html:134 -#: templates/my_practice/client_detail_tabs.html:184 #: templates/my_practice/gebueh_leistung_form.html:6 #: templates/my_practice/invoice_detail.html:110 #: templates/my_practice/invoice_detail.html:158 @@ -1458,20 +1434,15 @@ msgstr "Min" #: templates/my_practice/client_detail.html:229 #: templates/my_practice/client_detail.html:275 -#: templates/my_practice/client_detail_tabs.html:135 -#: templates/my_practice/client_detail_tabs.html:185 msgid "Unbilled" msgstr "Unberechnet" #: templates/my_practice/client_detail.html:229 #: templates/my_practice/client_detail.html:275 -#: templates/my_practice/client_detail_tabs.html:135 -#: templates/my_practice/client_detail_tabs.html:185 msgid "Non-billable" msgstr "Nicht abrechenbar" #: templates/my_practice/client_detail.html:240 -#: templates/my_practice/client_detail_tabs.html:152 #: templates/my_practice/session_log_form.html:76 msgid "Own reflection" msgstr "Eigene Reflexion" @@ -1483,8 +1454,6 @@ msgstr "Ideen für nächste Sitzung" #: templates/my_practice/client_detail.html:253 #: templates/my_practice/client_detail.html:282 -#: templates/my_practice/client_detail_tabs.html:159 -#: templates/my_practice/client_detail_tabs.html:199 #: templates/my_practice/inquiry_list.html:26 #: templates/my_practice/inquiry_list.html:203 #: templates/my_practice/inquiry_list.html:249 @@ -1496,41 +1465,31 @@ msgstr "Bearbeiten" #: templates/my_practice/client_detail.html:255 #: templates/my_practice/client_detail.html:284 #: templates/my_practice/client_detail.html:294 -#: templates/my_practice/client_detail_tabs.html:161 -#: templates/my_practice/client_detail_tabs.html:201 -#: templates/my_practice/client_detail_tabs.html:211 msgid "GebüH" msgstr "GebüH" #: templates/my_practice/client_detail.html:303 -#: templates/my_practice/client_detail_tabs.html:223 msgid "+ Log" msgstr "+ Protokoll" #: templates/my_practice/client_detail.html:306 -#: templates/my_practice/client_detail_tabs.html:226 msgid "Duration in minutes" msgstr "Dauer in Minuten" #: templates/my_practice/client_detail.html:307 -#: templates/my_practice/client_detail_tabs.html:227 msgid "Save min." msgstr "Min. speichern" #: templates/my_practice/client_detail.html:322 -#: templates/my_practice/client_detail_tabs.html:242 msgid "Delete supervision note?" msgstr "Supervisionsnotiz löschen?" #: templates/my_practice/client_detail.html:326 #: templates/my_practice/client_detail.html:376 -#: templates/my_practice/client_detail_tabs.html:10 -#: templates/my_practice/client_detail_tabs.html:246 msgid "Supervision" msgstr "Supervision" #: templates/my_practice/client_detail.html:345 -#: templates/my_practice/client_detail_tabs.html:269 #: templates/my_practice/client_intake.html:123 #: templates/my_practice/gebueh_leistung_form.html:42 #: templates/my_practice/inquiry_confirm_delete.html:39 @@ -1542,43 +1501,35 @@ msgid "Cancel" msgstr "Abbrechen" #: templates/my_practice/client_detail.html:351 -#: templates/my_practice/client_detail_tabs.html:275 msgid "Delete note?" msgstr "Notiz löschen?" #: templates/my_practice/client_detail.html:371 -#: templates/my_practice/client_detail_tabs.html:296 msgid "No entries yet. Session logs are created when adding new entries." msgstr "Noch keine Einträge. Sitzungsprotokolle entstehen beim Erstellen neuer Einträge." #: templates/my_practice/client_detail.html:380 -#: templates/my_practice/client_detail_tabs.html:305 msgid "Supervision topic or question…" msgstr "Supervisionsthema oder -frage…" #: templates/my_practice/client_detail.html:381 -#: templates/my_practice/client_detail_tabs.html:306 #: templates/my_practice/inquiry_list.html:30 msgid "Add" msgstr "Hinzufügen" #: templates/my_practice/client_detail.html:386 -#: templates/my_practice/client_detail_tabs.html:312 msgid "Delete supervision topic?" msgstr "Supervisionsthema wirklich löschen?" #: templates/my_practice/client_detail.html:387 -#: templates/my_practice/client_detail_tabs.html:313 msgid "↩ Reopen" msgstr "↩ Öffnen" #: templates/my_practice/client_detail.html:387 -#: templates/my_practice/client_detail_tabs.html:313 msgid "✓ Discussed" msgstr "✓ Besprochen" #: templates/my_practice/client_detail.html:404 -#: templates/my_practice/client_detail_tabs.html:330 msgid "No supervision topics for this client yet." msgstr "Noch keine Supervisionsthemen für diese Klient:in." @@ -1588,44 +1539,35 @@ msgid "Tags" msgstr "Tags" #: templates/my_practice/client_detail.html:423 -#: templates/my_practice/client_detail_tabs.html:20 msgid "Diagnosis not yet set — the probationary phase appears to be complete. Please update the diagnosis." msgstr "Diagnose noch nicht gesetzt — die probatorische Phase scheint abgeschlossen. Bitte Diagnose aktualisieren." #: templates/my_practice/client_detail.html:425 -#: templates/my_practice/client_detail_tabs.html:22 msgid "Diagnosis not yet set — invoices currently show \"Probationary phase\". Please update once the probationary phase is complete." msgstr "Diagnose noch nicht gesetzt — aktuell wird \"Probatorik\" auf der Rechnung verwendet. Nach Abschluss der probatorischen Phase bitte Diagnose aktualisieren." #: templates/my_practice/client_detail.html:427 -#: templates/my_practice/client_detail_tabs.html:24 msgid "Edit →" msgstr "Bearbeiten →" #: templates/my_practice/client_detail.html:440 -#: templates/my_practice/client_detail_tabs.html:35 msgid "Intake & Anamnesis" msgstr "Aufnahme & Anamnese" #: templates/my_practice/client_detail.html:442 -#: templates/my_practice/client_detail_tabs.html:37 msgid "Clinical initial assessment…" msgstr "Klinische Erstbewertung…" #: templates/my_practice/client_detail.html:445 #: templates/my_practice/client_detail.html:456 -#: templates/my_practice/client_detail_tabs.html:40 -#: templates/my_practice/client_detail_tabs.html:51 msgid "Preview" msgstr "Vorschau" #: templates/my_practice/client_detail.html:453 -#: templates/my_practice/client_detail_tabs.html:48 msgid "Topics, dynamics, challenges…" msgstr "Themen, Dynamik, Herausforderungen…" #: templates/my_practice/client_detail.html:462 -#: templates/my_practice/client_detail_tabs.html:57 msgid "Save profile" msgstr "Profil speichern" diff --git a/app/locale/en/LC_MESSAGES/django.po b/app/locale/en/LC_MESSAGES/django.po index fe6f01d..e7c39ca 100644 --- a/app/locale/en/LC_MESSAGES/django.po +++ b/app/locale/en/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: my-practice\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 09:13+0200\n" +"POT-Creation-Date: 2026-07-06 13:27+0200\n" "PO-Revision-Date: 2026-06-22 23:01+0200\n" "Language-Team: English\n" "Language: en\n" @@ -149,7 +149,6 @@ msgstr "" #: my_practice/utils/dashboard_widgets.py:29 #: templates/includes/client_card.html:11 #: templates/my_practice/client_detail.html:276 -#: templates/my_practice/client_detail_tabs.html:186 msgid "Log missing" msgstr "Log missing" @@ -218,7 +217,7 @@ msgstr[0] "🧾 To invoice" msgstr[1] "🧾 To invoice" #: my_practice/utils/dashboard_widgets.py:375 -#: my_practice/views/invoice_views.py:783 +#: my_practice/views/invoice_views.py:746 #: templates/my_practice/dashboard.html:136 #: templates/my_practice/invoice_detail.html:40 msgid "Draft" @@ -600,66 +599,66 @@ msgid "Invoice %(num)s for %(code)s was deleted successfully." msgstr "" #: my_practice/views/invoice_views.py:455 -#: my_practice/views/invoice_views.py:545 +#: my_practice/views/invoice_views.py:515 #, fuzzy #| msgid "+ Session log" msgid "No sessions specified." msgstr "+ Session log" -#: my_practice/views/invoice_views.py:509 +#: my_practice/views/invoice_views.py:479 #, python-format msgid "%(count)s session added to %(invoice)s." msgid_plural "%(count)s sessions added to %(invoice)s." msgstr[0] "" msgstr[1] "" -#: my_practice/views/invoice_views.py:516 +#: my_practice/views/invoice_views.py:486 msgid "No new sessions added (already billed?)." msgstr "" -#: my_practice/views/invoice_views.py:530 -#: my_practice/views/invoice_views.py:803 -#: my_practice/views/invoice_views.py:917 +#: my_practice/views/invoice_views.py:500 +#: my_practice/views/invoice_views.py:766 +#: my_practice/views/invoice_views.py:880 #, fuzzy #| msgid "Create new practice" msgid "No active practice found." msgstr "Create new practice" -#: my_practice/views/invoice_views.py:577 +#: my_practice/views/invoice_views.py:540 msgid "Invoice {} with {} session created." msgid_plural "Invoice {} with {} sessions created." msgstr[0] "" msgstr[1] "" -#: my_practice/views/invoice_views.py:777 +#: my_practice/views/invoice_views.py:740 #, fuzzy #| msgid "Delete session?" msgid "Cancelled session billed" msgstr "Delete session?" -#: my_practice/views/invoice_views.py:779 +#: my_practice/views/invoice_views.py:742 msgid "Appointments pending" msgstr "" -#: my_practice/views/invoice_views.py:781 +#: my_practice/views/invoice_views.py:744 #, fuzzy #| msgid "Unbilled" msgid "Not billed" msgstr "Unbilled" -#: my_practice/views/invoice_views.py:785 +#: my_practice/views/invoice_views.py:748 #: templates/my_practice/dashboard.html:141 #: templates/my_practice/invoice_detail.html:41 msgid "Sent" msgstr "" -#: my_practice/views/invoice_views.py:787 +#: my_practice/views/invoice_views.py:750 #: templates/my_practice/dashboard.html:146 #: templates/my_practice/invoice_detail.html:42 msgid "Paid" msgstr "" -#: my_practice/views/invoice_views.py:788 +#: my_practice/views/invoice_views.py:751 msgid "OK" msgstr "" @@ -699,11 +698,11 @@ msgstr "" msgid "Praxis '%(name)s' deaktiviert." msgstr "" -#: my_practice/views/tax_views.py:44 +#: my_practice/views/tax_views.py:45 msgid "No practice selected" msgstr "" -#: my_practice/views/tax_views.py:49 my_practice/views/tax_views.py:52 +#: my_practice/views/tax_views.py:50 my_practice/views/tax_views.py:53 msgid "Invalid year" msgstr "" @@ -1354,12 +1353,10 @@ msgid "Overview" msgstr "Preview" #: templates/my_practice/client_detail.html:46 -#: templates/my_practice/client_detail_tabs.html:7 msgid "Protocol" msgstr "Protocol" #: templates/my_practice/client_detail.html:49 -#: templates/my_practice/client_detail_tabs.html:6 msgid "Profile" msgstr "Profile" @@ -1373,7 +1370,6 @@ msgstr "" #: templates/my_practice/client_detail.html:62 #: templates/my_practice/client_detail.html:435 -#: templates/my_practice/client_detail_tabs.html:30 msgid "Working diagnosis" msgstr "Working diagnosis" @@ -1422,7 +1418,6 @@ msgstr "" #: templates/my_practice/client_detail.html:125 #: templates/my_practice/client_detail.html:451 -#: templates/my_practice/client_detail_tabs.html:46 msgid "Case notes" msgstr "Case notes" @@ -1439,92 +1434,73 @@ msgstr "" #: templates/my_practice/client_detail.html:153 #: templates/my_practice/client_detail.html:172 -#: templates/my_practice/client_detail_tabs.html:66 msgid "+ Session log" msgstr "+ Session log" #: templates/my_practice/client_detail.html:156 #: templates/my_practice/client_detail.html:173 -#: templates/my_practice/client_detail_tabs.html:70 msgid "+ Note" msgstr "+ Note" #: templates/my_practice/client_detail.html:160 #: templates/my_practice/client_detail.html:174 -#: templates/my_practice/client_detail_tabs.html:74 msgid "+ Supervision" msgstr "+ Supervision" #: templates/my_practice/client_detail.html:182 #: templates/my_practice/client_detail.html:194 #: templates/my_practice/client_detail.html:344 -#: templates/my_practice/client_detail_tabs.html:84 -#: templates/my_practice/client_detail_tabs.html:98 -#: templates/my_practice/client_detail_tabs.html:267 #: templates/my_practice/gebueh_leistung_form.html:41 msgid "Save" msgstr "Save" #: templates/my_practice/client_detail.html:184 -#: templates/my_practice/client_detail_tabs.html:87 msgid "Note (Markdown supported)…" msgstr "Note (Markdown supported)…" #: templates/my_practice/client_detail.html:196 -#: templates/my_practice/client_detail_tabs.html:101 msgid "Supervision feedback and recommendations (Markdown)…" msgstr "Supervision feedback and recommendations (Markdown)…" #: templates/my_practice/client_detail.html:201 -#: templates/my_practice/client_detail_tabs.html:106 msgid "▴ Collapse" msgstr "▴ Collapse" #: templates/my_practice/client_detail.html:202 -#: templates/my_practice/client_detail_tabs.html:107 msgid "▾ Show older entries" msgstr "▾ Show older entries" #: templates/my_practice/client_detail.html:203 -#: templates/my_practice/client_detail_tabs.html:108 msgid "more" msgstr "more" #: templates/my_practice/client_detail.html:217 -#: templates/my_practice/client_detail_tabs.html:122 msgid "🧾 To invoice" msgstr "🧾 To invoice" #: templates/my_practice/client_detail.html:218 -#: templates/my_practice/client_detail_tabs.html:123 msgid "🚷 Non-billable" msgstr "🚷 Non-billable" #: templates/my_practice/client_detail.html:219 -#: templates/my_practice/client_detail_tabs.html:124 msgid "✅ Billable" msgstr "✅ Billable" #: templates/my_practice/client_detail.html:220 -#: templates/my_practice/client_detail_tabs.html:125 msgid "🚫 No-show" msgstr "🚫 No-show" #: templates/my_practice/client_detail.html:221 -#: templates/my_practice/client_detail_tabs.html:126 msgid "Delete session log?" msgstr "Delete session log?" #: templates/my_practice/client_detail.html:222 -#: templates/my_practice/client_detail_tabs.html:127 msgid "Delete session?" msgstr "Delete session?" #: templates/my_practice/client_detail.html:228 #: templates/my_practice/client_detail.html:274 #: templates/my_practice/client_detail.html:633 -#: templates/my_practice/client_detail_tabs.html:134 -#: templates/my_practice/client_detail_tabs.html:184 #: templates/my_practice/gebueh_leistung_form.html:6 #: templates/my_practice/invoice_detail.html:110 #: templates/my_practice/invoice_detail.html:158 @@ -1533,20 +1509,15 @@ msgstr "min" #: templates/my_practice/client_detail.html:229 #: templates/my_practice/client_detail.html:275 -#: templates/my_practice/client_detail_tabs.html:135 -#: templates/my_practice/client_detail_tabs.html:185 msgid "Unbilled" msgstr "Unbilled" #: templates/my_practice/client_detail.html:229 #: templates/my_practice/client_detail.html:275 -#: templates/my_practice/client_detail_tabs.html:135 -#: templates/my_practice/client_detail_tabs.html:185 msgid "Non-billable" msgstr "Non-billable" #: templates/my_practice/client_detail.html:240 -#: templates/my_practice/client_detail_tabs.html:152 #: templates/my_practice/session_log_form.html:76 msgid "Own reflection" msgstr "Own reflection" @@ -1560,8 +1531,6 @@ msgstr "Delete session?" #: templates/my_practice/client_detail.html:253 #: templates/my_practice/client_detail.html:282 -#: templates/my_practice/client_detail_tabs.html:159 -#: templates/my_practice/client_detail_tabs.html:199 #: templates/my_practice/inquiry_list.html:26 #: templates/my_practice/inquiry_list.html:203 #: templates/my_practice/inquiry_list.html:249 @@ -1573,41 +1542,31 @@ msgstr "Edit" #: templates/my_practice/client_detail.html:255 #: templates/my_practice/client_detail.html:284 #: templates/my_practice/client_detail.html:294 -#: templates/my_practice/client_detail_tabs.html:161 -#: templates/my_practice/client_detail_tabs.html:201 -#: templates/my_practice/client_detail_tabs.html:211 msgid "GebüH" msgstr "" #: templates/my_practice/client_detail.html:303 -#: templates/my_practice/client_detail_tabs.html:223 msgid "+ Log" msgstr "+ Log" #: templates/my_practice/client_detail.html:306 -#: templates/my_practice/client_detail_tabs.html:226 msgid "Duration in minutes" msgstr "Duration in minutes" #: templates/my_practice/client_detail.html:307 -#: templates/my_practice/client_detail_tabs.html:227 msgid "Save min." msgstr "Save min." #: templates/my_practice/client_detail.html:322 -#: templates/my_practice/client_detail_tabs.html:242 msgid "Delete supervision note?" msgstr "Delete supervision note?" #: templates/my_practice/client_detail.html:326 #: templates/my_practice/client_detail.html:376 -#: templates/my_practice/client_detail_tabs.html:10 -#: templates/my_practice/client_detail_tabs.html:246 msgid "Supervision" msgstr "Supervision" #: templates/my_practice/client_detail.html:345 -#: templates/my_practice/client_detail_tabs.html:269 #: templates/my_practice/client_intake.html:123 #: templates/my_practice/gebueh_leistung_form.html:42 #: templates/my_practice/inquiry_confirm_delete.html:39 @@ -1619,43 +1578,35 @@ msgid "Cancel" msgstr "Cancel" #: templates/my_practice/client_detail.html:351 -#: templates/my_practice/client_detail_tabs.html:275 msgid "Delete note?" msgstr "Delete note?" #: templates/my_practice/client_detail.html:371 -#: templates/my_practice/client_detail_tabs.html:296 msgid "No entries yet. Session logs are created when adding new entries." msgstr "No entries yet. Session logs are created when adding new entries." #: templates/my_practice/client_detail.html:380 -#: templates/my_practice/client_detail_tabs.html:305 msgid "Supervision topic or question…" msgstr "Supervision topic or question…" #: templates/my_practice/client_detail.html:381 -#: templates/my_practice/client_detail_tabs.html:306 #: templates/my_practice/inquiry_list.html:30 msgid "Add" msgstr "Add" #: templates/my_practice/client_detail.html:386 -#: templates/my_practice/client_detail_tabs.html:312 msgid "Delete supervision topic?" msgstr "Delete supervision topic?" #: templates/my_practice/client_detail.html:387 -#: templates/my_practice/client_detail_tabs.html:313 msgid "↩ Reopen" msgstr "↩ Reopen" #: templates/my_practice/client_detail.html:387 -#: templates/my_practice/client_detail_tabs.html:313 msgid "✓ Discussed" msgstr "✓ Discussed" #: templates/my_practice/client_detail.html:404 -#: templates/my_practice/client_detail_tabs.html:330 msgid "No supervision topics for this client yet." msgstr "No supervision topics for this client yet." @@ -1665,46 +1616,37 @@ msgid "Tags" msgstr "" #: templates/my_practice/client_detail.html:423 -#: templates/my_practice/client_detail_tabs.html:20 msgid "Diagnosis not yet set — the probationary phase appears to be complete. Please update the diagnosis." msgstr "" #: templates/my_practice/client_detail.html:425 -#: templates/my_practice/client_detail_tabs.html:22 msgid "Diagnosis not yet set — invoices currently show \"Probationary phase\". Please update once the probationary phase is complete." msgstr "" #: templates/my_practice/client_detail.html:427 -#: templates/my_practice/client_detail_tabs.html:24 #, fuzzy #| msgid "Edit" msgid "Edit →" msgstr "Edit" #: templates/my_practice/client_detail.html:440 -#: templates/my_practice/client_detail_tabs.html:35 msgid "Intake & Anamnesis" msgstr "Intake & Anamnesis" #: templates/my_practice/client_detail.html:442 -#: templates/my_practice/client_detail_tabs.html:37 msgid "Clinical initial assessment…" msgstr "Clinical initial assessment…" #: templates/my_practice/client_detail.html:445 #: templates/my_practice/client_detail.html:456 -#: templates/my_practice/client_detail_tabs.html:40 -#: templates/my_practice/client_detail_tabs.html:51 msgid "Preview" msgstr "Preview" #: templates/my_practice/client_detail.html:453 -#: templates/my_practice/client_detail_tabs.html:48 msgid "Topics, dynamics, challenges…" msgstr "Topics, dynamics, challenges…" #: templates/my_practice/client_detail.html:462 -#: templates/my_practice/client_detail_tabs.html:57 msgid "Save profile" msgstr "Save profile" diff --git a/app/templates/my_practice/client_detail_sidebar.html b/app/templates/my_practice/client_detail_sidebar.html deleted file mode 100644 index d25fff1..0000000 --- a/app/templates/my_practice/client_detail_sidebar.html +++ /dev/null @@ -1,224 +0,0 @@ -{% load payment_tags %} - -
- - -
-
- - {% if client.onboarding_complete_date %} - ✅ Aufnahmeprozess abgeschlossen am {{ client.onboarding_complete_date|date:"d.m.Y" }} - {% else %} - 📋 Aufnahmeprozess - {% endif %} - -
    - - {# Step 1: Aufnahmebogen #} -
  • - {% if client.intake_sent_date %}✅{% else %}○{% endif %} -
    -
    Aufnahme
    - {% if client.intake_sent_date %} -
    Ausgehändigt am {{ client.intake_sent_date|date:"d.m.Y" }}
    - {% else %} -
    PDF herunterladen und aushändigen, dann als erledigt markieren
    - {% endif %} -
    -
    - 📄 PDF - {% url 'client_onboarding_step' client.pk as onboarding_url %} - {% if client.intake_sent_date %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='rückgängig' button_class='btn-undo' step='intake' reset='1' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='✓ Erledigt' button_class='btn btn-sm btn-done' step='intake' %} - {% endif %} -
    -
  • - - {# Step 2: Behandlungsvertrag #} -
  • - {% if client.contract_signed_date %}✅{% else %}○{% endif %} -
    -
    Vertrag
    - {% if client.contract_signed_date %} -
    Unterzeichnet am {{ client.contract_signed_date|date:"d.m.Y" }}
    - {% else %} -
    Vertrag per E-Mail zusenden, unterschrieben zurückerhalten
    - {% endif %} -
    -
    - {% if client.email %} - 📧 Senden - {% else %} - keine E-Mail - {% endif %} - {% url 'client_onboarding_step' client.pk as onboarding_url %} - {% if client.contract_signed_date %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='rückgängig' button_class='btn-undo' step='contract' reset='1' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='✓ Erledigt' button_class='btn btn-sm btn-done' step='contract' %} - {% endif %} -
    -
  • - - {# Step 3: Anamnesebogen #} -
  • - {% if client.questionnaire_sent_date %}✅{% else %}○{% endif %} -
    -
    Anamnese
    - {% if client.questionnaire_sent_date %} -
    Gesendet am {{ client.questionnaire_sent_date|date:"d.m.Y" }}
    - {% else %} -
    Fragebogen per E-Mail zusenden
    - {% endif %} -
    -
    - {% if client.email %} - 📧 Senden - {% else %} - keine E-Mail - {% endif %} - {% url 'client_onboarding_step' client.pk as onboarding_url %} - {% if client.questionnaire_sent_date %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='rückgängig' button_class='btn-undo' step='questionnaire' reset='1' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='✓ Erledigt' button_class='btn btn-sm btn-done' step='questionnaire' %} - {% endif %} -
    -
  • - - {# Step 4: Aufnahme abgeschlossen #} -
  • - {% if client.onboarding_complete_date %}🎉{% else %}○{% endif %} -
    -
    Aufnahme abgeschlossen
    - {% if client.onboarding_complete_date %} -
    Abgeschlossen am {{ client.onboarding_complete_date|date:"d.m.Y" }}
    - {% else %} -
    Alle Unterlagen vollständig — Aufnahme abschließen
    - {% endif %} -
    -
    - {% url 'client_onboarding_step' client.pk as onboarding_url %} - {% if client.onboarding_complete_date %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='rückgängig' button_class='btn-undo' step='complete' reset='1' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=onboarding_url button_text='✓ Abschließen' button_class='btn btn-sm btn-complete' step='complete' %} - {% endif %} -
    -
  • - -
-
-
- - -
-
-

Status

-
- {% if client.active %} - ● Aktiv - {% else %} - ● Inaktiv - {% endif %} -
-
- -
-

Zeitraum

-
{% if stats.activity_period %}{{ stats.activity_period }}{% else %}–{% endif %}
-
- -
-

Sitzungsstunden

-
{{ stats.total_hours|floatformat:1 }}h
-
- -
-

Offener Betrag

-
{% if stats.open_amount %}{{ stats.open_amount|currency }}{% else %}–{% endif %}
-
-
- - -
-

📧 Kontakt & Konditionen

-
-
-
Email
-
{{ client.email }}
-
-
-
Telefon
-
{{ client.phone|default:"–" }}
-
- {% if client.address %} -
-
Adresse
-
{{ client.address|linebreaksbr }}
-
- {% endif %} -
-
Kostenträger
-
{{ client.cost_carrier|default:"–" }}
-
-
-
Sprache
-
{{ client.get_language_display }}
-
-
-
Sitzungsformat
-
- {% if client.is_online_client %} - 💻 Online - {% else %} - 🏢 Vor Ort - {% endif %} -
-
-
-
Stundensatz
-
- {{ client.hourly_rate_60|floatformat:0 }} € -
-
-
-
Gesamtumsatz
-
{{ stats.total_revenue|currency }}
-
-
-
- - - -{% if documents %} - -{% endif %} - -{# P-031: Inquiry back-link — only if this client was converted from an inquiry #} -{% if client.source_inquiry %} - -{% endif %} - -
diff --git a/app/templates/my_practice/client_detail_tabs.html b/app/templates/my_practice/client_detail_tabs.html deleted file mode 100644 index b2cfc6b..0000000 --- a/app/templates/my_practice/client_detail_tabs.html +++ /dev/null @@ -1,334 +0,0 @@ -{% load markdown_filters %} -{% load i18n %} - -
-
- - - -
- - -
- {% if client.needs_gebueh_invoice and not profile.arbeitsdiagnose %} -
- {% if gebueh_diagnostic_count >= 5 %} - ⚠️ {% trans "Diagnosis not yet set — the probationary phase appears to be complete. Please update the diagnosis." %} - {% else %} - {% blocktrans %}Diagnosis not yet set — invoices currently show "Probationary phase". Please update once the probationary phase is complete.{% endblocktrans %} - {% endif %} - {% trans "Edit →" %} -
- {% endif %} -
- {% csrf_token %} -
- - -
-
- - - {% if profile.intake_notes %} -
- 📖 {% trans "Preview" %} -
{{ profile.intake_notes|render_markdown }}
-
- {% endif %} -
-
- - - {% if profile.case_notes %} -
- 📖 {% trans "Preview" %} -
{{ profile.case_notes|render_markdown }}
-
- {% endif %} -
-
- -
-
-
- - -
-
- - {% trans "+ Session log" %} - - - -
- -
-
- {% csrf_token %} -
- - -
- -
-
- -
-
- {% csrf_token %} - -
- - -
- -
-
- - {% if log_entries %} - {% trans "▴ Collapse" as txt_collapse %} - {% trans "▾ Show older entries" as txt_show_older %} - {% trans "more" as txt_more %} -
- {% for entry in log_entries %} - {% if forloop.counter == 11 %} - -
- {% endif %} - - {% if entry.type == "session" %} - {% with session=entry.obj log=entry.obj.log %} - {% trans "🧾 To invoice" as lbl_to_invoice %} - {% trans "🚷 Non-billable" as lbl_non_billable %} - {% trans "✅ Billable" as lbl_billable %} - {% trans "🚫 No-show" as lbl_noshow %} - {% trans "Delete session log?" as confirm_delete_log %} - {% trans "Delete session?" as confirm_delete_session %} -
- {% if log and log.content or log and log.summary or log and log.interventions or log and log.therapist_reflection %} - {# Two-column layout: narrow left (date + tags), wide right (content + interventions) #} -
-
- {{ session.session_date|date:"d.m.Y" }} - {{ session.duration }} {% trans "min" %} - {% if not session.invoice_items.all %}{% if session.billable %}{% trans "Unbilled" %}{% else %}{% trans "Non-billable" %}{% endif %}{% endif %} - {% if log.session_type %}{{ log.get_session_type_display }}{% endif %} - {% for tag in log.mood_tags %} - {{ tag|title }} - {% endfor %} - {% if session.gebueh_leistungen.all %} -
{% for le in session.gebueh_leistungen.all %}{{ le.ziffer.nummer }}{% endfor %}
- {% endif %} -
-
- {% if log.summary %}

{{ log.summary }}

{% endif %} - {% if log.content %}
{{ log.content|render_markdown }}
{% endif %} - {% if log.interventions %} -
{{ log.interventions }}
- {% endif %} - {% if log.therapist_reflection %} -
- {% trans "Own reflection" %} -
{{ log.therapist_reflection|render_markdown }}
-
- {% endif %} -
-
-
- ✏️ {% trans "Edit" %} - {% if client.needs_gebueh_invoice %} - {% trans "GebüH" %} - {% endif %} - {% if not session.invoice_items.all %} - {% url 'session_bill' client.pk session.pk as session_bill_url %} - {% include 'includes/inline_post_button.html' with action_url=session_bill_url button_text=lbl_to_invoice %} - {% url 'session_toggle_billable' client.pk session.pk as session_toggle_billable_url %} - {% if session.billable %} - {% include 'includes/inline_post_button.html' with action_url=session_toggle_billable_url button_text=lbl_non_billable button_class='btn-secondary btn-sm' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=session_toggle_billable_url button_text=lbl_billable button_class='btn-secondary btn-sm' %} - {% endif %} - {% endif %} - {% if log.session_type != 'ausfall' %} - {% url 'session_log_mark_noshow' client.pk log.pk as session_log_mark_noshow_url %} - {% include 'includes/inline_post_button.html' with action_url=session_log_mark_noshow_url button_text=lbl_noshow %} - {% endif %} - {% url 'session_log_delete' client.pk log.pk as session_log_delete_url %} - {% include 'includes/inline_post_button.html' with action_url=session_log_delete_url button_text='🗑️' button_class='btn-danger btn-sm' confirm=confirm_delete_log %} -
- {% else %} - {# No content yet — simple single-row layout #} -
- {{ session.session_date|date:"d.m.Y" }} - {{ session.duration }} {% trans "min" %} - {% if not session.invoice_items.all %}{% if session.billable %}{% trans "Unbilled" %}{% else %}{% trans "Non-billable" %}{% endif %}{% endif %} - {% if session.pk in recent_sessions_needing_log %}📝 {% trans "Log missing" %}{% endif %} - {% if log %} - {{ log.get_session_type_display }} - {% for tag in log.mood_tags %} - {{ tag|title }} - {% endfor %} - {% endif %} - {% if session.gebueh_leistungen.all %} -
{% for le in session.gebueh_leistungen.all %}{{ le.ziffer.nummer }}{% endfor %}
- {% endif %} -
-
- {% if log %} - ✏️ {% trans "Edit" %} - {% if client.needs_gebueh_invoice %} - {% trans "GebüH" %} - {% endif %} - {% if log.session_type != 'ausfall' %} - {% url 'session_log_mark_noshow' client.pk log.pk as session_log_mark_noshow_url %} - {% include 'includes/inline_post_button.html' with action_url=session_log_mark_noshow_url button_text=lbl_noshow %} - {% endif %} - {% url 'session_log_delete' client.pk log.pk as session_log_delete_url %} - {% include 'includes/inline_post_button.html' with action_url=session_log_delete_url button_text='🗑️' button_class='btn-danger btn-sm' confirm=confirm_delete_log %} - {% else %} - {% if client.needs_gebueh_invoice %} - {% trans "GebüH" %} - {% endif %} - {% if not session.invoice_items.all %} - {% url 'session_bill' client.pk session.pk as session_bill_url %} - {% include 'includes/inline_post_button.html' with action_url=session_bill_url button_text=lbl_to_invoice %} - {% url 'session_toggle_billable' client.pk session.pk as session_toggle_billable_url %} - {% if session.billable %} - {% include 'includes/inline_post_button.html' with action_url=session_toggle_billable_url button_text=lbl_non_billable button_class='btn-secondary btn-sm' %} - {% else %} - {% include 'includes/inline_post_button.html' with action_url=session_toggle_billable_url button_text=lbl_billable button_class='btn-secondary btn-sm' %} - {% endif %} - {% endif %} - {% if session.pk not in no_log_needed_session_ids %}{% trans "+ Log" %}{% endif %} -
- {% csrf_token %} - - -
- {% if not session.invoice_items.all %} - {% url 'session_delete' client.pk session.pk as session_delete_url %} - {% include 'includes/inline_post_button.html' with action_url=session_delete_url button_text='🗑️' button_class='btn-danger btn-sm' confirm=confirm_delete_session %} - {% endif %} - {% endif %} -
- {% endif %} -
- {% endwith %} - - {% elif entry.type == "note" %} - {% with note=entry.obj %} - {% if note.note_type == "supervision" %} - {% trans "Delete supervision note?" as confirm_del_supervision_note %} -
-
- {{ note.note_date|date:"d.m.Y" }} - 🔍 {% trans "Supervision" %} - -
- {% csrf_token %} - -
-
-
{{ note.content|render_markdown }}
-
-
- {% csrf_token %} - -
- -
- -
- - -
-
-
-
- {% else %} - {% trans "Delete note?" as confirm_del_note %} -
-
- {{ note.note_date|date:"d.m.Y" }} -
- {% csrf_token %} - -
-
-
{{ note.content|render_markdown }}
-
- {% endif %} - {% endwith %} - {% endif %} - - {% endfor %} - {% if log_entries|length > 10 %}
{% endif %} -
- {% else %} -

{% trans "No entries yet. Session logs are created when adding new entries." %}

- {% endif %} -
- - -
-
- {% csrf_token %} - - -
- - {% if supervision_items %} -
- {% for item in supervision_items %} - {% trans "Delete supervision topic?" as confirm_del_supervision %} - {% if item.status %}{% trans "↩ Reopen" as lbl_toggle %}{% else %}{% trans "✓ Discussed" as lbl_toggle %}{% endif %} -
-
{{ item.content }}
-
- {{ item.get_status_display }} - {{ item.created_at|date:"d.m.Y" }} -
-
- {% url 'supervision_item_toggle' client.pk item.pk as toggle_url %} - {% include 'includes/inline_post_button.html' with action_url=toggle_url button_text=lbl_toggle button_class='btn btn-secondary btn-sm' %} - {% url 'supervision_item_delete' client.pk item.pk as delete_url %} - {% include 'includes/inline_post_button.html' with action_url=delete_url button_text='🗑' button_class='btn btn-danger btn-sm' confirm=confirm_del_supervision %} -
-
- {% endfor %} -
- {% else %} -

{% trans "No supervision topics for this client yet." %}

- {% endif %} -
- -
From 774d89f4b6c502d5b3be08bc3666e073e3cafe87 Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:28:40 +0200 Subject: [PATCH 5/6] docs(#187): rewrite CODE_STRUCTURE.md to reflect current codebase - Updated directory tree: 18 models (adds capacity.py), 35 utils (adds billing_helpers.py) - Dashboard section replaced with current reality: 22-line dispatcher + 9 widget builders - Replaced stale per-module line counts with a views quick-reference table - Documents billing_helpers.py, available_data_years(), CapacityPeriod (all v0.2.9) - Migration history table removed (see CHANGELOG.md for that) - Last updated: 2026-07-06 Closes #187 Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture/CODE_STRUCTURE.md | 519 +++++++--------------------- 1 file changed, 119 insertions(+), 400 deletions(-) diff --git a/docs/architecture/CODE_STRUCTURE.md b/docs/architecture/CODE_STRUCTURE.md index f01aca7..c985a4a 100644 --- a/docs/architecture/CODE_STRUCTURE.md +++ b/docs/architecture/CODE_STRUCTURE.md @@ -1,94 +1,101 @@ # my_practice — Code Structure -**Last updated: 2026-06-15** (reflects current codebase; module descriptions below are from the original Jan 2026 refactor — see source files for full detail) +**Last updated: 2026-07-06** ## Overview -The application was refactored from a monolithic `views.py` (1480 lines) into a modular structure with clear separation of concerns. +The application was refactored from a monolithic `views.py` into a modular structure with clear separation of concerns. This document describes the current layout; see [docs/CHANGELOG.md](../CHANGELOG.md) for the evolution history. ## Directory Structure ``` app/my_practice/ -├── models/ # Domain models package (17 modules) +├── models/ # Domain models package (18 modules) │ ├── __init__.py # Package exports with __all__ -│ ├── base.py # TimestampedModel base class -│ ├── bank_statement.py # Bank statement + transactions -│ ├── calendar.py # Google Calendar tokens +│ ├── base.py # TimestampedModel base class, PracticeScopedManager +│ ├── bank_statement.py # BankTransaction +│ ├── calendar.py # GoogleCalendarToken, PendingCalendarEvent +│ ├── capacity.py # CapacityPeriod (configurable capacity periods) │ ├── client.py # Client management -│ ├── client_alias.py # Client alias / search name +│ ├── client_alias.py # ClientAlias / search name │ ├── clinical.py # ClientProfile, SessionLog, SupervisionItem, ClientNote -│ ├── financial.py # CompanyWithdrawal & CompanyExpense +│ ├── financial.py # CompanyWithdrawal, CompanyExpense │ ├── inquiry.py # ClientInquiry lead tracking -│ ├── invoice.py # Invoice & InvoiceItem +│ ├── invoice.py # Invoice, InvoiceItem │ ├── operational.py # OperationalChecklist + items -│ ├── practice.py # Practice + UserPractice +│ ├── practice.py # Practice, UserPractice │ ├── service.py # ServiceType │ ├── session.py # Session -│ ├── tag.py # ClientTag + ClientTagAssignment +│ ├── tag.py # ClientTag, ClientTagAssignment │ ├── timeoff.py # TimeOff tracking │ └── todo.py # PracticeTodo │ ├── views/ # View modules │ ├── __init__.py # Central exports for all views -│ ├── crud_mixins.py # PracticeScopedCreateView/UpdateView/DeleteView/ListView -│ ├── analytics_views.py # Analytics & reports -│ ├── api_views.py # JSON API endpoints + PDF generation -│ ├── bank_import_views.py # Bank statement CSV import +│ ├── crud_mixins.py # PracticeScopedCreate/Update/Delete/ListView, InvoiceFormsetMixin +│ ├── analytics_views.py # Analytics & revenue reports +│ ├── api_views.py # JSON API endpoints, PDF generation +│ ├── bank_import_views.py # Bank statement CSV import & review │ ├── batch_invoice_views.py # Monthly batch invoice creation -│ ├── calendar_views.py # Google Calendar sync + session import +│ ├── calendar_views.py # Google Calendar OAuth + session import approval │ ├── client_views.py # Client list, detail, intake -│ ├── clinical_views.py # ClientProfile, SessionLog, SupervisionItem, triage -│ ├── dashboard_views.py # Dashboard & home -│ ├── email_views.py # Email compose + send +│ ├── clinical_views.py # SessionLog, ClientNote, SupervisionItem, triage +│ ├── dashboard_views.py # Dashboard (delegates to DashboardContextAssembler) +│ ├── email_views.py # Email compose + send (invoice, reminder, contract, …) │ ├── expense_views.py # Expense CRUD + list │ ├── import_views/ # CSV import package (invoices, sessions) -│ ├── inquiry_views.py # Lead tracking + funnel analytics (P-031/P-034/P-037) -│ ├── invoice_views.py # Invoice CRUD + formset -│ ├── operational_views.py # Operational checklist widget +│ ├── inquiry_views.py # Lead tracking + funnel analytics +│ ├── invoice_views.py # Invoice CRUD + billing overview +│ ├── operational_views.py # Operational checklist │ ├── practice_views.py # Practice settings + multi-practice management │ ├── search_views.py # Global search │ ├── tag_views.py # Client tag management -│ ├── tax_views.py # Tax year summary + workday audit +│ ├── tax_views.py # Tax year summary, quarterly overview, workday audit │ ├── todo_views.py # Practice todo list │ └── withdrawal_views.py # Withdrawal CRUD + list │ -├── utils/ # Utility functions (34 modules) +├── utils/ # Utility functions (35 modules) │ ├── __init__.py # Central exports -│ ├── agenda_helpers.py # Session agenda / queue helpers -│ ├── aggregation_helpers.py # Reusable DB aggregation patterns +│ ├── action_queue_builder.py # ActionQueueBuilder (dashboard action queue) +│ ├── agenda_helpers.py # AgendaWidgetBuilder (daily/weekly agenda) +│ ├── aggregation_helpers.py # Reusable DB aggregation patterns │ ├── analytics_dashboard_builder.py # AnalyticsDashboardBuilder -│ ├── analytics_utils.py # Analytics computations -│ ├── bank_import.py # Bank statement parsing logic -│ ├── calculations.py # count_sessions() and financial math -│ ├── calendar_event_processor.py # CalendarImportProcessor (P-100) -│ ├── calendar_import_helpers.py # Calendar event → Session import -│ ├── calendar_preflight.py # Calendar connection preflight checks -│ ├── capacity_helpers.py # Capacity / utilisation calculations -│ ├── chart_helpers.py # Chart data preparation -│ ├── client_detail_builder.py # ClientDetailContextBuilder (P-100) -│ ├── client_helpers.py # Client convenience helpers -│ ├── contract_form.py # Contract PDF generation helpers -│ ├── csv_parser.py # German/US decimal parsing -│ ├── dashboard_context_builder.py # DashboardContextAssembler (P-100) -│ ├── dashboard_widgets.py # Dashboard widget data builders -│ ├── date_helpers.py # DateRangeHelper + working-day counts -│ ├── email_utils.py # Email composition helpers +│ ├── analytics_utils.py # Analytics computations +│ ├── bank_import.py # Bank statement CSV parsing +│ ├── billing_helpers.py # Session→InvoiceItem: build_service_type_map, +│ │ # resolve_session_rate, is_session_already_billed, +│ │ # create_invoice_item_for_session +│ ├── calculations.py # count_sessions(), financial math +│ ├── calendar_event_processor.py # CalendarImportProcessor +│ ├── calendar_import_helpers.py # Calendar event → Session import helpers +│ ├── calendar_preflight.py # Calendar connection preflight checks +│ ├── capacity_helpers.py # Capacity / utilisation calculations +│ ├── chart_helpers.py # Chart data preparation +│ ├── client_detail_builder.py # ClientDetailContextBuilder +│ ├── client_helpers.py # Client convenience helpers +│ ├── contract_form.py # Contract PDF generation helpers +│ ├── csv_parser.py # German/US decimal parsing +│ ├── dashboard_context_builder.py # DashboardContextAssembler +│ ├── dashboard_widgets.py # Dashboard widget data builders (9 builders) +│ ├── date_helpers.py # DateRangeHelper, working-day counts, +│ │ # get_quarter_range, get_quarter_for_date +│ ├── email_utils.py # Email composition helpers │ ├── financial_list_context_builder.py # FinancialListContextBuilder -│ ├── google_calendar.py # Google Calendar API wrapper -│ ├── heatmap_utils.py # Session heatmap generation -│ ├── import_helpers.py # CSV import base classes -│ ├── invoice_filter_helper.py # InvoiceFilterHelper -│ ├── invoice_helpers.py # Invoice number generation -│ ├── practice_analysis.py # PracticeAnalyzer -│ ├── practice_days.py # berlin_public_holidays() -│ ├── practice_helpers.py # Practice-scoped query helpers -│ ├── revenue_helpers.py # RevenueCalculator -│ ├── tag_helpers.py # Tag sorting + category helpers -│ ├── tax_context_builder.py # TaxYearContextBuilder (P-100) -│ ├── timeoff_helpers.py # Time-off query helpers -│ ├── view_helpers.py # Year/date/search filter helpers -│ └── weekly_focus_widget.py # WeeklyFocus widget logic +│ ├── google_calendar.py # Google Calendar API wrapper +│ ├── heatmap_utils.py # Session heatmap generation +│ ├── import_helpers.py # CSV import base classes +│ ├── invoice_filter_helper.py # InvoiceFilterHelper +│ ├── invoice_helpers.py # get_next_invoice_number() +│ ├── practice_analysis.py # PracticeAnalyzer +│ ├── practice_days.py # berlin_public_holidays(), PracticeDayCalculator, +│ │ # WorkdayAuditCalculator +│ ├── practice_helpers.py # Practice-scoped query helpers +│ ├── revenue_helpers.py # RevenueCalculator +│ ├── tag_helpers.py # Tag sorting + category helpers +│ ├── tax_context_builder.py # TaxYearContextBuilder, available_data_years() +│ ├── timeoff_helpers.py # Time-off query helpers +│ ├── view_helpers.py # Year/date/search filter helpers +│ └── weekly_focus_widget.py # WeeklyFocusWidgetBuilder │ ├── templatetags/ │ ├── payment_tags.py # Custom template tags/filters (query_string, etc.) @@ -96,9 +103,9 @@ app/my_practice/ │ └── number_filters.py # Number formatting filters │ ├── management/ -│ └── commands/ # Management commands (see SCRIPTS.md for usage) +│ └── commands/ # Management commands (see docs/operations/SCRIPTS.md) │ -├── tests/ # Test suite (~937 tests as of 2026-06-09) +├── tests/ # Test suite (~960+ tests) │ └── ... # One file per module; see test_*.py files │ ├── static/ @@ -107,371 +114,84 @@ app/my_practice/ │ │ └── tests/ # JavaScript test suite │ └── css/ │ ├── tailwind.css # Single CSS source — all styles live here -│ └── tailwind.out.css # Compiled output (gitignored; rebuilt by npm run build:css) +│ └── tailwind.out.css # Compiled output (gitignored) └── ... ``` -## Views Modules +## Key Architectural Patterns -### 1. `client_views.py` (72 lines) -**Responsibility**: Client management +### Builder classes for complex context -**Views**: -- `ClientListView`: Client list with sorting -- `ClientIntakeView`: Create new client +Views with more than trivial context preparation delegate to builder classes: -**Dependencies**: `Client`, `ClientIntakeForm` - ---- - -### 2. `invoice_views.py` (201 lines) -**Responsibility**: Invoice CRUD operations - -**Views**: -- `InvoiceListView`: Invoice list with filters (status, year) -- `InvoiceCreateView`: Create new invoice with line items -- `InvoiceDetailView`: View invoice details - -**Features**: -- Automatic invoice number generation -- Formset handling for invoice line items -- Statistics (draft/sent/paid) - -**Dependencies**: `Invoice`, `Client`, `InvoiceForm`, `InvoiceItemFormSet` - ---- - -### 3. `dashboard_views.py` (166 lines) -**Responsibility**: Dashboard and home page - -**Views**: -- `home()`: Redirect to dashboard -- `dashboard()`: Main dashboard with statistics - -**Features**: -- Revenue statistics (total/year/month) -- Monthly revenue trend (12 months) -- Session history heatmap (configurable) -- Status breakdown (draft/sent/paid/cancelled) -- Active clients overview - -**Dependencies**: `Invoice`, `Client`, `SessionHistory`, `InvoiceItem`, `heatmap_utils` - ---- - -### 4. `api_views.py` (152 lines) -**Responsibility**: API endpoints and PDF generation - -**Views**: -- `next_invoice_number()`: Next invoice number for client -- `invoice_pdf()`: PDF generation (DE/EN) -- `update_invoice_status()`: Status update via POST - -**Features**: -- JSON responses for AJAX -- PDF generation with WeasyPrint -- Image optimization (logo/signature) -- Automatic paid_date management - -**Dependencies**: `Invoice`, `Client`, `Practice`, `weasyprint`, `PIL` - ---- - -### 5. `analytics_views.py` (128 lines) -**Responsibility**: Analytics and reports - -**Views**: -- `analytics_dashboard()`: 5-year analysis (2020–2025) -- `revenue_report()`: Detailed revenue report by payment year - -**Features**: -- Revenue trends (monthly, yearly) -- Session type distribution -- Top-10 clients by revenue -- Most active months -- Prior-year revenue comparison - -**Dependencies**: `Invoice`, `analytics_utils` - ---- - -### 7. `expense_views.py` (~50 lines) [Uses aggregation_helpers - 23 Dec] -**Responsibility**: Expense management - -**Views**: -- `expense_list()`: Expense list with yearly/category overview - -**Features**: -- Uses `get_yearly_totals()` from aggregation_helpers -- Uses `get_category_breakdown()` with human-readable names -- Uses `get_grand_total()` with filter conditions -- Tax-deductible vs. non-deductible filtering -- Dark Mode compatible -- Integration with Finance dropdown menu - -**Dependencies**: `CompanyExpense`, `utils.aggregation_helpers` - ---- - -### 8. `withdrawal_views.py` (~65 lines) [Uses aggregation_helpers - 23 Dec] -**Responsibility**: Withdrawal management - -**Views**: -- `withdrawal_list()`: Withdrawal list with monthly overview -- `withdrawal_create()`: Create new withdrawal -- `withdrawal_update()`: Update withdrawal -- `withdrawal_delete()`: Delete withdrawal - -**Features**: -- Uses `get_yearly_totals()` from aggregation_helpers -- Uses `get_monthly_breakdown()` for current year -- Uses `get_grand_total()` for total calculations -- CRUD operations with German UI messages -- Category filtering -- Integration with analytics dashboard -- Dark mode compatible - -**Dependencies**: `CompanyWithdrawal`, `utils.aggregation_helpers`, `CompanyWithdrawalForm` - ---- - -### 9. `tax_views.py` (~130 lines) [Uses aggregation_helpers - 23 Dec] -**Responsibility**: Tax year overview - -**Views**: -- `tax_year_summary()`: Comprehensive tax year report - -**Features**: -- Revenue breakdown (paid invoices with paid_date accuracy) -- Expense breakdown by category using `get_category_breakdown()` -- Withdrawal breakdown by category using `get_category_breakdown()` -- Uses `get_grand_total()` for expense/withdrawal totals -- Profit calculations (Gross: Revenue - Expenses, Net: Gross - Withdrawals) -- Year filter with `get_year_from_request()` -- Tax deductible expense filtering - -**Dependencies**: `Invoice`, `CompanyExpense`, `CompanyWithdrawal`, `utils.aggregation_helpers`, `utils.revenue_helpers` - ---- - -## Utils Modules - -### 1. `calculations.py` (48 lines) -**Responsibility**: Session counting & financial calculations - -**Functions**: -- `count_sessions()`: Accurate session counting with duration normalization - - Formula: `(duration / 60.0) * quantity` - - Returns float for precision -- `count_sessions_rounded()`: Rounded session counts (integer) -- `apply_remainder_distribution()`: Remainder distribution for exact totals - - **Problem**: 340€ ÷ 3 = 113.33€ × 3 = 339.99€ (0.01€ lost) - - **Solution**: Last item gets the remainder (113.33€ + 113.33€ + 113.34€ = 340€) - -**Usage**: Heatmap, analytics, reconciliation, import views - ---- - -### 2. `csv_parser.py` (32 lines) -**Responsibility**: Decimal parsing - -**Functions**: -- `parse_german_decimal()`: **Enhanced** — Intelligent format detection - - Handles German format: `1.234,56` (dot=thousand, comma=decimal) - - Handles US format: `1,234.56` (comma=thousand, dot=decimal) - - Handles mixed format: `-2,160.00 €` (auto-detects last separator as decimal) - - Logic: If both separators present, last one is decimal separator - -**Usage**: Import views (invoices, withdrawals, expenses) - ---- - -### 3. `reconciliation.py` (289 lines) -**Responsibility**: Session history vs invoice items reconciliation - -**Functions**: -- `group_items_by_month()`: Group invoice items by month -- `count_sessions_in_items()`: Count sessions using centralized logic -- `build_invoice_summary()`: Build monthly invoice summaries -- `detect_discrepancies()`: Detect SessionHistory/InvoiceItem mismatches -- `build_reconciliation_data()`: Main orchestration function - -**Usage**: reconciliation_views.py, management commands - ---- - -### 4. `date_helpers.py` (87 lines) -**Responsibility**: Date range operations - -**Class**: `DateRangeHelper` -- `get_month_range()`: First and last day of month -- `get_year_range()`: First and last day of year -- `get_previous_month()`: Previous month date -- `get_next_month()`: Next month date - -**Usage**: Analytics, heatmap, reports - ---- - -### 5. `invoice_helpers.py` (53 lines) -**Responsibility**: Invoice numbering logic - -**Functions**: -- `get_next_invoice_number()`: Generate next invoice number for client - - Handles sequential numbering - - Handles gaps in sequence - - Handles malformed numbers - -**Tests**: 10 comprehensive tests - -**Usage**: InvoiceCreateView, InvoiceDetailView, API endpoints - ---- - -### 6. `revenue_helpers.py` (143 lines) -**Responsibility**: Revenue aggregation and calculations - -**Class**: `RevenueCalculator` -- `get_paid_invoices()`: Get paid invoices with optional year filter -- `get_total_revenue()`: Total revenue from paid invoices -- `get_year_revenue()`: Revenue for specific year (paid_date aware) -- `get_status_breakdown()`: Invoice counts/totals by status (single query) - -**Usage**: Dashboard, tax views, invoice list views - ---- - -### 7. `view_helpers.py` (167 lines) -**Responsibility**: Reusable view helper functions - -**Functions**: -- `get_date_range_from_request()`: Parse start/end date from request -- `filter_queryset_by_date_range()`: Apply date filters to queryset -- `get_year_from_request()`: Parse and validate year parameter -- `paginate_queryset()`: Generic pagination helper -- `get_search_query_filter()`: Build Q object for multi-field search - -**Tests**: 10 tests - -**Usage**: All views with date filters, year filters, search - ---- - -**Responsibility**: Reusable view mixins for ListView patterns - -**Mixins**: -- `YearFilterMixin`: Automatic year filtering with context - - Attributes: `date_field`, `year_param` - - Adds: `current_year`, `available_years` to context -- `StatusFilterMixin`: Status filtering for list views - - Attributes: `status_field`, `status_param`, `valid_statuses` - - Adds: `current_status` to context -- `SearchMixin`: Multi-field search capability - - Attributes: `search_param`, `search_fields` - - Adds: `search_query` to context -- `CombinedFilterMixin`: Combines all three mixins - -**Usage Example**: ```python -class ExpenseListView(YearFilterMixin, ListView): - model = CompanyExpense - date_field = 'date' - template_name = 'expense_list.html' -``` - -**Usage**: Future ListView refactorings +# Dashboard +from my_practice.utils import DashboardContextAssembler +context = DashboardContextAssembler(request).build() ---- +# Tax year summary +from my_practice.utils import TaxYearContextBuilder +context = TaxYearContextBuilder(year, practice, user).build(expense_sort="date") -### 9. `aggregation_helpers.py` (200 lines) [NEW - 23 Dec 2025] -**Responsibility**: Reusable aggregation patterns for data analysis - -**Functions**: -- `get_yearly_totals()`: Aggregate by year with totals - - Params: queryset, date_field, amount_field, order - - Returns: List of {year, total} -- `get_category_breakdown()`: Aggregate by category with counts - - Params: queryset, category_field, amount_field, category_choices - - Returns: List of {category, category_name, total, count} -- `get_monthly_breakdown()`: Aggregate by month for specific year - - Params: queryset, year, date_field, amount_field - - Returns: Dict {YYYY-MM: Decimal} -- `get_grand_total()`: Calculate total with optional filtering - - Params: queryset, amount_field, filter_condition - - Returns: Decimal -- `get_year_over_year_comparison()`: Compare totals across years - - Params: queryset, years, date_field, amount_field - - Returns: Dict with totals and growth percentages +# Analytics +from my_practice.utils import AnalyticsDashboardBuilder +context = AnalyticsDashboardBuilder(start_date, end_date).build_context() +``` -**Tests**: 10 comprehensive tests in test_aggregation_helpers.py +### Dashboard architecture (as of v0.2.7 P-117 redesign) -**Usage**: expense_views.py, withdrawal_views.py, tax_views.py +`dashboard_views.py` is a thin dispatcher (22 lines). All data preparation lives in: +- `DashboardContextAssembler` (`dashboard_context_builder.py`) — orchestrates widget builders +- Nine widget builders in `dashboard_widgets.py`: `AgendaWidgetBuilder`, `WeeklyFocusWidgetBuilder`, `ActionQueueBuilder`, `InvoiceActionsWidgetBuilder`, `ClientAttentionWidgetBuilder`, `SessionImportWidgetBuilder`, `PendingCalendarWidgetBuilder`, `ChecklistWidgetBuilder`, `CapacityMonitoringWidgetBuilder`, `TaxQuarterWidgetBuilder`, `BankImportReminderWidgetBuilder` ---- +### Session billing helpers (`utils/billing_helpers.py`) -## Import Compatibility +Three call sites (add-to-invoice, create-invoice-with-sessions, calendar approval) share: +- `build_service_type_map(practice)` — `{duration: ServiceType}` dict for the practice +- `resolve_session_rate(client, service_type)` — handles `therapy_free` zero-rating +- `is_session_already_billed(session)` — excludes cancelled invoices +- `create_invoice_item_for_session(invoice, session, map, fallback)` — full helper with all guards -The `views/__init__.py` exports all views so that URLs remain **unchanged**: +### CRUD mixins (`views/crud_mixins.py`) ```python -# urls.py -from .views import ( - ClientListView, - InvoiceCreateView, - analytics_dashboard, - import_invoices, - # ... all other views -) +class ExpenseCreateView(FormCreateViewMixin): + model = CompanyExpense + form_class = CompanyExpenseForm + success_url_name = "expense_list" ``` -No changes to `urls.py`, `admin.py`, or templates required! - ---- - -## Benefits of the New Structure +`PracticeScopedDeleteView`, `PracticeScopedListView`, `InvoiceFormsetMixin` follow the same pattern. -### 1. **Maintainability** -- Each file < 800 lines (average 200 lines) -- Clear responsibilities (Single Responsibility Principle) -- Easier navigation and debugging +### Centralized calculations -### 2. **Reusability** -- Utils can be used in management commands, tests, and tasks -- CSV parser is independent of views -- Validators can be used in forms/serializers +| Helper | Location | Use for | +|--------|----------|---------| +| `RevenueCalculator` | `revenue_helpers.py` | All revenue queries — always use this, never filter manually | +| `DateRangeHelper` | `date_helpers.py` | Month/quarter/year ranges, working-day counts | +| `available_data_years(practice)` | `tax_context_builder.py` | Year-selector dropdowns in tax views | +| `count_sessions(items)` | `calculations.py` | Session count with duration normalization | +| `get_next_invoice_number(client)` | `invoice_helpers.py` | Invoice number generation | +| `build_client_map()` | `import_helpers.py` | Optimized code→client mapping | -### 3. **Testability** -- Isolated functions are easier to test -- Utils have no Django view dependencies -- Mocking is simpler +### Capacity periods (v0.2.9) -### 4. **Scalability** -- New features can be added in new files -- Import logic can be split further (e.g. invoice_import.py, session_import.py) -- Utils can be extended without touching views +Weekly capacity is no longer hard-coded at 2023-08-01. The `CapacityPeriod` model holds one row per change, editable in Practice Settings. `capacity_helpers.get_weekly_capacity_for_date(date)` returns the correct hours per week for any date. All utilisation calculations in `analytics_utils.py` and `practice_days.py` consume it. -### 5. **Code deduplication** -- `apply_remainder_distribution()` instead of copy-paste -- `parse_german_decimal()` centrally available -- `validate_paid_date()` reusable +## Views Quick Reference ---- - -## Migration history (summary) - -| Date | Change | -|------|--------| -| 2025-12-16 | Initial refactor: monolithic `views.py` (1 480 lines) → 7 view modules + utils | -| 2025-12-18 | Expense tracking added; import views packaged; aggregation helpers extracted | -| 2025-12-21 | Performance: N+1 fixes, DB indexes (migrations 0013–0014), `select_related` everywhere | -| 2025-12-23 | Template components (`pagination.html`, `empty_state.html`); CSS filter-bar components | -| 2025-12-26 | Models package (9 → now 17 modules); chart system modularised (11 JS modules) | -| Jan 2026 | CRUD mixin refactoring (`PracticeScopedCreateView/UpdateView/DeleteView/ListView`) | -| Feb–Apr 2026 | P-031–P-042: inquiries, calendar, clinical docs, analytics, batch invoicing, tax allocation; codebase renamed `payments_app` → `my_practice` (P-032) | - -For full detail see [docs/CHANGELOG.md](../CHANGELOG.md). - ---- +| Module | Responsibility | Notable patterns | +|--------|---------------|-----------------| +| `dashboard_views.py` | Dashboard home | Pure dispatcher; delegates to `DashboardContextAssembler` | +| `invoice_views.py` | Invoice CRUD + billing overview | `InvoiceFormsetMixin`; `billing_helpers` for session items | +| `client_views.py` | Client list + intake | `PracticeScopedListView` | +| `clinical_views.py` | SessionLog, Notes, Supervision, triage | Fernet encryption for notes/sessions | +| `calendar_views.py` | Google Calendar OAuth + event approval | `CalendarImportProcessor`; `resolve_session_rate` | +| `email_views.py` | Email compose + send | `BaseClientEmailView` base class; 5 concrete views | +| `bank_import_views.py` | CSV import + manual review | `_fetch_invoice_maps()` batch helper; stored `total` | +| `tax_views.py` | Tax summary, quarters, workday audit | `TaxYearContextBuilder`; `available_data_years()` | +| `analytics_views.py` | Revenue & session analytics | `AnalyticsDashboardBuilder` | +| `inquiry_views.py` | Lead pipeline | Funnel analytics, email templates, client code suggester | ## Support @@ -480,4 +200,3 @@ For questions about code structure or extensions: 2. Check docstrings in the module 3. For performance patterns: see [PERFORMANCE.md](PERFORMANCE.md) 4. For available utility classes: see [CLAUDE.md](../../CLAUDE.md) "Available Utility Classes" section - From bd8ff0738766c584482d8b9ac5641e4e9e7c0e4d Mon Sep 17 00:00:00 2001 From: Daniel Holbach Date: Mon, 6 Jul 2026 13:49:01 +0200 Subject: [PATCH 6/6] i18n: wrap all user-facing strings in bank_import_views + calendar_views (English msgids) Per CLAUDE.md: any Python file touched for any reason must have user-facing strings wrapped. Both files were touched for the code-quality fixes in this branch and were previously missing gettext coverage. - bank_import_views.py: 28 messages/JsonResponse strings wrapped; all four view classes covered (BankImportView, BankReviewView, BankExpenseReviewView, BankWithdrawalReviewView); ngettext used for plurals - calendar_views.py: 21 messages/JsonResponse strings wrapped; gettext import added to module All msgids use English; German translations added to locale/de/django.po. locale/en/django.po: pass-through msgstr = msgid for all new entries. Co-Authored-By: Claude Sonnet 4.6 --- app/locale/de/LC_MESSAGES/django.po | 237 +++++++++++++++++++- app/locale/en/LC_MESSAGES/django.po | 239 ++++++++++++++++++++- app/my_practice/views/bank_import_views.py | 138 ++++++++---- app/my_practice/views/calendar_views.py | 57 ++--- 4 files changed, 589 insertions(+), 82 deletions(-) diff --git a/app/locale/de/LC_MESSAGES/django.po b/app/locale/de/LC_MESSAGES/django.po index e38aa8a..d11c6af 100644 --- a/app/locale/de/LC_MESSAGES/django.po +++ b/app/locale/de/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: my-practice\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 13:27+0200\n" +"POT-Creation-Date: 2026-07-06 13:43+0200\n" "PO-Revision-Date: 2026-06-22 23:01+0200\n" "Language-Team: German\n" "Language: de\n" @@ -386,6 +386,235 @@ msgid_plural "💰 Revenue opportunity: %(n)s clients with sessions but no invoi msgstr[0] "💰 Abrechnungspotenzial: %(n)s Klient:in mit Sitzungen aber ohne Rechnung" msgstr[1] "💰 Abrechnungspotenzial: %(n)s Klient:innen mit Sitzungen aber ohne Rechnung" +#: my_practice/views/bank_import_views.py:72 +#, python-format +msgid "%(count)s automatically matched" +msgstr "%(count)s automatisch zugeordnet" + +#: my_practice/views/bank_import_views.py:73 +#, python-format +msgid "%(count)s require manual review" +msgstr "%(count)s benötigen manuelle Prüfung" + +#: my_practice/views/bank_import_views.py:77 +#, python-format +msgid "%(count)s expenses detected automatically" +msgstr "%(count)s Ausgaben automatisch erkannt" + +#: my_practice/views/bank_import_views.py:79 +#, python-format +msgid "%(count)s ignored" +msgstr "%(count)s ignoriert" + +#: my_practice/views/bank_import_views.py:82 +#, python-format +msgid "Import complete: %(details)s." +msgstr "Import abgeschlossen: %(details)s." + +#: my_practice/views/bank_import_views.py:220 +msgid "No transaction selected." +msgstr "Keine Transaktion ausgewählt." + +#: my_practice/views/bank_import_views.py:309 +#, python-format +msgid "Auto-ignored: invoice %(number)s already paid" +msgstr "Auto-ignoriert: Rechnung %(number)s bereits bezahlt" + +#: my_practice/views/bank_import_views.py:319 +#, python-format +msgid "✅ %(count)s transaction with already-paid invoice ignored." +msgid_plural "✅ %(count)s transactions with already-paid invoices ignored." +msgstr[0] "✅ %(count)s Transaktion mit bereits bezahlter Rechnung ignoriert." +msgstr[1] "✅ %(count)s Transaktionen mit bereits bezahlten Rechnungen ignoriert." + +#: my_practice/views/bank_import_views.py:326 +msgid "No matching transactions to ignore." +msgstr "Keine passenden Transaktionen zum Ignorieren gefunden." + +#: my_practice/views/bank_import_views.py:339 +#, python-format +msgid "%(count)s expense ignored." +msgid_plural "%(count)s expenses ignored." +msgstr[0] "%(count)s Ausgabe ignoriert." +msgstr[1] "%(count)s Ausgaben ignoriert." + +#: my_practice/views/bank_import_views.py:356 +#: my_practice/views/bank_import_views.py:669 +#: my_practice/views/bank_import_views.py:817 +#, python-format +msgid "%(count)s transaction ignored." +msgid_plural "%(count)s transactions ignored." +msgstr[0] "%(count)s Transaktion ignoriert." +msgstr[1] "%(count)s Transaktionen ignoriert." + +#: my_practice/views/bank_import_views.py:370 +msgid "Transaction ignored." +msgstr "Transaktion ignoriert." + +#: my_practice/views/bank_import_views.py:394 +#, python-format +msgid "Transaction linked to %(number)s (already paid)." +msgstr "Transaktion mit %(number)s verknüpft (bereits bezahlt)." + +#: my_practice/views/bank_import_views.py:399 +msgid "Transaction confirmed (invoice already paid)." +msgstr "Transaktion bestätigt (Rechnung bereits bezahlt)." + +#: my_practice/views/bank_import_views.py:406 +msgid "No invoice specified." +msgstr "Keine Rechnung angegeben." + +#: my_practice/views/bank_import_views.py:426 +#, python-format +msgid "Transaction automatically linked to %(number)s and marked as paid." +msgstr "Transaktion automatisch mit %(number)s verknüpft und als bezahlt markiert." + +#: my_practice/views/bank_import_views.py:435 +msgid "Error processing form." +msgstr "Fehler beim Verarbeiten des Formulars." + +#: my_practice/views/bank_import_views.py:440 +msgid "Please select at least one invoice." +msgstr "Bitte wählen Sie mindestens eine Rechnung aus." + +#: my_practice/views/bank_import_views.py:471 +#, python-format +msgid "Payment successfully assigned to invoice %(number)s." +msgstr "Zahlung erfolgreich der Rechnung %(number)s zugeordnet." + +#: my_practice/views/bank_import_views.py:478 +#, python-format +msgid "Payment successfully assigned to %(count)s invoices: %(numbers)s" +msgstr "Zahlung erfolgreich %(count)s Rechnungen zugeordnet: %(numbers)s" + +#: my_practice/views/bank_import_views.py:499 +#, python-format +msgid "📌 Alias '%(name)s' for %(code)s saved." +msgstr "📌 Alias '%(name)s' für %(code)s gespeichert." + +#: my_practice/views/bank_import_views.py:506 +#, python-format +msgid "Alias '%(name)s' for %(code)s saved." +msgstr "Alias '%(name)s' für %(code)s gespeichert." + +#: my_practice/views/bank_import_views.py:573 +#: my_practice/views/bank_import_views.py:654 +#: my_practice/views/bank_import_views.py:717 +#: my_practice/views/bank_import_views.py:790 +msgid "Please select at least one transaction." +msgstr "Bitte wähle mindestens eine Transaktion aus." + +#: my_practice/views/bank_import_views.py:582 +#: my_practice/views/bank_import_views.py:726 +msgid "No transactions found." +msgstr "Keine Transaktionen gefunden." + +#: my_practice/views/bank_import_views.py:643 +#, python-format +msgid "%(count)s transaction successfully grouped into expense: %(amount)s €" +msgid_plural "%(count)s transactions successfully grouped into expense: %(amount)s €" +msgstr[0] "%(count)s Transaktion erfolgreich zu Ausgabe zusammengefasst: %(amount)s €" +msgstr[1] "%(count)s Transaktionen erfolgreich zu Ausgabe zusammengefasst: %(amount)s €" + +#: my_practice/views/bank_import_views.py:780 +#, python-format +msgid "%(count)s transaction successfully grouped into withdrawal: %(amount)s €" +msgid_plural "%(count)s transactions successfully grouped into withdrawal: %(amount)s €" +msgstr[0] "%(count)s Transaktion erfolgreich zu Entnahme zusammengefasst: %(amount)s €" +msgstr[1] "%(count)s Transaktionen erfolgreich zu Entnahme zusammengefasst: %(amount)s €" + +#: my_practice/views/calendar_views.py:76 +msgid "✅ Google Calendar connected successfully!" +msgstr "✅ Google Calendar erfolgreich verbunden!" + +#: my_practice/views/calendar_views.py:80 +#, python-format +msgid "Authorization error: %(error)s" +msgstr "Fehler bei der Autorisierung: %(error)s" + +#: my_practice/views/calendar_views.py:88 +msgid "Please connect your Google Calendar first." +msgstr "Bitte verbinde zuerst deinen Google Calendar." + +#: my_practice/views/calendar_views.py:96 +msgid "No calendar named 'Praxis' found." +msgstr "Kein Kalender mit dem Namen 'Praxis' gefunden." + +#: my_practice/views/calendar_views.py:146 +#, python-format +msgid "Error loading calendar events: %(error)s" +msgstr "Fehler beim Laden der Kalender-Einträge: %(error)s" + +#: my_practice/views/calendar_views.py:160 +msgid "No events selected" +msgstr "Keine Events ausgewählt" + +#: my_practice/views/calendar_views.py:175 +msgid "Google Calendar not connected" +msgstr "Google Calendar nicht verbunden" + +#: my_practice/views/calendar_views.py:180 +msgid "Calendar 'Praxis' not found" +msgstr "Kalender 'Praxis' nicht gefunden" + +#: my_practice/views/calendar_views.py:188 +#, python-format +msgid "Error loading events: %(error)s" +msgstr "Fehler beim Laden der Events: %(error)s" + +#: my_practice/views/calendar_views.py:223 +#: my_practice/views/invoice_views.py:500 +#: my_practice/views/invoice_views.py:766 +#: my_practice/views/invoice_views.py:880 +msgid "No active practice found." +msgstr "Keine aktive Praxis gefunden." + +#: my_practice/views/calendar_views.py:316 +msgid "No events selected." +msgstr "Keine Termine ausgewählt." + +#: my_practice/views/calendar_views.py:413 +#: my_practice/views/calendar_views.py:446 +msgid "Event not found." +msgstr "Termin nicht gefunden." + +#: my_practice/views/calendar_views.py:459 +#, python-format +msgid "Event on %(date)s skipped." +msgstr "Termin am %(date)s übersprungen." + +#: my_practice/views/calendar_views.py:464 +msgid "Unknown action." +msgstr "Unbekannte Aktion." + +#: my_practice/views/calendar_views.py:468 +msgid "No client assigned to this event." +msgstr "Kein Klient für diesen Termin zugeordnet." + +#: my_practice/views/calendar_views.py:482 +msgid "No matching service type found." +msgstr "Kein passender Leistungstyp gefunden." + +#: my_practice/views/calendar_views.py:490 +#, python-format +msgid "No hourly rate set for %(code)s." +msgstr "Kein Stundensatz für %(code)s hinterlegt." + +#: my_practice/views/calendar_views.py:507 +#, python-format +msgid "Session on %(date)s is already billed." +msgstr "Sitzung am %(date)s ist bereits abgerechnet." + +#: my_practice/views/calendar_views.py:551 +#, python-format +msgid "Event on %(date)s added to invoice %(number)s." +msgstr "Termin am %(date)s zu Rechnung %(number)s hinzugefügt." + +#: my_practice/views/calendar_views.py:555 +#, python-format +msgid "Import error: %(error)s" +msgstr "Fehler beim Import: %(error)s" + #: my_practice/views/client_views.py:172 #, python-brace-format msgid "Client {obj.full_name} saved successfully!" @@ -597,12 +826,6 @@ msgstr[1] "%(count)s Sitzungen zu %(invoice)s hinzugefügt." msgid "No new sessions added (already billed?)." msgstr "Keine neuen Sitzungen hinzugefügt (bereits abgerechnet?)." -#: my_practice/views/invoice_views.py:500 -#: my_practice/views/invoice_views.py:766 -#: my_practice/views/invoice_views.py:880 -msgid "No active practice found." -msgstr "Keine aktive Praxis gefunden." - #: my_practice/views/invoice_views.py:540 msgid "Invoice {} with {} session created." msgid_plural "Invoice {} with {} sessions created." diff --git a/app/locale/en/LC_MESSAGES/django.po b/app/locale/en/LC_MESSAGES/django.po index e7c39ca..aa9d38c 100644 --- a/app/locale/en/LC_MESSAGES/django.po +++ b/app/locale/en/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: my-practice\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 13:27+0200\n" +"POT-Creation-Date: 2026-07-06 13:43+0200\n" "PO-Revision-Date: 2026-06-22 23:01+0200\n" "Language-Team: English\n" "Language: en\n" @@ -386,6 +386,235 @@ msgid_plural "💰 Revenue opportunity: %(n)s clients with sessions but no invoi msgstr[0] "" msgstr[1] "" +#: my_practice/views/bank_import_views.py:72 +#, python-format +msgid "%(count)s automatically matched" +msgstr "%(count)s automatically matched" + +#: my_practice/views/bank_import_views.py:73 +#, python-format +msgid "%(count)s require manual review" +msgstr "%(count)s require manual review" + +#: my_practice/views/bank_import_views.py:77 +#, python-format +msgid "%(count)s expenses detected automatically" +msgstr "%(count)s expenses detected automatically" + +#: my_practice/views/bank_import_views.py:79 +#, python-format +msgid "%(count)s ignored" +msgstr "%(count)s ignored" + +#: my_practice/views/bank_import_views.py:82 +#, python-format +msgid "Import complete: %(details)s." +msgstr "Import complete: %(details)s." + +#: my_practice/views/bank_import_views.py:220 +msgid "No transaction selected." +msgstr "No transaction selected." + +#: my_practice/views/bank_import_views.py:309 +#, python-format +msgid "Auto-ignored: invoice %(number)s already paid" +msgstr "Auto-ignored: invoice %(number)s already paid" + +#: my_practice/views/bank_import_views.py:319 +#, python-format +msgid "✅ %(count)s transaction with already-paid invoice ignored." +msgid_plural "✅ %(count)s transactions with already-paid invoices ignored." +msgstr[0] "✅ %(count)s transaction with already-paid invoice ignored." +msgstr[1] "✅ %(count)s transactions with already-paid invoices ignored." + +#: my_practice/views/bank_import_views.py:326 +msgid "No matching transactions to ignore." +msgstr "No matching transactions to ignore." + +#: my_practice/views/bank_import_views.py:339 +#, python-format +msgid "%(count)s expense ignored." +msgid_plural "%(count)s expenses ignored." +msgstr[0] "%(count)s expense ignored." +msgstr[1] "%(count)s expenses ignored." + +#: my_practice/views/bank_import_views.py:356 +#: my_practice/views/bank_import_views.py:669 +#: my_practice/views/bank_import_views.py:817 +#, python-format +msgid "%(count)s transaction ignored." +msgid_plural "%(count)s transactions ignored." +msgstr[0] "%(count)s transaction ignored." +msgstr[1] "%(count)s transactions ignored." + +#: my_practice/views/bank_import_views.py:370 +msgid "Transaction ignored." +msgstr "Transaction ignored." + +#: my_practice/views/bank_import_views.py:394 +#, python-format +msgid "Transaction linked to %(number)s (already paid)." +msgstr "Transaction linked to %(number)s (already paid)." + +#: my_practice/views/bank_import_views.py:399 +msgid "Transaction confirmed (invoice already paid)." +msgstr "Transaction confirmed (invoice already paid)." + +#: my_practice/views/bank_import_views.py:406 +msgid "No invoice specified." +msgstr "No invoice specified." + +#: my_practice/views/bank_import_views.py:426 +#, python-format +msgid "Transaction automatically linked to %(number)s and marked as paid." +msgstr "Transaction automatically linked to %(number)s and marked as paid." + +#: my_practice/views/bank_import_views.py:435 +msgid "Error processing form." +msgstr "Error processing form." + +#: my_practice/views/bank_import_views.py:440 +msgid "Please select at least one invoice." +msgstr "Please select at least one invoice." + +#: my_practice/views/bank_import_views.py:471 +#, python-format +msgid "Payment successfully assigned to invoice %(number)s." +msgstr "Payment successfully assigned to invoice %(number)s." + +#: my_practice/views/bank_import_views.py:478 +#, python-format +msgid "Payment successfully assigned to %(count)s invoices: %(numbers)s" +msgstr "Payment successfully assigned to %(count)s invoices: %(numbers)s" + +#: my_practice/views/bank_import_views.py:499 +#, python-format +msgid "📌 Alias '%(name)s' for %(code)s saved." +msgstr "📌 Alias '%(name)s' for %(code)s saved." + +#: my_practice/views/bank_import_views.py:506 +#, python-format +msgid "Alias '%(name)s' for %(code)s saved." +msgstr "Alias '%(name)s' for %(code)s saved." + +#: my_practice/views/bank_import_views.py:573 +#: my_practice/views/bank_import_views.py:654 +#: my_practice/views/bank_import_views.py:717 +#: my_practice/views/bank_import_views.py:790 +msgid "Please select at least one transaction." +msgstr "Please select at least one transaction." + +#: my_practice/views/bank_import_views.py:582 +#: my_practice/views/bank_import_views.py:726 +msgid "No transactions found." +msgstr "No transactions found." + +#: my_practice/views/bank_import_views.py:643 +#, python-format +msgid "%(count)s transaction successfully grouped into expense: %(amount)s €" +msgid_plural "%(count)s transactions successfully grouped into expense: %(amount)s €" +msgstr[0] "%(count)s transaction successfully grouped into expense: %(amount)s €" +msgstr[1] "%(count)s transactions successfully grouped into expense: %(amount)s €" + +#: my_practice/views/bank_import_views.py:780 +#, python-format +msgid "%(count)s transaction successfully grouped into withdrawal: %(amount)s €" +msgid_plural "%(count)s transactions successfully grouped into withdrawal: %(amount)s €" +msgstr[0] "%(count)s transaction successfully grouped into withdrawal: %(amount)s €" +msgstr[1] "%(count)s transactions successfully grouped into withdrawal: %(amount)s €" + +#: my_practice/views/calendar_views.py:76 +msgid "✅ Google Calendar connected successfully!" +msgstr "✅ Google Calendar connected successfully!" + +#: my_practice/views/calendar_views.py:80 +#, python-format +msgid "Authorization error: %(error)s" +msgstr "Authorization error: %(error)s" + +#: my_practice/views/calendar_views.py:88 +msgid "Please connect your Google Calendar first." +msgstr "Please connect your Google Calendar first." + +#: my_practice/views/calendar_views.py:96 +msgid "No calendar named 'Praxis' found." +msgstr "No calendar named 'Praxis' found." + +#: my_practice/views/calendar_views.py:146 +#, python-format +msgid "Error loading calendar events: %(error)s" +msgstr "Error loading calendar events: %(error)s" + +#: my_practice/views/calendar_views.py:160 +msgid "No events selected" +msgstr "No events selected" + +#: my_practice/views/calendar_views.py:175 +msgid "Google Calendar not connected" +msgstr "Google Calendar not connected" + +#: my_practice/views/calendar_views.py:180 +msgid "Calendar 'Praxis' not found" +msgstr "Calendar 'Praxis' not found" + +#: my_practice/views/calendar_views.py:188 +#, python-format +msgid "Error loading events: %(error)s" +msgstr "Error loading events: %(error)s" + +#: my_practice/views/calendar_views.py:223 +#: my_practice/views/invoice_views.py:500 +#: my_practice/views/invoice_views.py:766 +#: my_practice/views/invoice_views.py:880 +msgid "No active practice found." +msgstr "No active practice found." + +#: my_practice/views/calendar_views.py:316 +msgid "No events selected." +msgstr "No events selected." + +#: my_practice/views/calendar_views.py:413 +#: my_practice/views/calendar_views.py:446 +msgid "Event not found." +msgstr "Event not found." + +#: my_practice/views/calendar_views.py:459 +#, python-format +msgid "Event on %(date)s skipped." +msgstr "Event on %(date)s skipped." + +#: my_practice/views/calendar_views.py:464 +msgid "Unknown action." +msgstr "Unknown action." + +#: my_practice/views/calendar_views.py:468 +msgid "No client assigned to this event." +msgstr "No client assigned to this event." + +#: my_practice/views/calendar_views.py:482 +msgid "No matching service type found." +msgstr "No matching service type found." + +#: my_practice/views/calendar_views.py:490 +#, python-format +msgid "No hourly rate set for %(code)s." +msgstr "No hourly rate set for %(code)s." + +#: my_practice/views/calendar_views.py:507 +#, python-format +msgid "Session on %(date)s is already billed." +msgstr "Session on %(date)s is already billed." + +#: my_practice/views/calendar_views.py:551 +#, python-format +msgid "Event on %(date)s added to invoice %(number)s." +msgstr "Event on %(date)s added to invoice %(number)s." + +#: my_practice/views/calendar_views.py:555 +#, python-format +msgid "Import error: %(error)s" +msgstr "Import error: %(error)s" + #: my_practice/views/client_views.py:172 #, python-brace-format msgid "Client {obj.full_name} saved successfully!" @@ -616,14 +845,6 @@ msgstr[1] "" msgid "No new sessions added (already billed?)." msgstr "" -#: my_practice/views/invoice_views.py:500 -#: my_practice/views/invoice_views.py:766 -#: my_practice/views/invoice_views.py:880 -#, fuzzy -#| msgid "Create new practice" -msgid "No active practice found." -msgstr "Create new practice" - #: my_practice/views/invoice_views.py:540 msgid "Invoice {} with {} session created." msgid_plural "Invoice {} with {} sessions created." diff --git a/app/my_practice/views/bank_import_views.py b/app/my_practice/views/bank_import_views.py index dab017d..b7d4677 100644 --- a/app/my_practice/views/bank_import_views.py +++ b/app/my_practice/views/bank_import_views.py @@ -9,6 +9,8 @@ from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ +from django.utils.translation import ngettext from django.views.generic import FormView, ListView from django.views.generic.edit import FormMixin @@ -66,18 +68,18 @@ def form_valid(self, form): "errors": results["errors"], } - # Show summary message msg_parts = [ - f"{results['matched']} automatisch zugeordnet", - f"{results['unmatched']} benötigen manuelle Prüfung", + _("%(count)s automatically matched") % {"count": results["matched"]}, + _("%(count)s require manual review") % {"count": results["unmatched"]}, ] if results["needs_review"] > 0: - msg_parts.append(f"{results['needs_review']} Ausgaben automatisch erkannt") - msg_parts.append(f"{results['ignored']} ignoriert") - + msg_parts.append( + _("%(count)s expenses detected automatically") % {"count": results["needs_review"]} + ) + msg_parts.append(_("%(count)s ignored") % {"count": results["ignored"]}) messages.success( self.request, - f"Import abgeschlossen: {', '.join(msg_parts)}.", + _("Import complete: %(details)s.") % {"details": ", ".join(msg_parts)}, ) # Redirect to review page @@ -215,7 +217,7 @@ def post(self, request, *args, **kwargs): # Single-transaction actions transaction_id = request.POST.get("transaction_id") if not transaction_id: - messages.error(request, "Keine Transaktion ausgewählt.") + messages.error(request, _("No transaction selected.")) return redirect("bank_review") transaction = get_object_or_404( @@ -303,20 +305,24 @@ def _handle_bulk_ignore_paid(self, request): if paid_invoice and abs(paid_invoice.total - trans.amount) < Decimal("0.01"): trans.match_confidence = "ignored" trans.processed = True - trans.notes = ( - f"Auto-ignoriert: Rechnung {paid_invoice.invoice_number} bereits bezahlt" - ) + trans.notes = _("Auto-ignored: invoice %(number)s already paid") % { + "number": paid_invoice.invoice_number + } trans.save() ignored_count += 1 if ignored_count > 0: messages.success( request, - f"✅ {ignored_count} Transaktion{'en' if ignored_count != 1 else ''} " - "mit bereits bezahlten Rechnungen ignoriert.", + ngettext( + "✅ %(count)s transaction with already-paid invoice ignored.", + "✅ %(count)s transactions with already-paid invoices ignored.", + ignored_count, + ) + % {"count": ignored_count}, ) else: - messages.info(request, "Keine passenden Transaktionen zum Ignorieren gefunden.") + messages.info(request, _("No matching transactions to ignore.")) return redirect("bank_review") def _handle_ignore_all_expenses(self, request): @@ -326,7 +332,15 @@ def _handle_ignore_all_expenses(self, request): processed=False, amount__lt=0, ).update(match_confidence="ignored") - messages.success(request, f"{updated} Ausgaben ignoriert.") + messages.success( + request, + ngettext( + "%(count)s expense ignored.", + "%(count)s expenses ignored.", + updated, + ) + % {"count": updated}, + ) return redirect("bank_review") def _handle_ignore_all_unmatched(self, request): @@ -335,7 +349,15 @@ def _handle_ignore_all_unmatched(self, request): match_confidence="unmatched", processed=False, ).update(match_confidence="ignored") - messages.success(request, f"{updated} Transaktionen ignoriert.") + messages.success( + request, + ngettext( + "%(count)s transaction ignored.", + "%(count)s transactions ignored.", + updated, + ) + % {"count": updated}, + ) return redirect("bank_review") # ── single-transaction action handlers ─────────────────────────────────── @@ -344,7 +366,7 @@ def _handle_ignore(self, request, transaction, redirect_url): transaction.match_confidence = "ignored" transaction.processed = True transaction.save() - messages.success(request, "Transaktion wurde ignoriert.") + messages.success(request, _("Transaction ignored.")) return redirect(redirect_url) def _handle_confirm_paid(self, request, transaction, redirect_url): @@ -368,18 +390,17 @@ def _handle_confirm_paid(self, request, transaction, redirect_url): if invoice_id and invoice: messages.success( request, - f"Transaktion mit {invoice.invoice_number} verknüpft (bereits bezahlt).", + _("Transaction linked to %(number)s (already paid).") + % {"number": invoice.invoice_number}, ) else: - messages.success( - request, "Transaktion als bestätigt markiert (Rechnung bereits bezahlt)." - ) + messages.success(request, _("Transaction confirmed (invoice already paid).")) return redirect(redirect_url) def _handle_auto_match(self, request, transaction, redirect_url): invoice_id = request.POST.get("suggested_invoice_id") if not invoice_id: - messages.error(request, "Keine Rechnung angegeben.") + messages.error(request, _("No invoice specified.")) return redirect(redirect_url) invoice = get_object_or_404( @@ -399,7 +420,8 @@ def _handle_auto_match(self, request, transaction, redirect_url): messages.success( request, - f"Transaktion automatisch mit {invoice.invoice_number} verknüpft und als bezahlt markiert.", + _("Transaction automatically linked to %(number)s and marked as paid.") + % {"number": invoice.invoice_number}, ) return redirect(redirect_url) @@ -407,12 +429,12 @@ def _handle_match(self, request, transaction, redirect_url): form = self.get_form() if not form.is_valid(): - messages.error(request, "Fehler beim Verarbeiten des Formulars.") + messages.error(request, _("Error processing form.")) return redirect(redirect_url) invoices = form.cleaned_data["invoice"] if not invoices: - messages.error(request, "Bitte wählen Sie mindestens eine Rechnung aus.") + messages.error(request, _("Please select at least one invoice.")) return redirect(redirect_url) notes = form.cleaned_data.get("notes", "") @@ -443,13 +465,15 @@ def _handle_match(self, request, transaction, redirect_url): if len(invoice_list) == 1 and first_invoice: messages.success( request, - f"Zahlung erfolgreich der Rechnung {first_invoice.invoice_number} zugeordnet.", + _("Payment successfully assigned to invoice %(number)s.") + % {"number": first_invoice.invoice_number}, ) else: invoice_numbers = ", ".join(inv.invoice_number for inv in invoice_list) messages.success( request, - f"Zahlung erfolgreich {len(invoice_list)} Rechnungen zugeordnet: {invoice_numbers}", + _("Payment successfully assigned to %(count)s invoices: %(numbers)s") + % {"count": len(invoice_list), "numbers": invoice_numbers}, ) return redirect(redirect_url) @@ -467,14 +491,18 @@ def _maybe_create_alias(self, request, transaction, invoice, context: str) -> No if context == "confirm": notes = f"Auto-erstellt beim Bestätigen am {transaction.transaction_date}" - msg = ( - f"📌 Alias '{transaction.payer_name}' für {invoice.client.client_code} gespeichert." + messages.info( + request, + _("📌 Alias '%(name)s' for %(code)s saved.") + % {"name": transaction.payer_name, "code": invoice.client.client_code}, ) - messages.info(request, msg) else: notes = f"Erstellt beim Bank Import am {transaction.transaction_date}" - msg = f"Alias '{transaction.payer_name}' für {invoice.client.client_code} gespeichert." - messages.success(request, msg) + messages.success( + request, + _("Alias '%(name)s' for %(code)s saved.") + % {"name": transaction.payer_name, "code": invoice.client.client_code}, + ) ClientAlias.objects.create( client=invoice.client, @@ -539,7 +567,7 @@ def post(self, request, *args, **kwargs): # Get selected transaction IDs transaction_ids = request.POST.getlist("transactions") if not transaction_ids: - messages.error(request, "Bitte wähle mindestens eine Transaktion aus.") + messages.error(request, _("Please select at least one transaction.")) return redirect("bank_expense_review") transactions = BankTransaction.objects.filter( @@ -548,7 +576,7 @@ def post(self, request, *args, **kwargs): ) if not transactions.exists(): - messages.error(request, "Keine Transaktionen gefunden.") + messages.error(request, _("No transactions found.")) return redirect("bank_expense_review") # Get form data @@ -608,7 +636,12 @@ def post(self, request, *args, **kwargs): # Success message messages.success( request, - f"{len(transaction_ids)} Transaktion(en) erfolgreich zu Ausgabe zusammengefasst: {total_amount:.2f} €", + ngettext( + "%(count)s transaction successfully grouped into expense: %(amount)s €", + "%(count)s transactions successfully grouped into expense: %(amount)s €", + len(transaction_ids), + ) + % {"count": len(transaction_ids), "amount": f"{total_amount:.2f}"}, ) return redirect("bank_expense_review") @@ -616,7 +649,7 @@ def post(self, request, *args, **kwargs): # Mark selected transactions as ignored transaction_ids = request.POST.getlist("transactions") if not transaction_ids: - messages.error(request, "Bitte wähle mindestens eine Transaktion aus.") + messages.error(request, _("Please select at least one transaction.")) return redirect("bank_expense_review") count = BankTransaction.objects.filter( @@ -628,7 +661,15 @@ def post(self, request, *args, **kwargs): processed=True, ) - messages.success(request, f"{count} Transaktion(en) ignoriert.") + messages.success( + request, + ngettext( + "%(count)s transaction ignored.", + "%(count)s transactions ignored.", + count, + ) + % {"count": count}, + ) return redirect("bank_expense_review") return redirect("bank_expense_review") @@ -672,7 +713,7 @@ def post(self, request, *args, **kwargs): if action == "group": transaction_ids = request.POST.getlist("transactions") if not transaction_ids: - messages.error(request, "Bitte wähle mindestens eine Transaktion aus.") + messages.error(request, _("Please select at least one transaction.")) return redirect("bank_withdrawal_review") transactions = BankTransaction.objects.filter( @@ -681,7 +722,7 @@ def post(self, request, *args, **kwargs): ) if not transactions.exists(): - messages.error(request, "Keine Transaktionen gefunden.") + messages.error(request, _("No transactions found.")) return redirect("bank_withdrawal_review") category = request.POST.get("category", "salary") @@ -734,14 +775,19 @@ def post(self, request, *args, **kwargs): messages.success( request, - f"{len(transaction_ids)} Transaktion(en) erfolgreich zu Entnahme zusammengefasst: {total_amount:.2f} €", + ngettext( + "%(count)s transaction successfully grouped into withdrawal: %(amount)s €", + "%(count)s transactions successfully grouped into withdrawal: %(amount)s €", + len(transaction_ids), + ) + % {"count": len(transaction_ids), "amount": f"{total_amount:.2f}"}, ) return redirect("bank_withdrawal_review") elif action == "ignore": transaction_ids = request.POST.getlist("transactions") if not transaction_ids: - messages.error(request, "Bitte wähle mindestens eine Transaktion aus.") + messages.error(request, _("Please select at least one transaction.")) return redirect("bank_withdrawal_review") transactions_qs = BankTransaction.objects.filter( @@ -765,7 +811,15 @@ def post(self, request, *args, **kwargs): linked_withdrawal=None, ) - messages.success(request, f"{count} Transaktion(en) ignoriert.") + messages.success( + request, + ngettext( + "%(count)s transaction ignored.", + "%(count)s transactions ignored.", + count, + ) + % {"count": count}, + ) return redirect("bank_withdrawal_review") return redirect("bank_withdrawal_review") diff --git a/app/my_practice/views/calendar_views.py b/app/my_practice/views/calendar_views.py index dd9ddd2..53ed392 100644 --- a/app/my_practice/views/calendar_views.py +++ b/app/my_practice/views/calendar_views.py @@ -13,6 +13,7 @@ from django.shortcuts import redirect, render from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.decorators.http import require_POST from ..models import Client, Invoice, InvoiceItem, PendingCalendarEvent, ServiceType @@ -72,11 +73,11 @@ def calendar_oauth2callback(request: HttpRequest) -> HttpResponse: practice = getattr(request, "current_practice", None) GoogleCalendarOAuth.save_token(flow.credentials, practice=practice) - messages.success(request, "✅ Google Calendar erfolgreich verbunden!") + messages.success(request, _("✅ Google Calendar connected successfully!")) return redirect("calendar_import") except Exception as e: - messages.error(request, f"Fehler bei der Autorisierung: {str(e)}") + messages.error(request, _("Authorization error: %(error)s") % {"error": str(e)}) return redirect("dashboard") @@ -84,7 +85,7 @@ def calendar_import(request: HttpRequest) -> HttpResponse: """Show calendar import interface — list events from Google Calendar and allow import.""" service = GoogleCalendarOAuth.get_service() if not service: - messages.warning(request, "Bitte verbinde zuerst deinen Google Calendar.") + messages.warning(request, _("Please connect your Google Calendar first.")) return render(request, "my_practice/calendar_connect.html") start_date, end_date = parse_date_range(request) @@ -92,7 +93,7 @@ def calendar_import(request: HttpRequest) -> HttpResponse: try: praxis_calendar_id = find_calendar_by_name(service, "Praxis") if not praxis_calendar_id: - messages.warning(request, "Kein Kalender mit dem Namen 'Praxis' gefunden.") + messages.warning(request, _("No calendar named 'Praxis' found.")) return render( request, "my_practice/calendar_import.html", @@ -142,7 +143,7 @@ def calendar_import(request: HttpRequest) -> HttpResponse: return render(request, "my_practice/calendar_import.html", context) except Exception as e: - messages.error(request, f"Fehler beim Laden der Kalender-Einträge: {str(e)}") + messages.error(request, _("Error loading calendar events: %(error)s") % {"error": str(e)}) return redirect("dashboard") @@ -156,7 +157,7 @@ def calendar_import_events(request: HttpRequest) -> JsonResponse: data = json.loads(request.body) events_to_process = data.get("events", []) if not events_to_process: - return JsonResponse({"success": False, "error": "Keine Events ausgewählt"}, status=400) + return JsonResponse({"success": False, "error": _("No events selected")}, status=400) user_overrides = build_user_overrides(events_to_process) processor = CalendarImportProcessor(request) @@ -171,12 +172,12 @@ def calendar_import_events(request: HttpRequest) -> JsonResponse: service = GoogleCalendarOAuth.get_service() if not service: return JsonResponse( - {"success": False, "error": "Google Calendar nicht verbunden"}, status=401 + {"success": False, "error": _("Google Calendar not connected")}, status=401 ) praxis_calendar_id = find_calendar_by_name(service, "Praxis") if not praxis_calendar_id: return JsonResponse( - {"success": False, "error": "Kalender 'Praxis' nicht gefunden"}, status=404 + {"success": False, "error": _("Calendar 'Praxis' not found")}, status=404 ) try: parsed_events = processor.fetch_specific_events( @@ -184,7 +185,10 @@ def calendar_import_events(request: HttpRequest) -> JsonResponse: ) except Exception as e: return JsonResponse( - {"success": False, "error": f"Fehler beim Laden der Events: {str(e)}"}, + { + "success": False, + "error": _("Error loading events: %(error)s") % {"error": str(e)}, + }, status=500, ) @@ -219,7 +223,7 @@ def calendar_approval_queue(request: HttpRequest) -> HttpResponse: practice = getattr(request, "current_practice", None) if not practice: - messages.error(request, "Keine aktive Praxis gefunden.") + messages.error(request, _("No active practice found.")) return redirect("dashboard") # Subquery: does an InvoiceItem already exist for this client/date/duration? @@ -311,9 +315,7 @@ def calendar_queue_import(request: HttpRequest) -> JsonResponse: invoice_id = data.get("invoice_id") if not event_ids: - return JsonResponse( - {"success": False, "error": "Keine Termine ausgewählt."}, status=400 - ) + return JsonResponse({"success": False, "error": _("No events selected.")}, status=400) practice = getattr(request, "current_practice", None) events = PendingCalendarEvent.objects.filter( @@ -409,7 +411,7 @@ def calendar_queue_skip(request: HttpRequest, pk: int) -> JsonResponse: event.save(update_fields=["status"]) return JsonResponse({"success": True, "event_id": pk}) except PendingCalendarEvent.DoesNotExist: - return JsonResponse({"success": False, "error": "Termin nicht gefunden."}, status=404) + return JsonResponse({"success": False, "error": _("Event not found.")}, status=404) @require_POST @@ -442,7 +444,7 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: "matched_client", "suggested_service_type" ).get(pk=pk, practice=practice, status=PendingCalendarEvent.Status.PENDING) except PendingCalendarEvent.DoesNotExist: - messages.error(request, "Termin nicht gefunden.") + messages.error(request, _("Event not found.")) return redirect("client_list") client = event.matched_client @@ -454,16 +456,17 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: event.status = PendingCalendarEvent.Status.SKIPPED event.save(update_fields=["status"]) messages.success( - request, f"Termin am {event.event_date.strftime('%d.%m.%Y')} übersprungen." + request, + _("Event on %(date)s skipped.") % {"date": event.event_date.strftime("%d.%m.%Y")}, ) return redirect_target if action not in ("current_invoice", "new_invoice"): - messages.error(request, "Unbekannte Aktion.") + messages.error(request, _("Unknown action.")) return redirect_target if not client: - messages.error(request, "Kein Klient für diesen Termin zugeordnet.") + messages.error(request, _("No client assigned to this event.")) return redirect("calendar_approval_queue") # Resolve service type — prefer calendar match, fall back to default 60min @@ -477,13 +480,16 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: ).first() if service_type is None: - messages.error(request, "Kein passender Leistungstyp gefunden.") + messages.error(request, _("No matching service type found.")) return redirect_target rate = resolve_session_rate(client, service_type) if rate == Decimal("0") and service_type.code != "therapy_free": - messages.error(request, f"Kein Stundensatz für {client.client_code} hinterlegt.") + messages.error( + request, + _("No hourly rate set for %(code)s.") % {"code": client.client_code}, + ) return redirect_target # Guard against duplicates — exclude cancelled invoices so re-import after @@ -498,7 +504,9 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: .exists() ): messages.warning( - request, f"Sitzung am {event.event_date.strftime('%d.%m.%Y')} ist bereits abgerechnet." + request, + _("Session on %(date)s is already billed.") + % {"date": event.event_date.strftime("%d.%m.%Y")}, ) return redirect_target @@ -517,7 +525,7 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: try: with transaction.atomic(): - session, _ = Session.objects.get_or_create( + session, _created = Session.objects.get_or_create( client=client, session_date=event.event_date, session_time=event.event_time, @@ -541,9 +549,10 @@ def calendar_event_quick_action(request: HttpRequest, pk: int) -> HttpResponse: messages.success( request, - f"Termin am {event.event_date.strftime('%d.%m.%Y')} zu Rechnung {invoice.invoice_number} hinzugefügt.", + _("Event on %(date)s added to invoice %(number)s.") + % {"date": event.event_date.strftime("%d.%m.%Y"), "number": invoice.invoice_number}, ) except Exception as e: - messages.error(request, f"Fehler beim Import: {e}") + messages.error(request, _("Import error: %(error)s") % {"error": e}) return redirect_target