Finanz- & Rechnungsmodul - Code-Architektur
Modulstruktur
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
workmate_os/
├── backend/app/modules/backoffice/
│ ├── invoices/
│ │ ├── __init__.py
│ │ ├── models.py # SQLAlchemy ORM-Modelle
│ │ │ ├── Invoice # Haupt-Rechnungsmodell
│ │ │ ├── InvoiceLineItem # Positionen
│ │ │ ├── Payment # Zahlungseingänge
│ │ │ └── NumberSequence # Rechnungsnummerierung
│ │ ├── schemas.py # Pydantic-Validierung
│ │ │ ├── InvoiceStatus enum
│ │ │ ├── PaymentMethod enum
│ │ │ ├── DocumentType enum
│ │ │ ├── InvoiceCreate/Update/Response
│ │ │ ├── PaymentCreate/Update/Response
│ │ │ ├── InvoiceLineItemCreate/Response
│ │ │ ├── InvoiceStatisticsResponse
│ │ │ └── BulkStatusUpdate
│ │ ├── routes.py # FastAPI-Endpoints
│ │ │ ├── list_invoices() # GET /
│ │ │ ├── get_statistics() # GET /statistics
│ │ │ ├── get_invoice() # GET /{id}
│ │ │ ├── create_invoice() # POST /
│ │ │ ├── update_invoice() # PATCH /{id}
│ │ │ ├── delete_invoice() # DELETE /{id}
│ │ │ ├── download_invoice_pdf()# GET /{id}/pdf
│ │ │ ├── add_payment() # POST /{id}/payments
│ │ │ ├── bulk_update_status() # POST /bulk/status-update
│ │ │ └── ... (weitere Endpoints)
│ │ ├── crud.py # Datenbankoperationen
│ │ │ ├── get_invoices() # Lesen mit Filtern
│ │ │ ├── create_invoice() # Erstellen mit Positionen
│ │ │ ├── update_invoice() # Update Status/Notizen
│ │ │ ├── delete_invoice() # Kaskadierendes Löschen
│ │ │ ├── recalculate_invoice_totals()
│ │ │ ├── get_invoice_statistics()
│ │ │ ├── _generate_invoice_number()
│ │ │ ├── _generate_next_number()
│ │ │ ├── _validate_customer_exists()
│ │ │ ├── _validate_invoice_number_unique()
│ │ │ └── _generate_and_save_pdf()
│ │ ├── payments_crud.py # Zahlungsspezifische Operationen
│ │ │ ├── create_payment() # Erstellen mit Validierung
│ │ │ ├── get_payment() # Einzelne Zahlung
│ │ │ ├── get_payments() # Zahlungen auflisten
│ │ │ ├── update_payment() # Zahlung aktualisieren
│ │ │ └── delete_payment() # Zahlung löschen
│ │ ├── pdf_generator.py # PDF-Generierung
│ │ │ ├── generate_invoice_pdf()
│ │ │ ├── format_eur() # Deutsche Währungsformatierung
│ │ │ ├── draw_logo_watermark()
│ │ │ ├── DOCUMENT_TYPES # Vorlagenkonfiguration
│ │ │ ├── COMPANY_* # Firmenkonstanten
│ │ │ ├── BANK_* # Bankkonstanten
│ │ │ └── ... (Rendering-Funktionen)
│ │ └── templates/ # (Leer, für Zukunft)
│ │
│ └── finance/
│ ├── __init__.py
│ ├── models.py # SQLAlchemy ORM-Modelle
│ │ └── Expense # Ausgaben-/Kostenverfolgung
│ ├── schemas.py # Pydantic-Validierung
│ │ ├── ExpenseCreate/Update/Read
│ │ ├── ExpenseListResponse
│ │ ├── ExpenseKpiResponse
│ │ └── ExpenseCategory enum
│ ├── router.py # FastAPI-Endpoints
│ │ ├── create_expense_endpoint() # POST /expenses
│ │ ├── list_expenses_endpoint() # GET /expenses
│ │ ├── get_expense_endpoint() # GET /expenses/{id}
│ │ ├── update_expense_endpoint() # PATCH /expenses/{id}
│ │ ├── delete_expense_endpoint() # DELETE /expenses/{id}
│ │ └── get_expense_kpis_endpoint() # GET /kpis/expenses
│ └── crud.py (service.py) # Datenbankoperationen
│ ├── create_expense()
│ ├── get_expense()
│ ├── list_expenses()
│ ├── update_expense()
│ ├── delete_expense()
│ └── get_expense_kpis()
│
├── backend/alembic/versions/
│ ├── 2025_10_24_1224-..._fix_invoice_expense_relationship.py
│ ├── 2025_10_24_1331-..._add_invoices_and_payments_tables.py
│ └── 2025_11_19_1707-..._add_document_type_to_invoices.py
│
├── backend/tests/
│ └── test_invoice.py # Integrationstests
│
└── backend/app/main.py # Router-Registrierung
├── app.include_router(invoices_router, ...)
└── app.include_router(finance_routes.router, ...)
Datenfluss-Diagramm
Rechnungserstellungs-Ablauf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Benutzeranfrage (API)
↓
routes.py::create_invoice()
↓
[Validierung]
├─ Kunde existiert?
└─ Projekt existiert?
↓
crud.py::create_invoice()
├─ Rechnungsnummer generieren (NumberSequence)
├─ Invoice-Objekt erstellen
├─ InvoiceLineItem-Objekte erstellen
├─ recalculate_totals() auf Invoice
├─ In Datenbank speichern (commit)
└─ Optional: PDF generieren
└─ pdf_generator.py::generate_invoice_pdf()
↓
Antwort (InvoiceResponse-Schema)
Zahlungsablauf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Benutzeranfrage (API)
↓
routes.py::add_payment()
↓
payments_crud.py::create_payment()
├─ Rechnung existiert?
├─ Betrag <= outstanding_amount?
├─ Payment-Objekt erstellen
├─ In Datenbank speichern
└─ Rechnungsstatus aktualisieren
└─ Invoice.update_status_from_payments()
↓
SQLAlchemy Event (after_insert)
└─ Rechnungsstatus automatisch aktualisieren
↓
Antwort (PaymentResponse-Schema)
Listen- & Filterablauf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Benutzeranfrage (API)
↓
routes.py::list_invoices()
│
└─ Query-Filter aufbauen:
├─ Status-Filter?
├─ customer_id-Filter?
├─ project_id-Filter?
├─ date_from/date_to?
└─ skip/limit Pagination?
↓
crud.py::get_invoices()
├─ Filter auf Query anwenden
├─ Nach issued_date desc sortieren
├─ Offset + Limit
├─ Beziehungen Eager-laden (customer, line_items, payments)
└─ Query ausführen
↓
Antwort (InvoiceListResponse-Schema)
Datenbankschema-Beziehungen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
CREATE TABLE invoices (
id UUID PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE,
customer_id UUID FOREIGN KEY → customers.id,
project_id UUID FOREIGN KEY → projects.id,
total DECIMAL(10,2),
subtotal DECIMAL(10,2),
tax_amount DECIMAL(10,2),
status VARCHAR(50),
document_type VARCHAR(50),
issued_date DATE,
due_date DATE,
pdf_path TEXT,
notes TEXT,
terms TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
CHECK (total >= 0),
CHECK (subtotal >= 0),
CHECK (status IN (...)),
INDEX (customer_id),
INDEX (project_id),
INDEX (status),
INDEX (invoice_number)
);
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY,
invoice_id UUID FOREIGN KEY → invoices.id CASCADE,
position INT,
description TEXT,
quantity DECIMAL(10,2),
unit VARCHAR(50),
unit_price DECIMAL(10,2),
tax_rate DECIMAL(5,2),
discount_percent DECIMAL(5,2),
CHECK (quantity > 0),
INDEX (invoice_id, position)
);
CREATE TABLE payments (
id UUID PRIMARY KEY,
invoice_id UUID FOREIGN KEY → invoices.id CASCADE,
amount DECIMAL(10,2),
payment_date DATE,
method VARCHAR(50),
reference VARCHAR(100),
note TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
CHECK (amount > 0),
INDEX (invoice_id),
INDEX (payment_date),
INDEX (method)
);
CREATE TABLE number_sequences (
id UUID PRIMARY KEY,
doc_type VARCHAR(50),
year INT,
current_number INT,
UNIQUE (doc_type, year)
);
CREATE TABLE expenses (
id UUID PRIMARY KEY,
title VARCHAR(50),
category VARCHAR(50),
amount DECIMAL(10,2),
description TEXT,
receipt_path TEXT,
note TEXT,
is_billable BOOLEAN,
project_id UUID FOREIGN KEY → projects.id,
invoice_id UUID FOREIGN KEY → invoices.id,
created_at TIMESTAMP,
updated_at TIMESTAMP,
CHECK (amount > 0),
INDEX (project_id),
INDEX (invoice_id),
INDEX (category)
);
Validierungsebenen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌─────────────────────────────────────────┐
│ API-Anfrage │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ Pydantic-Schema-Validierung │
│ (schemas.py) │
│ ├─ Typprüfung │
│ ├─ Decimal-Konvertierung │
│ ├─ Datumsvalidierung │
│ ├─ Enum-Validierung │
│ └─ Min/Max-Constraints │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ CRUD-Validierung (crud.py) │
│ ├─ Kunde existiert? │
│ ├─ Projekt existiert? │
│ ├─ Rechnungsnummer eindeutig? │
│ ├─ Zahlungsbetrag <= offen? │
│ └─ Status-Übergang gültig? │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ Datenbank-Constraints │
│ ├─ CHECK-Constraints │
│ ├─ FOREIGN KEY-Constraints │
│ ├─ UNIQUE-Constraints │
│ └─ NOT NULL-Constraints │
└────────────┬────────────────────────────┘
│
(Erfolg oder 400/422/409 Fehler)
Modell-Eigenschaften & Berechnete Felder
Invoice-Modell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Datenbankfelder
- id: UUID
- invoice_number: str
- customer_id: UUID
- project_id: Optional[UUID]
- total: Decimal
- subtotal: Decimal
- tax_amount: Decimal
- status: str
- document_type: str
- issued_date: Optional[date]
- due_date: Optional[date]
- pdf_path: Optional[str]
- notes: Optional[str]
- terms: Optional[str]
# Berechnete Eigenschaften (Echtzeit-Berechnungen)
- paid_amount: Decimal # SUM(payments.amount)
- outstanding_amount: Decimal # total - paid_amount
- is_paid: bool # outstanding_amount <= 0
- is_overdue: bool # heute > due_date UND nicht bezahlt
- days_until_due: Optional[int] # (due_date - heute).days
- payment_rate: float # (paid_amount / total) * 100
# Methoden
- recalculate_totals() # Neu berechnen aus Positionen
- update_status_from_payments() # Status automatisch aktualisieren
InvoiceLineItem-Modell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Datenbankfelder
- id: UUID
- invoice_id: UUID
- position: int
- description: str
- quantity: Decimal
- unit: str
- unit_price: Decimal
- tax_rate: Decimal
- discount_percent: Decimal
# Berechnete Eigenschaften
- subtotal: Decimal # quantity * unit_price
- discount_amount: Decimal # subtotal * (discount_percent / 100)
- subtotal_after_discount: Decimal # subtotal - discount_amount
- tax_amount: Decimal # subtotal_after_discount * (tax_rate / 100)
- total: Decimal # subtotal_after_discount + tax_amount
Expense-Modell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Datenbankfelder
- id: UUID
- title: str
- category: str (enum)
- amount: Decimal
- description: str
- receipt_path: Optional[str]
- note: Optional[str]
- is_billable: bool
- project_id: Optional[UUID]
- invoice_id: Optional[UUID]
# Berechnete Eigenschaften
- is_invoiced: bool # invoice_id is not None
API-Endpoint-Matrix
| Methode | Pfad | Handler | Zweck |
|---|---|---|---|
| GET | /invoices/ |
list_invoices() |
Liste mit Filtern/Pagination |
| GET | /invoices/statistics |
get_statistics() |
KPI-Daten |
| GET | /invoices/{id} |
get_invoice() |
Einzelne abrufen |
| GET | /invoices/by-number/{num} |
get_invoice_by_number() |
Nach Nummer |
| POST | /invoices/ |
create_invoice() |
Neue erstellen |
| PATCH | /invoices/{id} |
update_invoice() |
Aktualisieren |
| PATCH | /invoices/{id}/status |
update_invoice_status() |
Nur Status |
| POST | /invoices/{id}/recalculate |
recalculate_totals() |
Summen neu berechnen |
| DELETE | /invoices/{id} |
delete_invoice() |
Löschen |
| GET | /invoices/{id}/pdf |
download_invoice_pdf() |
PDF herunterladen |
| POST | /invoices/{id}/regenerate-pdf |
regenerate_pdf() |
PDF neu generieren |
| POST | /invoices/bulk/status-update |
bulk_update_status() |
Bulk-Update |
| POST | /invoices/{id}/payments |
add_payment() |
Zahlung erstellen |
| GET | /invoices/{id}/payments |
list_invoice_payments() |
Zahlungen auflisten |
| GET | /invoices/payments/{id} |
get_payment() |
Zahlung abrufen |
| PATCH | /invoices/payments/{id} |
update_payment() |
Zahlung aktualisieren |
| DELETE | /invoices/payments/{id} |
delete_payment() |
Zahlung löschen |
| POST | /finance/expenses |
create_expense_endpoint() |
Erstellen |
| GET | /finance/expenses |
list_expenses_endpoint() |
Liste |
| GET | /finance/expenses/{id} |
get_expense_endpoint() |
Einzelne abrufen |
| PATCH | /finance/expenses/{id} |
update_expense_endpoint() |
Aktualisieren |
| DELETE | /finance/expenses/{id} |
delete_expense_endpoint() |
Löschen |
| GET | /finance/kpis/expenses |
get_expense_kpis_endpoint() |
KPI-Daten |
Fehlerbehandlungs-Strategie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# CRUD-Funktionen verwenden try/except-Muster:
try:
# Validierung
if not customer_exists(customer_id):
raise HTTPException(404, "Kunde nicht gefunden")
# Datenbankoperation
invoice = models.Invoice(...)
db.add(invoice)
db.commit()
db.refresh(invoice)
except HTTPException:
db.rollback()
raise # HTTP-Exceptions erneut werfen
except Exception as e:
db.rollback()
raise HTTPException(500, f"Fehlgeschlagen: {str(e)}")
Test-Strategie
Unit-Tests (Geplant)
- Modell-Validierung
- Eigenschaftsberechnungen
- CRUD-Operationen
- PDF-Generierung
Integrationstests (Aktuell)
test_invoice.py: End-to-End-API-Tests- Rechnungserstellung
- PDF-Generierung
- Zahlungsablauf
- Status-Updates
Manuelles Testen
- API-Endpoints via curl/Postman
- Visuelle PDF-Inspektion
- Datenbank-Abfragen
Performance-Überlegungen
- Eager Loading: Beziehungen mit
selectinload()geladen um N+1 zu vermeiden - Indizierung: Alle FK- und Filterspalten indiziert
- Pagination: Begrenzt auf max. 500 Einträge pro Anfrage
- Query-Optimierung: Spezifische SELECT-Abfragen, keine vollständigen Objekt-Loads
- PDF-Generierung: Optionale Background-Task-Unterstützung
- Decimal-Arithmetik: Verwendet Decimal-Typ für Genauigkeit (nicht float)
- Atomare Zähler: FOR UPDATE Sperre bei Nummernsequenzen
Sicherheits-Überlegungen
- Validierung: Alle Eingaben validiert (Schema + CRUD)
- Typ-Sicherheit: Decimal/UUID-Typen durchgehend verwendet
- SQL-Injection: ORM-Schutz via parametrisierte Abfragen
- Kaskadierendes Löschen: Ordentlich konfiguriert zur Wahrung referenzieller Integrität
- Dateipfade: PDFs serverseitig gespeichert, nicht unter Benutzerkontrolle
- Constraints: Constraints auf Datenbankebene durchgesetzt
Deployment-Hinweise
Erforderliche Tabellen
- invoices
- invoice_line_items
- payments
- number_sequences
- expenses
Erforderliche Migrationen
- 2025_10_24_1224-…_fix_invoice_expense_relationship.py
- 2025_10_24_1331-…_add_invoices_and_payments_tables.py
- 2025_11_19_1707-…_add_document_type_to_invoices.py
Dateisystem
- PDF-Speicher:
/root/workmate_os_uploads/invoices/ - Schreibberechtigungen sicherstellen
- Backup-Strategie für PDFs
Konfiguration
- Firmendetails in
pdf_generator.py(fest kodiert) - Dokumentvorlagen in DOCUMENT_TYPES dict
- Bankdetails in BANK_*-Konstanten
- Diese vor Produktions-Deployment aktualisieren