diff --git a/app/locale/de/LC_MESSAGES/django.po b/app/locale/de/LC_MESSAGES/django.po index 2f5da67..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 09:24+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" @@ -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" @@ -387,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!" @@ -583,58 +811,52 @@ 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 -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 +896,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 +1509,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 +1526,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 +1568,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 +1582,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 +1657,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 +1677,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 +1688,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 +1724,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 +1762,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..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 09:13+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" @@ -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" @@ -387,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!" @@ -600,66 +828,58 @@ 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 -#, 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 +919,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 +1574,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 +1591,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 +1639,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 +1655,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 +1730,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 +1752,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 +1763,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 +1799,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 +1837,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/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..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, @@ -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", @@ -90,6 +100,7 @@ "ClientDetailContextBuilder", "DashboardContextAssembler", "TaxYearContextBuilder", + "available_data_years", "FinancialListContextBuilder", "InvoiceFilterHelper", "AgendaWidgetBuilder", 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/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/bank_import_views.py b/app/my_practice/views/bank_import_views.py index 4aba51d..b7d4677 100644 --- a/app/my_practice/views/bank_import_views.py +++ b/app/my_practice/views/bank_import_views.py @@ -4,9 +4,13 @@ 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 +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 @@ -64,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 @@ -108,23 +112,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 +127,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 @@ -222,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( @@ -245,6 +240,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,41 +290,39 @@ 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 = ( - 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): @@ -317,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): @@ -326,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 ─────────────────────────────────── @@ -335,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): @@ -359,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( @@ -390,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) @@ -398,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", "") @@ -434,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) @@ -458,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, @@ -530,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( @@ -539,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 @@ -599,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") @@ -607,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( @@ -619,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") @@ -663,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( @@ -672,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") @@ -725,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( @@ -756,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 61fc995..53ed392 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 @@ -12,9 +13,11 @@ 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 +from ..utils.billing_helpers import resolve_session_rate from ..utils.calendar_event_processor import ( CalendarImportProcessor, build_user_overrides, @@ -70,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") @@ -82,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) @@ -90,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", @@ -140,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") @@ -154,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) @@ -169,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( @@ -182,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, ) @@ -217,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? @@ -309,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( @@ -407,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 @@ -422,7 +426,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 @@ -441,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 @@ -453,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 @@ -476,29 +480,33 @@ 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 - # 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.") + messages.error( + request, + _("No hourly rate set for %(code)s.") % {"code": client.client_code}, + ) 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." + 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, @@ -529,8 +537,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 @@ -540,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 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") 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, 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 %} -
- -
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 -