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 %} - -
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 %} - -{{ log.summary }}
{% endif %} - {% if log.content %}{% trans "No entries yet. Session logs are created when adding new entries." %}
- {% endif %} -{% trans "No supervision topics for this client yet." %}
- {% endif %} -