Phase 2: Buchungen, Kontoblatt und QIF-Buchungsimport
Der Plan ging davon aus, Umbuchungen stünden zweimal im Export und müssten zusammengeführt werden. Der Vollexport zeigt das Gegenteil: Eine Umbuchung steht einmal, in dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch L[Zielkonto] benannt und muss beim Import erzeugt werden. Von 965 Umbuchungen hat keine ein zweites Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte Kontosalden auf den Cent. Datenmodell: Migration 003 mit buchungen, buchung_splits und buchungsadressen. Eine Umbuchung ist ein Datensatz mit gegenkonto_id, kein gespiegeltes Paar - zwei Zeilen laufen früher oder später auseinander. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen. Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen. Konten führen zusätzlich in_summe, die Sigma-Spalte der Kontenübersicht, und saldo_online für Phase 5. Oberfläche: Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich, Volltextsuche und Seitenblättern. Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen, mit Splitzeilen und Restbetragsanzeige. Import: QifBuchungsImport arbeitet mit Stichtag. Alles davor wird je Konto zum Anfangssaldo aufsummiert, Gegenbuchungen eingerechnet; alles danach bis zum Importtag kommt einzeln in die Datenbank. Spätere Buchungen sind Vorausberechnungen des FinanzManagers und bleiben draußen. Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto zu Buche. Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35 Konten verrechnet. Behoben: Die confirm()-Rückfragen vor dem Löschen waren wirkungslos. public/static/app.js brachte den Mechanismus mit, war aber nie im Layout eingebunden; die ersatzweise gesetzten onsubmit-Attribute verstoßen gegen die eigene Content-Security-Policy und wurden vom Browser verworfen - gelöscht wurde ohne Rückfrage. Ein E2E-Test besteht jetzt auf dem Dialog. Tests: 307 PHP-Prüfungen und 21 Playwright-Tests, alle grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6d3ac149f3
commit
1dbc848cd1
32 changed files with 3008 additions and 115 deletions
53
Changes.md
53
Changes.md
|
|
@ -1,5 +1,58 @@
|
|||
# Änderungen
|
||||
|
||||
## Phase 2 – Buchungen, Kontoblatt und Buchungsimport (10.08.2026)
|
||||
|
||||
**Erkenntnis aus dem Vollexport**
|
||||
|
||||
Der Plan ging davon aus, Umbuchungen stünden zweimal in der Datei und müssten
|
||||
zusammengeführt werden. Das Gegenteil ist der Fall: Eine Umbuchung steht **einmal**, in
|
||||
dem Konto, in dem sie erfasst wurde; die Gegenseite ist nur durch `L[Zielkonto]` benannt
|
||||
und muss beim Import erzeugt werden. Von 965 Umbuchungen im Export hat keine ein zweites
|
||||
Vorkommen. Mit erzeugter Gegenseite stimmen acht gegen den FinanzManager geprüfte
|
||||
Kontosalden auf den Cent.
|
||||
|
||||
**Datenmodell**
|
||||
- Migration `003_buchungen.sql`: `buchungen`, `buchung_splits`, `buchungsadressen`
|
||||
- Eine Umbuchung ist ein Datensatz mit `gegenkonto_id`, kein gespiegeltes Paar – das
|
||||
Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen
|
||||
- Auch einzelne Splitzeilen dürfen ein Gegenkonto tragen
|
||||
- Konten führen `in_summe` (die Σ-Spalte des FinanzManagers) sowie `saldo_online`
|
||||
|
||||
**Kontoblatt und Buchungsmaske**
|
||||
- Kontoblatt mit laufendem Saldo, Filter auf Zeitraum, Kategorie, Klasse und Abgleich,
|
||||
Volltextsuche und Seitenblättern
|
||||
- Buchungsmaske für Einnahme, Ausgabe und Umbuchung in beide Richtungen; Splitzeilen mit
|
||||
Restbetragsanzeige, geprüft auch serverseitig
|
||||
- Aktueller und erwarteter Saldo; in die Summen zählen nur Konten mit Σ-Kennzeichen
|
||||
|
||||
**QIF-Buchungsimport**
|
||||
- `QifBuchungsImport` mit Stichtag: alles davor wird je Konto zum Anfangssaldo
|
||||
aufsummiert – einschließlich der erzeugten Gegenbuchungen –, alles danach importiert
|
||||
- Buchungen mit Datum nach dem Importtag gelten als Vorausberechnung und bleiben draußen
|
||||
- Eröffnungsbuchungen (`L[eigenes Konto]`) werden nicht zu Umbuchungen
|
||||
- Wertpapiergeschäfte schlagen vorerst nur mit ihrem Geldfluss auf dem Verrechnungskonto
|
||||
zu Buche; Bestände folgen in Phase 6
|
||||
- Idempotent über einen Fingerabdruck je Buchung, der auch echte Doppelbuchungen am
|
||||
selben Tag auseinanderhält
|
||||
- Am Vollexport geprüft: 833 Buchungen ab 01.01.2026, 7946 in die Anfangssalden von 35
|
||||
Konten verrechnet, eine vorausberechnete verworfen
|
||||
|
||||
**Behoben**
|
||||
- Die `confirm()`-Rückfragen vor dem Löschen waren wirkungslos. `public/static/app.js`
|
||||
brachte den Mechanismus (`data-bestaetigen`) von Anfang an mit, war aber nie im Layout
|
||||
eingebunden; ersatzweise standen Inline-Handler (`onsubmit`) im HTML, und die verstoßen
|
||||
gegen die eigene Content-Security-Policy (`script-src 'self'`) – der Browser verwarf
|
||||
sie stillschweigend, gelöscht wurde ohne Rückfrage. Jetzt ist das Skript eingebunden,
|
||||
die Inline-Handler sind raus, und ein E2E-Test besteht auf dem Dialog.
|
||||
|
||||
**Tests**
|
||||
- `09_BuchungenTest` – Umbuchungen mit zwei Sichten, Splitzeilen samt Summenprüfung,
|
||||
Splitzeile mit Gegenkonto, aktueller und erwarteter Saldo, Σ-Kennzeichen, Filter
|
||||
- `10_QifBuchungsImportTest` – Stichtag, Anfangssalden aus der Vorgeschichte, erzeugte
|
||||
Gegenbuchung, Eröffnungsbuchung, Zukunftsfilter, zweiter Lauf ohne Wirkung
|
||||
- E2E `buchungen.spec.js` – Ausgabe, Umbuchung auf beiden Kontoblättern, Splitbuchung,
|
||||
abgewiesene Splitsumme, Suche, Löschbestätigung
|
||||
|
||||
## Phase 1 – Stammdaten und QIF-Stammdatenimport (09.08.2026)
|
||||
|
||||
**Datenmodell**
|
||||
|
|
|
|||
70
PLAN.md
70
PLAN.md
|
|
@ -29,8 +29,8 @@ Randbedingungen aus der Aufgabenstellung:
|
|||
|---|---|
|
||||
| `FELIX2018.QDF` ist **vollständig verschlüsselt** (Entropie ≈ 8 bit/Byte über alle 258 Blöcke, keine Klartext-Treffer für Kategorie- oder Kontonamen) | Aus der QDF ist nichts zu holen. Datenquelle ist der **QIF-Export**. |
|
||||
| `FELIX2018.QSD` enthält im Klartext nur Bankleitzahlen, Spaltenlayouts und Berichtsnamen | Nur als Referenz für Berichtsdefinitionen brauchbar, keine Stammdaten. |
|
||||
| `Export-Migration-Beispiel.QIF` (342 KB, ISO-8859-1, CRLF) enthält alles Nötige: 17 Klassen, 105 Kategorien, 37 Konten, 572 Bank-, 154 Sonstige-, 16 Wertpapierbuchungen, 579 Buchungsvorlagen | **Der QIF-Import ist der Migrationsweg.** |
|
||||
| Der Beispielexport umfasst nur 02.01.–07.08.2026 und enthält **keine Eröffnungssalden** | Für die echte Migration braucht es einen Vollexport über alle Jahre; Anfangssalden werden je Konto aus der Kontenübersicht ergänzt. |
|
||||
| `Export-Migration-Beispiel.QIF` (1,8 MB, ISO-8859-1, CRLF) enthält alles Nötige: 17 Klassen, 105 Kategorien, 37 Konten, 8780 Buchungen von 2017 bis 2026, 172 Wertpapierbuchungen, 579 Buchungsvorlagen | **Der QIF-Import ist der Migrationsweg.** |
|
||||
| Der Vollexport reicht bis 2017 zurück; gebraucht wird nur der Zeitraum ab 2026 | **Stichtagsimport**: alles davor wird je Konto zum Anfangssaldo verrechnet (siehe Kapitel 4). Eröffnungssalden stehen als Buchung „Eröffnungssaldo“ mit `L[eigenes Konto]` in der Datei. |
|
||||
| **Budgets sind im QIF nicht enthalten** | Budgetdaten müssen separat aus dem Budgetplaner exportiert (Export-Symbol oben rechts) oder einmalig neu erfasst werden. |
|
||||
| `nemiah/php-fints` hängt nur an `psr/log` | Sehr schlank — als Fork gut beherrschbar. |
|
||||
|
||||
|
|
@ -167,16 +167,43 @@ Das Lexware-QIF ist erweitertes Quicken-QIF. Fallstricke, die der Parser beherrs
|
|||
Umbuchung: `L[DKB Girokonto alt]`, ebenfalls mit optionaler Klasse.
|
||||
6. **Splitbuchungen** als Tripel `S` (Kategorie), `E` (Notiz), `$` (Betrag) — mehrfach je
|
||||
Buchung.
|
||||
7. **Umbuchungen erscheinen doppelt** (einmal je beteiligtem Konto) und müssen zu *einer*
|
||||
Buchung zusammengeführt werden — sonst zählt jeder Übertrag zweimal.
|
||||
7. **Umbuchungen stehen nur einmal in der Datei** — in dem Konto, in dem sie erfasst
|
||||
wurden. Die Gegenseite ist allein durch `L[Zielkonto]` benannt und **muss beim Import
|
||||
erzeugt werden**; sonst fehlt sie auf dem Gegenkonto und dessen Saldo stimmt nicht.
|
||||
(Am Vollexport verifiziert: von 965 Umbuchungen hat keine ein zweites Vorkommen; mit
|
||||
erzeugter Gegenseite stimmen acht geprüfte Kontosalden auf den Cent mit dem
|
||||
FinanzManager überein.) Auch **Splitzeilen** können ein Umbuchungsziel tragen
|
||||
(`S[Konto]`) — dann entsteht die Gegenbuchung je Splitzeile.
|
||||
Eine Buchung, die auf **ihr eigenes Konto** verweist (`L[eigenes Konto]`, Empfänger
|
||||
„Eröffnungssaldo“), ist die Eröffnungsbuchung und bekommt keine Gegenseite.
|
||||
8. **Wertpapierbuchungen** (`!Type:Invst`) mit `Y`/`~`/`@`/`&`/`I`/`Q`/`O`/`B`; `O` ist ein
|
||||
Array aus 12 `|`-getrennten Beträgen (Gebühren, Steuern, Zuschläge).
|
||||
Array aus 12 `|`-getrennten Beträgen (Gebühren, Steuern, Zuschläge). Das
|
||||
Verrechnungskonto steht in `L|[Konto]`, der dort gebuchte Betrag in `$`.
|
||||
9. **Idempotenz:** Wiederholter Import derselben Datei darf keine Dubletten erzeugen
|
||||
(Hash aus Konto + Datum + Betrag + Nummer + Buchungstext).
|
||||
10. **Das Feld `C` ist der Abgleichstatus** (`X` = abgeglichen), **kein** Kennzeichen für
|
||||
vorausberechnete Buchungen: sämtliche Wertpapierbuchungen führen es nie. Wer geplante
|
||||
Buchungen aussortieren will, filtert über das Datum.
|
||||
|
||||
Der Import läuft als CLI-Skript **und** über eine Weboberfläche mit Vorschau, Trockenlauf
|
||||
und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Import wird der User pro Konto gefragt, ob der jeweilige Eröffnungssaldo beibehalten oder geändert werden soll. Der User kann den Saldo jeweils eintragen oder übernehmen, wie er aktuell ist.
|
||||
|
||||
### Stichtag statt Vollhistorie
|
||||
|
||||
Übernommen wird nur der Zeitraum **ab dem 01.01.2026**. Alles davor fließt nicht als
|
||||
Buchung ein, sondern wird je Konto zum **Anfangssaldo** aufsummiert — inklusive der
|
||||
erzeugten Gegenbuchungen aus Umbuchungen. Die Salden stimmen dadurch exakt, ohne dass
|
||||
neun Jahrgänge mitgeschleppt werden.
|
||||
|
||||
Ebenfalls übersprungen werden **vorausberechnete Buchungen**, also solche mit einem Datum
|
||||
nach dem Importtag: sie stammen nicht von der Bank, sondern aus der Vorausschau des
|
||||
FinanzManagers.
|
||||
|
||||
Was damit entfällt: Auswertungen über die Jahre vor 2026. Was bleibt: sämtliche Salden,
|
||||
das komplette Jahr 2026 und die Budgetplanung für 2027. Für die **Depots** reicht ein
|
||||
Saldo nicht — dort werden Bestand und Einstandswert je Wertpapier zum Stichtag gebildet
|
||||
(Phase 6).
|
||||
|
||||
## 5. Phasen
|
||||
|
||||
### Phase 0 — Projektgerüst *(erledigt)*
|
||||
|
|
@ -207,7 +234,7 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor
|
|||
* *Ergebnis:* 17 Klassen, 105 Kategorien und 37 Konten sind aus dem Export übernommen und
|
||||
in der Oberfläche gepflegt. Einzelheiten in `docs/stammdaten.md` und `docs/qif-import.md`.
|
||||
|
||||
### Phase 2 — Buchungen
|
||||
### Phase 2 — Buchungen *(erledigt)*
|
||||
* Migrationen `buchungen`, `buchung_splits`, `buchungsadressen`.
|
||||
* **Kontoblatt** je Konto: Tabelle mit Datum, Nummer, Empfänger, Buchungstext, Kategorie,
|
||||
Klasse, Betrag, Saldo, Abgleich-Status; Sortierung, Filter, Volltextsuche.
|
||||
|
|
@ -285,15 +312,17 @@ und Protokoll (importiert / übersprungen / fehlerhaft je Zeile). Nach dem Impor
|
|||
|
||||
## 6. Was du beisteuern musst
|
||||
|
||||
1. **Vollständiger QIF-Export** aus dem laufenden FinanzManager über *alle* Jahre
|
||||
(der vorliegende Beispielexport deckt nur 2026 ab).
|
||||
2. **Anfangssalden je Konto** — werden nach dem Import im Dialog je Konto bestätigt oder
|
||||
korrigiert (siehe Kapitel 4); als Referenz genügt ein Blick in die Kontenübersicht des
|
||||
FinanzManagers.
|
||||
3. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
||||
1. ~~**Vollständiger QIF-Export** über alle Jahre~~ — liegt vor (8780 Buchungen, 2017–2026).
|
||||
2. ~~**Anfangssalden je Konto**~~ — entfällt: sie werden aus dem Zeitraum vor dem Stichtag
|
||||
berechnet und stimmen gegen den FinanzManager auf den Cent. Nachträglich änderbar bleibt
|
||||
jeder Wert im Kontoformular.
|
||||
3. **Σ-Kennzeichen je Konto prüfen**: Welche Konten in die Summe der Kontostände zählen,
|
||||
steht nicht im Export. Nach dem Import einmal durch die Kontenübersicht gehen (Vorgabe:
|
||||
alle zählen mit).
|
||||
4. **Budgetdaten**: Export aus dem Budgetplaner (Symbol oben rechts in der Werkzeugleiste),
|
||||
sonst wird das Jahresbudget 2027 einmalig neu erfasst.
|
||||
4. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
||||
5. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
||||
5. Zugangsdaten der Bankverbindungen (erst in Phase 5, Eingabe direkt in der Anwendung).
|
||||
6. **API-Key für den Kursdienst** (Leeway.tech oder FMP, erst in Phase 6, Eingabe im
|
||||
Einstellungsdialog).
|
||||
|
||||
## 7. Verifikation
|
||||
|
|
@ -305,10 +334,15 @@ Je Phase, nicht erst am Ende:
|
|||
gegen Ausschnitte des Beispielexports als Fixture.
|
||||
* **E2E-Tests** `tests/e2e/run.sh` — Login, Buchung anlegen mit Split, Kategorie zuordnen,
|
||||
Budgetwert inline ändern, Monatsabschluss durchführen, Bericht aufrufen.
|
||||
* **Migrations-Abgleich als Härtetest:** Nach dem Vollimport müssen die Kontosalden und die
|
||||
Jahressummen Einnahmen/Ausgaben exakt den Werten aus dem FinanzManager entsprechen
|
||||
(Cockpit 2026: Einnahmen 148.060,18 € / Ausgaben 58.457,07 €; Summe Kontostände
|
||||
503.430,75 €). Ein Testfall vergleicht die berechneten Summen gegen diese Sollwerte.
|
||||
* **Migrations-Abgleich als Härtetest:** Nach dem Import müssen die Kontosalden exakt den
|
||||
Werten aus der Kontenübersicht des FinanzManagers entsprechen. Geprüft an acht Konten,
|
||||
deren Werte aus den Screenshots vorliegen — sie stimmen auf den Cent (Stand Phase 2).
|
||||
|
||||
Die **Summe der Kontostände** (503.430,75 € laut Screenshot) lässt sich erst vollständig
|
||||
vergleichen, wenn zwei Dinge stehen: die Σ-Kennzeichen je Konto (welche Konten zählen
|
||||
überhaupt mit — im Export nicht enthalten) und die Depotbewertung aus Phase 6. Die
|
||||
Jahressummen Einnahmen/Ausgaben (Cockpit 2026: 148.060,18 € / 58.457,07 €) hängen an
|
||||
denselben Voraussetzungen und werden mit den Auswertungen in Phase 4 abgeglichen.
|
||||
* **Manuell**: Anwendung unter `http://localhost:8000` aufrufen, Kontenübersicht,
|
||||
Kontoblatt, Budgetplaner und Summenbericht mit den Screenshots aus `Altdaten/` vergleichen.
|
||||
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -10,10 +10,11 @@ Haushalt wird beides parallel genutzt.
|
|||
|
||||
## Stand
|
||||
|
||||
Phase 0 (Grundgerüst) und Phase 1 (Stammdaten) sind gebaut: Anmeldung, zentraler
|
||||
Einstellungsdialog, Debug-Log, Migrationsmechanik, Test-Harness sowie Konten,
|
||||
Kontengruppen, Kategorien, Klassen und der QIF-Stammdatenimport. Buchungen folgen in
|
||||
Phase 2 nach `PLAN.md`.
|
||||
Phase 0 bis 2 sind gebaut: Anmeldung, zentraler Einstellungsdialog, Debug-Log,
|
||||
Migrationsmechanik und Test-Harness; Konten, Kontengruppen, Kategorien und Klassen;
|
||||
Kontoblatt, Buchungsmaske mit Splitbuchungen und Umbuchungen sowie der QIF-Import für
|
||||
Stammdaten und Buchungen. Als Nächstes folgt nach `PLAN.md` der Budgetplaner mit dem
|
||||
Monatsabschluss (Phase 3).
|
||||
|
||||
## Technik
|
||||
|
||||
|
|
@ -64,8 +65,9 @@ verschlüsselt und nicht auswertbar. Der Importer liest Klassen, Kategorien, Kon
|
|||
Buchungen samt Splits sowie Wertpapierbuchungen. Einzelheiten zum Format und den
|
||||
Fallstricken stehen in `PLAN.md`, Kapitel 4.
|
||||
|
||||
Klassen, Kategorien und Konten lassen sich bereits übernehmen – im Dialog unter
|
||||
*Verwaltung → QIF-Import* oder auf der Kommandozeile:
|
||||
Klassen, Kategorien, Konten und Buchungen lassen sich übernehmen – im Dialog unter
|
||||
*Verwaltung → QIF-Import* oder auf der Kommandozeile. Buchungen ab dem Stichtag kommen
|
||||
einzeln in die Datenbank, alles davor wird je Konto zum Anfangssaldo verrechnet:
|
||||
|
||||
```bash
|
||||
docker compose exec -u www-data php php scripts/qif-import.php data/import/Export.QIF
|
||||
|
|
@ -80,4 +82,5 @@ Ohne `--uebernehmen` läuft nur der Trockenlauf.
|
|||
- `CLAUDE.md` – Entwicklungsregeln
|
||||
- `Changes.md` – Änderungsprotokoll
|
||||
- `docs/stammdaten.md` – Konten, Kontengruppen, Kategorien, Klassen
|
||||
- `docs/buchungen.md` – Kontoblatt, Umbuchungen, Splitbuchungen, Salden
|
||||
- `docs/qif-import.md` – Aufbau des QIF-Exports und Arbeitsweise des Importeurs
|
||||
|
|
|
|||
69
docs/buchungen.md
Normal file
69
docs/buchungen.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Buchungen
|
||||
|
||||
Kontoblatt, Buchungsmaske und die Saldenrechnung.
|
||||
|
||||
## Umbuchungen: ein Datensatz, zwei Sichten
|
||||
|
||||
Eine Umbuchung wird **einmal** gespeichert – als Buchung auf dem Ausgangskonto mit
|
||||
`gegenkonto_id`. Das Kontoblatt des Gegenkontos zeigt sie mit umgekehrtem Vorzeichen.
|
||||
|
||||
Der Grund ist nicht nur Sparsamkeit: Zwei gespiegelte Datensätze laufen früher oder später
|
||||
auseinander (einer wird geändert, der andere nicht). Und der QIF-Export macht es genauso –
|
||||
dort steht jede Umbuchung genau einmal, die Gegenseite nur als `L[Zielkonto]`.
|
||||
|
||||
`Buchung::kontoblatt()` vereinigt deshalb drei Mengen:
|
||||
|
||||
1. Buchungen mit `konto_id = X` (eigene Sicht)
|
||||
2. Buchungen mit `gegenkonto_id = X` ohne Splitzeilen, Betrag negiert
|
||||
3. Splitzeilen mit `gegenkonto_id = X`, Betrag negiert
|
||||
|
||||
Zeilen aus 2 und 3 tragen `gegensicht = true`. Sie sind nicht bearbeitbar – das geht auf
|
||||
dem Ursprungskonto –, damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich
|
||||
geändert wird.
|
||||
|
||||
Dieselbe Dreiteilung gilt für die Salden (`Buchung::saldenJeKonto()`), die alle Konten mit
|
||||
sechs Abfragen auf einmal berechnet statt je Konto einzeln.
|
||||
|
||||
## Splitbuchungen
|
||||
|
||||
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
||||
vorliegen, räumt das Model `kategorie_id` und `gegenkonto_id` der Buchung; die Zuordnung
|
||||
steckt dann ausschließlich in den Zeilen.
|
||||
|
||||
Die Summe der Zeilen muss dem Buchungsbetrag entsprechen – geprüft im Model (Toleranz ein
|
||||
halber Cent, für Rundungen aus der Betragsumrechnung) und in der Maske als
|
||||
Restbetragsanzeige. Eine Splitzeile darf ein Gegenkonto tragen; dann erscheint auf jenem
|
||||
Konto nur der Betrag dieser Zeile.
|
||||
|
||||
## Salden
|
||||
|
||||
| Spalte | Bedeutung |
|
||||
|---|---|
|
||||
| Online-Saldo | Stand des letzten Bankabrufs (`konten.saldo_online`, ab Phase 5) |
|
||||
| Aktueller Saldo | Anfangssaldo plus alle Buchungen bis heute |
|
||||
| Erwarteter Saldo | zusätzlich die künftig datierten Buchungen |
|
||||
|
||||
In die **Summe der Kontostände** zählen nur Konten mit `in_summe = 1` – das entspricht der
|
||||
Σ-Spalte im FinanzManager. Durchlaufende Auslagen und Rentenversicherungen bleiben so
|
||||
außen vor, erscheinen aber weiter in der Liste. Der QIF-Export kennt das Kennzeichen
|
||||
nicht; es wird im Kontoformular gepflegt.
|
||||
|
||||
## Beträge in der Oberfläche
|
||||
|
||||
Eingegeben wird immer ein **positiver** Betrag; die Richtung bestimmt die Auswahl
|
||||
*Ausgabe / Einnahme / Umbuchung*. `KontenController::zahl()` liest das deutsche Format und
|
||||
entscheidet bei einem Punkt ohne Komma nach der Stellenzahl: drei Ziffern dahinter heißt
|
||||
Tausendertrenner (`1.500`), sonst Dezimaltrenner (`1234.56`).
|
||||
|
||||
## Bestätigungen und die Content-Security-Policy
|
||||
|
||||
Die CSP erlaubt keine Inline-Skripte (`script-src 'self'`). Rückfragen vor dem Löschen
|
||||
dürfen deshalb **nicht** als `onsubmit="return confirm(…)"` im HTML stehen – der Browser
|
||||
verwirft sie stillschweigend, und gelöscht wird ohne Nachfrage. Stattdessen:
|
||||
|
||||
```html
|
||||
<form method="post" action="…" data-bestaetigen="Buchung wirklich löschen?">
|
||||
```
|
||||
|
||||
`public/static/app.js` hängt den Dialog daran. Ein E2E-Test besteht auf dem Dialog, damit
|
||||
das nicht unbemerkt zurückfällt.
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
Der Lexware FinanzManager exportiert erweitertes Quicken-QIF. Die `.QDF`-Datei ist
|
||||
verschlüsselt und damit keine Migrationsquelle – der QIF-Export ist der einzige Weg.
|
||||
|
||||
Stand: Klassen, Kategorien und Konten werden übernommen (Phase 1). Buchungen, Splits,
|
||||
Umbuchungen und Wertpapierbuchungen folgen in Phase 2 und 6.
|
||||
Stand: Klassen, Kategorien, Konten (Phase 1) sowie Buchungen samt Splitzeilen und
|
||||
Umbuchungen (Phase 2) werden übernommen. Wertpapierbestände folgen in Phase 6,
|
||||
Buchungsvorlagen in Phase 7.
|
||||
|
||||
## Aufbau der Datei
|
||||
|
||||
|
|
@ -58,19 +59,45 @@ zweite Schreibweise ab, bei der ein einzelner `!Account` den Kontowechsel bedeut
|
|||
Zwischenstufen selbst an (`Kategorie::findenOderAnlegenNachPfad()`).
|
||||
6. **Konten stehen zweimal in der Datei** – einmal in der Kontenliste, einmal als Kopf
|
||||
ihrer Buchungen. Der zweite Treffer ist keine Dublette.
|
||||
7. **Umbuchungen erscheinen doppelt**, einmal je beteiligtem Konto, und müssen in Phase 2
|
||||
zu einer Buchung zusammengeführt werden.
|
||||
8. **Anfangssalden fehlen im Export** und werden je Konto nachgetragen.
|
||||
7. **Umbuchungen stehen nur einmal in der Datei** – in dem Konto, in dem sie erfasst
|
||||
wurden. Die Gegenseite ist allein durch `L[Zielkonto]` benannt und wird beim Import
|
||||
erzeugt; ohne sie fehlte dem Gegenkonto der Betrag. Auch Splitzeilen können ein
|
||||
Umbuchungsziel tragen (`S[Konto]`). Eine Buchung, die auf ihr eigenes Konto verweist,
|
||||
ist die Eröffnungsbuchung und bekommt keine Gegenseite.
|
||||
8. **`C` ist der Abgleichstatus** (`X` = abgeglichen), kein Kennzeichen für
|
||||
vorausberechnete Buchungen – Wertpapierbuchungen führen es nie. Geplante Buchungen
|
||||
sortiert man über das Datum aus.
|
||||
9. **Anfangssalden stehen nicht als eigenes Feld im Export.** Sie ergeben sich aus den
|
||||
Buchungen vor dem Stichtag, Gegenbuchungen eingerechnet.
|
||||
|
||||
## Arbeitsweise des Importeurs
|
||||
## Arbeitsweise der Importeure
|
||||
|
||||
`QifStammdatenImport` ist **idempotent**: Was es schon gibt, wird gezählt und übersprungen,
|
||||
nie doppelt angelegt. Ein zweiter Lauf derselben Datei ändert nichts.
|
||||
Beide sind **idempotent**: Was es schon gibt, wird gezählt und übersprungen, nie doppelt
|
||||
angelegt. Ein zweiter Lauf derselben Datei ändert nichts. Der echte Lauf liegt jeweils in
|
||||
einer Transaktion – ein Abbruch mitten in der Kategorieliste hinterließe sonst einen halben
|
||||
Baum.
|
||||
|
||||
Der echte Lauf liegt in einer Transaktion – ein Abbruch mitten in der Kategorieliste
|
||||
hinterließe sonst einen halben Baum. Der **Trockenlauf** schreibt gar nicht, sondern zählt
|
||||
mit einer Menge bereits bekannter Pfade mit; auch die Zwischenstufen, die der Import selbst
|
||||
anlegen würde, erscheinen dort in der Vorschau.
|
||||
`QifStammdatenImport` (Klassen, Kategorien, Konten): Der **Trockenlauf** schreibt gar
|
||||
nicht, sondern zählt mit einer Menge bereits bekannter Pfade mit; auch die Zwischenstufen,
|
||||
die der Import selbst anlegen würde, erscheinen in der Vorschau.
|
||||
|
||||
`QifBuchungsImport` arbeitet mit einem **Stichtag**:
|
||||
|
||||
* Buchungen **vor** dem Stichtag werden nicht gespeichert, sondern je Konto zum
|
||||
Anfangssaldo aufsummiert – einschließlich der Gegenbuchungen aus Umbuchungen. Die Salden
|
||||
stimmen dadurch exakt, ohne die ganze Historie mitzuschleppen.
|
||||
* Buchungen **im Zeitraum** kommen einzeln in die Datenbank, mit Splitzeilen, Klasse,
|
||||
Abgleichstatus und Adresszeilen.
|
||||
* Buchungen **nach dem Enddatum** (Vorgabe: heute) sind Vorausberechnungen des
|
||||
FinanzManagers und bleiben draußen.
|
||||
* **Wertpapiergeschäfte** schlagen vorerst nur mit ihrem Geldfluss auf dem
|
||||
Verrechnungskonto zu Buche (`L|[Konto]` und `$`). Ein Betrag auf dem Depot ohne
|
||||
Gegenwert würde die Summe der Kontostände verfälschen; Bestände folgen in Phase 6.
|
||||
|
||||
Die Dublettenerkennung nutzt einen Fingerabdruck aus Konto, Datum, Betrag, Nummer und
|
||||
Text. Weil es echte Doppelbuchungen am selben Tag gibt (zweimal dasselbe im selben Laden),
|
||||
zählt ein laufender Index gleiche Fingerabdrücke durch – stabil über Läufe hinweg, weil
|
||||
die Datei immer dieselbe Reihenfolge hat.
|
||||
|
||||
## Bedienung
|
||||
|
||||
|
|
@ -88,15 +115,25 @@ docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen dat
|
|||
Ohne `--uebernehmen` läuft nur der Trockenlauf. `--benutzer=NAME` wählt den Mandanten,
|
||||
sonst gilt der erste aktive Benutzer.
|
||||
|
||||
`--stichtag=` und `--bis=` überschreiben die Vorgaben; ohne Angabe gilt der Stichtag aus
|
||||
den Einstellungen (Reiter *Import/Export*) und als Enddatum der heutige Tag.
|
||||
`--nur-stammdaten` lässt die Buchungen aus.
|
||||
|
||||
## Prüfung gegen den echten Export
|
||||
|
||||
Der Trockenlauf gegen `Export-Migration-Beispiel.QIF` liefert die Zahlen der Voranalyse:
|
||||
Der Vollexport (8780 Buchungen, 2017–2026) ergibt mit Stichtag 01.01.2026:
|
||||
|
||||
```
|
||||
Datei enthält Class: 17 · Cat: 105 · Account: 73 · Bank: 572 · Cash: 61 ·
|
||||
Oth T: 31 · Oth A: 154 · Invst: 16 · Memorized: 579
|
||||
Klassen 17 angelegt
|
||||
Kategorien 105 angelegt
|
||||
Konten 37 angelegt, 36 vorhanden (die Zweitnennung je Konto)
|
||||
Buchungen 833 angelegt
|
||||
|
||||
Klassen 17 neu
|
||||
Kategorien 105 neu
|
||||
Konten 37 neu, 36 vorhanden (die Zweitnennung je Konto)
|
||||
vor dem Stichtag verrechnet: 7946 Buchungen auf 35 Konten
|
||||
nach dem Enddatum verworfen: 1 vorausberechnete Buchung
|
||||
darunter Wertpapiergeschäfte: 16 (nur Geldfluss)
|
||||
```
|
||||
|
||||
Der Härtetest sind die Salden: Acht Konten, deren Werte aus der Kontenübersicht des
|
||||
FinanzManagers vorliegen, stimmen danach **auf den Cent** – von „Whg Besigheim“
|
||||
(210.601,99 €) bis „Auslagen Eltern Wohnungskauf“ (−22.410,94 €).
|
||||
|
|
|
|||
100
migrations/003_buchungen.sql
Normal file
100
migrations/003_buchungen.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
-- Phase 2: Buchungen, Splitzeilen und Buchungsadressen.
|
||||
--
|
||||
-- Eine Umbuchung ist EINE Buchung mit gegenkonto_id, nicht zwei gespiegelte
|
||||
-- Datensätze. Das entspricht dem QIF-Export (dort steht jede Umbuchung genau
|
||||
-- einmal, die Gegenseite nur als L[Zielkonto]) und erspart die Pflege zweier
|
||||
-- Zeilen, die auseinanderlaufen können. Das Kontoblatt des Gegenkontos zeigt
|
||||
-- die Buchung mit umgekehrtem Vorzeichen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS buchungen (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
benutzer_id INT UNSIGNED NOT NULL,
|
||||
konto_id INT UNSIGNED NOT NULL,
|
||||
datum DATE NOT NULL,
|
||||
valuta DATE NULL,
|
||||
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
|
||||
empfaenger VARCHAR(255) NOT NULL DEFAULT '',
|
||||
buchungstext TEXT NULL,
|
||||
nummer VARCHAR(32) NOT NULL DEFAULT '',
|
||||
abgeglichen TINYINT(1) NOT NULL DEFAULT 0,
|
||||
-- Entweder eine Kategorie, oder ein Gegenkonto (Umbuchung), oder Splitzeilen.
|
||||
kategorie_id INT UNSIGNED NULL,
|
||||
klasse_id INT UNSIGNED NULL,
|
||||
gegenkonto_id INT UNSIGNED NULL,
|
||||
gesplittet TINYINT(1) NOT NULL DEFAULT 0,
|
||||
quelle VARCHAR(16) NOT NULL DEFAULT 'manuell',
|
||||
-- Fingerabdruck für die Dublettenerkennung beim wiederholten Import.
|
||||
import_hash CHAR(40) NULL,
|
||||
erstellt_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
geaendert_am DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_buchung_konto (konto_id, datum),
|
||||
KEY idx_buchung_gegenkonto (gegenkonto_id, datum),
|
||||
KEY idx_buchung_datum (benutzer_id, datum),
|
||||
KEY idx_buchung_kategorie (kategorie_id),
|
||||
UNIQUE KEY uq_buchung_import (benutzer_id, import_hash),
|
||||
CONSTRAINT fk_buchung_benutzer FOREIGN KEY (benutzer_id)
|
||||
REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_buchung_konto FOREIGN KEY (konto_id)
|
||||
REFERENCES konten (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_buchung_gegenkonto FOREIGN KEY (gegenkonto_id)
|
||||
REFERENCES konten (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_buchung_kategorie FOREIGN KEY (kategorie_id)
|
||||
REFERENCES kategorien (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_buchung_klasse FOREIGN KEY (klasse_id)
|
||||
REFERENCES klassen (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Splitzeilen. Die Summe muss dem Buchungsbetrag entsprechen; geprüft im Model
|
||||
-- und im Test, nicht per Trigger.
|
||||
CREATE TABLE IF NOT EXISTS buchung_splits (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
buchung_id INT UNSIGNED NOT NULL,
|
||||
kategorie_id INT UNSIGNED NULL,
|
||||
klasse_id INT UNSIGNED NULL,
|
||||
gegenkonto_id INT UNSIGNED NULL,
|
||||
betrag DECIMAL(15,2) NOT NULL DEFAULT 0.00,
|
||||
notiz VARCHAR(255) NOT NULL DEFAULT '',
|
||||
sortierung INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_split_buchung (buchung_id),
|
||||
KEY idx_split_kategorie (kategorie_id),
|
||||
KEY idx_split_gegenkonto (gegenkonto_id),
|
||||
CONSTRAINT fk_split_buchung FOREIGN KEY (buchung_id)
|
||||
REFERENCES buchungen (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_split_kategorie FOREIGN KEY (kategorie_id)
|
||||
REFERENCES kategorien (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_split_klasse FOREIGN KEY (klasse_id)
|
||||
REFERENCES klassen (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_split_gegenkonto FOREIGN KEY (gegenkonto_id)
|
||||
REFERENCES konten (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Die bis zu sechs A-Zeilen einer Buchung (Empfängeranschrift).
|
||||
CREATE TABLE IF NOT EXISTS buchungsadressen (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
buchung_id INT UNSIGNED NOT NULL,
|
||||
zeile TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
text VARCHAR(255) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_adresse_buchung (buchung_id),
|
||||
CONSTRAINT fk_adresse_buchung FOREIGN KEY (buchung_id)
|
||||
REFERENCES buchungen (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Die Σ-Spalte der Kontenübersicht: nicht jedes Konto zählt zur Summe der
|
||||
-- Kontostände (Rentenversicherungen und durchlaufende Auslagen etwa nicht).
|
||||
-- Der QIF-Export kennt das Kennzeichen nicht, es wird in der Oberfläche gepflegt.
|
||||
ALTER TABLE konten ADD COLUMN IF NOT EXISTS
|
||||
in_summe TINYINT(1) NOT NULL DEFAULT 1 AFTER aktiv;
|
||||
|
||||
-- Online-Saldo aus dem Bankabruf (Phase 5); bis dahin bleibt er leer.
|
||||
ALTER TABLE konten ADD COLUMN IF NOT EXISTS
|
||||
saldo_online DECIMAL(15,2) NULL AFTER in_summe;
|
||||
ALTER TABLE konten ADD COLUMN IF NOT EXISTS
|
||||
saldo_online_am DATETIME NULL AFTER saldo_online;
|
||||
|
||||
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
|
||||
('import_stichtag', '2026-01-01', 'import'),
|
||||
('import_zukunft_uebernehmen', '0', 'import')
|
||||
ON DUPLICATE KEY UPDATE schluessel = schluessel;
|
||||
|
|
@ -10,6 +10,7 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
|||
|
||||
use Finanzmanager\Auth;
|
||||
use Finanzmanager\Config;
|
||||
use Finanzmanager\Controllers\BuchungenController;
|
||||
use Finanzmanager\Controllers\CockpitController;
|
||||
use Finanzmanager\Controllers\EinstellungenController;
|
||||
use Finanzmanager\Controllers\ImportController;
|
||||
|
|
@ -65,6 +66,14 @@ $router->get('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController(
|
|||
$router->post('/konten/{id}/bearbeiten', fn(array $p) => (new KontenController())->speichern($p));
|
||||
$router->post('/konten/{id}/loeschen', fn(array $p) => (new KontenController())->loeschen($p));
|
||||
|
||||
// Kontoblatt und Buchungen.
|
||||
$router->get('/konten/{id}', fn(array $p) => (new BuchungenController())->kontoblatt($p));
|
||||
$router->get('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->neu($p));
|
||||
$router->post('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->anlegen($p));
|
||||
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
|
||||
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
|
||||
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
|
||||
|
||||
// Verwaltung: Kategorien, Klassen, Kontengruppen, QIF-Import.
|
||||
$router->get('/verwaltung', fn() => (new VerwaltungController())->index());
|
||||
|
||||
|
|
|
|||
|
|
@ -448,6 +448,75 @@ table.liste input.schmal { width: 5rem; }
|
|||
|
||||
.seitenaktionen .fuellung { flex: 1; }
|
||||
|
||||
.saldoanzeige {
|
||||
font-size: .9rem;
|
||||
color: var(--text-gedaempft);
|
||||
}
|
||||
|
||||
.saldoanzeige strong {
|
||||
margin-left: .35rem;
|
||||
font-family: var(--schrift-zahl);
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.saldoanzeige strong.negativ { color: var(--negativ); }
|
||||
|
||||
/* ── Kontoblatt ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.filterleiste {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: .6rem .75rem;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.filterleiste input[type="search"],
|
||||
.filterleiste input[type="date"],
|
||||
.filterleiste select {
|
||||
padding: .3rem .45rem;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 4px;
|
||||
background: var(--flaeche);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.filterleiste input[type="search"] { min-width: 16rem; }
|
||||
.filterleiste select { max-width: 15rem; }
|
||||
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
|
||||
|
||||
table.kontoblatt { font-size: .89rem; }
|
||||
table.kontoblatt td { padding: .3rem .6rem; }
|
||||
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); }
|
||||
|
||||
.gekuerzt {
|
||||
max-width: 26rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blaettern {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
/* Die Splittabelle sitzt im Formular und braucht schmalere Zellen. */
|
||||
table.splittabelle td { padding: .3rem .4rem; }
|
||||
table.splittabelle select { max-width: 14rem; width: 100%; }
|
||||
table.splittabelle input.rechts { text-align: right; }
|
||||
|
||||
.buchungsmaske [hidden] { display: none; }
|
||||
|
||||
/* ── Formulare ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.formular {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
/* Finanzmanager – Frontend-Hilfen.
|
||||
*
|
||||
* Bewusst schlank und ohne Framework. Ab Phase 2 kommen hier Splitbuchungs-
|
||||
* maske und die Inline-Bearbeitung des Budgetplaners dazu.
|
||||
* Bewusst schlank und ohne Framework.
|
||||
*
|
||||
* Bestätigungen hängen an <form data-bestaetigen="…"> und nicht als onsubmit
|
||||
* im HTML: Die Content-Security-Policy erlaubt keine Inline-Skripte, ein
|
||||
* onsubmit-Attribut würde stillschweigend verworfen – und dann löschte der
|
||||
* Knopf ohne Rückfrage.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Formulare mit data-bestaetigen fragen vor dem Absenden nach.
|
||||
// In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein
|
||||
// anderer Handler das Ereignis unterwegs stoppt.
|
||||
document.addEventListener('submit', function (ereignis) {
|
||||
const formular = ereignis.target;
|
||||
const frage = formular.dataset ? formular.dataset.bestaetigen : null;
|
||||
if (!(formular instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frage = formular.dataset.bestaetigen;
|
||||
if (frage && !window.confirm(frage)) {
|
||||
ereignis.preventDefault();
|
||||
ereignis.stopPropagation();
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
|
||||
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
||||
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
||||
|
|
|
|||
107
public/static/buchungsmaske.js
Normal file
107
public/static/buchungsmaske.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/* Buchungsmaske: Felder je nach Art ein- und ausblenden, Restbetrag der
|
||||
* Splitzeilen mitrechnen.
|
||||
*
|
||||
* Nur Komfort – die Maske kommt fertig vom Server. Ohne JavaScript bleibt alles
|
||||
* bedienbar: dann sind sämtliche Felder sichtbar, und die Splitsumme prüft
|
||||
* ohnehin das Model.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const art = document.getElementById('art');
|
||||
const maske = document.querySelector('.buchungsmaske');
|
||||
if (!art || !maske) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kategorie = maske.querySelector('.nur-kategorie');
|
||||
const umbuchung = maske.querySelector('.nur-umbuchung');
|
||||
const gegenkonto = document.getElementById('gegenkonto_id');
|
||||
const summenfeld = document.getElementById('splitsumme');
|
||||
const betragsfeld = document.getElementById('betrag');
|
||||
|
||||
function zeigePassendeFelder() {
|
||||
const istUmbuchung = art.value.indexOf('umbuchung') === 0;
|
||||
if (kategorie) {
|
||||
kategorie.hidden = istUmbuchung;
|
||||
}
|
||||
if (umbuchung) {
|
||||
umbuchung.hidden = !istUmbuchung;
|
||||
}
|
||||
if (gegenkonto) {
|
||||
gegenkonto.required = istUmbuchung;
|
||||
}
|
||||
}
|
||||
|
||||
/* Beträge stehen im deutschen Format im Feld. Dieselbe Regel wie
|
||||
* KontenController::zahl(): ohne Komma entscheidet die Stellenzahl, ob der
|
||||
* Punkt Tausender- oder Dezimaltrenner ist. */
|
||||
function zahl(text) {
|
||||
const bereinigt = String(text).replace(/[\s€]/g, '');
|
||||
if (bereinigt === '') {
|
||||
return 0;
|
||||
}
|
||||
if (bereinigt.indexOf(',') !== -1) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
}
|
||||
|
||||
const punkte = (bereinigt.match(/\./g) || []).length;
|
||||
const letzter = bereinigt.split('.').pop();
|
||||
if (punkte > 1 || (punkte === 1 && letzter.length === 3)) {
|
||||
return parseFloat(bereinigt.replace(/\./g, '')) || 0;
|
||||
}
|
||||
return parseFloat(bereinigt) || 0;
|
||||
}
|
||||
|
||||
function formatiere(wert) {
|
||||
return wert.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function rechneSumme() {
|
||||
if (!summenfeld) {
|
||||
return;
|
||||
}
|
||||
|
||||
let summe = 0;
|
||||
let belegt = false;
|
||||
maske.querySelectorAll('.splitbetrag').forEach(function (feld) {
|
||||
if (feld.value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
belegt = true;
|
||||
summe += zahl(feld.value);
|
||||
});
|
||||
|
||||
if (!belegt) {
|
||||
summenfeld.textContent = '0,00';
|
||||
summenfeld.classList.remove('negativ');
|
||||
return;
|
||||
}
|
||||
|
||||
// Der Buchungsbetrag wird positiv eingegeben; das Vorzeichen ergibt
|
||||
// sich aus der Art. Für den Vergleich zählt der Betrag mit Richtung.
|
||||
let ziel = zahl(betragsfeld ? betragsfeld.value : '0');
|
||||
if (art.value === 'ausgabe' || art.value === 'umbuchung_ab') {
|
||||
ziel = -ziel;
|
||||
}
|
||||
|
||||
const rest = Math.round((ziel - summe) * 100) / 100;
|
||||
summenfeld.textContent = formatiere(summe) + (rest === 0 ? '' : ' (Rest ' + formatiere(rest) + ')');
|
||||
summenfeld.classList.toggle('negativ', rest !== 0);
|
||||
}
|
||||
|
||||
art.addEventListener('change', function () {
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
});
|
||||
|
||||
maske.addEventListener('input', function (ereignis) {
|
||||
if (ereignis.target.classList.contains('splitbetrag') || ereignis.target.id === 'betrag') {
|
||||
rechneSumme();
|
||||
}
|
||||
});
|
||||
|
||||
zeigePassendeFelder();
|
||||
rechneSumme();
|
||||
})();
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* QIF-Stammdatenimport auf der Kommandozeile.
|
||||
* QIF-Import auf der Kommandozeile.
|
||||
*
|
||||
* Dieselbe Logik wie im Dialog unter Verwaltung → QIF-Import, aber ohne Upload –
|
||||
* praktisch für den großen Vollexport und für die Erstbefüllung.
|
||||
|
|
@ -13,6 +13,15 @@ declare(strict_types=1);
|
|||
* docker compose exec -u www-data php php scripts/qif-import.php --uebernehmen data/import/Export.QIF
|
||||
*
|
||||
* Ohne --uebernehmen läuft nur der Trockenlauf: es wird nichts gespeichert.
|
||||
*
|
||||
* Weitere Optionen:
|
||||
* --nur-stammdaten Klassen, Kategorien und Konten, keine Buchungen
|
||||
* --stichtag=JJJJ-MM-TT ab hier werden Buchungen übernommen; alles davor
|
||||
* fließt in den Anfangssaldo (Vorgabe aus den
|
||||
* Einstellungen, sonst 2026-01-01)
|
||||
* --bis=JJJJ-MM-TT Enddatum; spätere Buchungen sind Vorausberechnungen
|
||||
* des FinanzManagers (Vorgabe: heute)
|
||||
* --benutzer=NAME Mandant, sonst der erste aktive Benutzer
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
|
@ -20,6 +29,8 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
|||
use Finanzmanager\Config;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Benutzer;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\QifBuchungsImport;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
|
||||
|
|
@ -27,14 +38,23 @@ Config::load();
|
|||
|
||||
$argumente = array_slice($argv, 1);
|
||||
$uebernehmen = false;
|
||||
$nurStammdaten = false;
|
||||
$benutzer = null;
|
||||
$datei = null;
|
||||
$stichtag = null;
|
||||
$bis = null;
|
||||
|
||||
foreach ($argumente as $argument) {
|
||||
if ($argument === '--uebernehmen') {
|
||||
$uebernehmen = true;
|
||||
} elseif ($argument === '--nur-stammdaten') {
|
||||
$nurStammdaten = true;
|
||||
} elseif (str_starts_with($argument, '--benutzer=')) {
|
||||
$benutzer = substr($argument, 11);
|
||||
} elseif (str_starts_with($argument, '--stichtag=')) {
|
||||
$stichtag = substr($argument, 11);
|
||||
} elseif (str_starts_with($argument, '--bis=')) {
|
||||
$bis = substr($argument, 6);
|
||||
} elseif (str_starts_with($argument, '-')) {
|
||||
fwrite(STDERR, "Unbekannte Option: {$argument}\n");
|
||||
exit(1);
|
||||
|
|
@ -44,7 +64,8 @@ foreach ($argumente as $argument) {
|
|||
}
|
||||
|
||||
if ($datei === null) {
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--benutzer=NAME] <datei.qif>\n");
|
||||
fwrite(STDERR, "Aufruf: qif-import.php [--uebernehmen] [--nur-stammdaten] "
|
||||
. "[--stichtag=JJJJ-MM-TT] [--bis=JJJJ-MM-TT] [--benutzer=NAME] <datei.qif>\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +88,18 @@ if ($benutzer !== null) {
|
|||
Mandant::setze((int)$zeile['id']);
|
||||
}
|
||||
|
||||
$stichtag ??= (string)Einstellung::get('import_stichtag', '2026-01-01');
|
||||
$bis ??= date('Y-m-d');
|
||||
|
||||
try {
|
||||
$parser = QifParser::ausDatei($datei);
|
||||
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
|
||||
$stamm = (new QifStammdatenImport($parser))->ausfuehren(!$uebernehmen);
|
||||
|
||||
$buch = null;
|
||||
if (!$nurStammdaten) {
|
||||
$buch = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
||||
->ausfuehren(!$uebernehmen);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, 'Import fehlgeschlagen: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
|
|
@ -88,25 +118,40 @@ foreach (['klassen' => 'Klassen', 'kategorien' => 'Kategorien', 'konten' => 'Kon
|
|||
printf(
|
||||
"%-12s %8d %12d %8d\n",
|
||||
$titel,
|
||||
$ergebnis[$schluessel]['neu'],
|
||||
$ergebnis[$schluessel]['vorhanden'],
|
||||
$ergebnis[$schluessel]['fehler']
|
||||
$stamm[$schluessel]['neu'],
|
||||
$stamm[$schluessel]['vorhanden'],
|
||||
$stamm[$schluessel]['fehler']
|
||||
);
|
||||
}
|
||||
|
||||
if ($ergebnis['meldungen'] !== []) {
|
||||
$meldungen = $stamm['meldungen'];
|
||||
|
||||
if ($buch !== null) {
|
||||
printf("%-12s %8d %12d %8d\n", 'Buchungen',
|
||||
$buch['buchungen']['neu'], $buch['buchungen']['vorhanden'], $buch['buchungen']['fehler']);
|
||||
|
||||
echo "\nZeitraum ab {$stichtag} bis {$bis}\n";
|
||||
printf(" vor dem Stichtag verrechnet: %6d Buchungen auf %d Konten (Summe %s)\n",
|
||||
$buch['buchungen']['vor_stichtag'],
|
||||
$buch['anfangssalden']['konten'],
|
||||
number_format($buch['anfangssalden']['summe'], 2, ',', '.'));
|
||||
printf(" nach dem Enddatum verworfen: %6d vorausberechnete Buchungen\n", $buch['buchungen']['zukunft']);
|
||||
printf(" darunter Wertpapiergeschäfte: %5d (nur Geldfluss)\n", $buch['buchungen']['wertpapier']);
|
||||
|
||||
$meldungen = array_merge($meldungen, $buch['meldungen']);
|
||||
}
|
||||
|
||||
if ($meldungen !== []) {
|
||||
echo "\nProtokoll:\n";
|
||||
foreach ($ergebnis['meldungen'] as $meldung) {
|
||||
foreach ($meldungen as $meldung) {
|
||||
echo ' - ' . $meldung['text'] . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!$uebernehmen) {
|
||||
echo "\nEs wurde nichts gespeichert. Mit --uebernehmen wird der Import durchgeführt.\n";
|
||||
} else {
|
||||
echo "\nAnfangssalden stehen nicht im Export – sie werden je Konto in der\n"
|
||||
. "Kontenübersicht nachgetragen.\n";
|
||||
}
|
||||
|
||||
$fehler = $ergebnis['klassen']['fehler'] + $ergebnis['kategorien']['fehler'] + $ergebnis['konten']['fehler'];
|
||||
$fehler = $stamm['klassen']['fehler'] + $stamm['kategorien']['fehler'] + $stamm['konten']['fehler']
|
||||
+ ($buch['buchungen']['fehler'] ?? 0);
|
||||
exit($fehler > 0 ? 1 : 0);
|
||||
|
|
|
|||
237
src/Controllers/BuchungenController.php
Normal file
237
src/Controllers/BuchungenController.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Kontoblatt und Buchungsmaske.
|
||||
*
|
||||
* Das Kontoblatt zeigt eigene Buchungen und die Gegenseiten fremder
|
||||
* Umbuchungen; Letztere lassen sich nur auf ihrem Ursprungskonto bearbeiten,
|
||||
* damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich geändert wird.
|
||||
*/
|
||||
class BuchungenController extends BaseController
|
||||
{
|
||||
/** @param array<string,string> $params */
|
||||
public function kontoblatt(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$konto = Konto::find((int)$params['id']);
|
||||
if ($konto === null) {
|
||||
$this->nichtGefunden('/konten/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$filter = [
|
||||
'suche' => $this->query('suche'),
|
||||
'von' => $this->query('von'),
|
||||
'bis' => $this->query('bis'),
|
||||
'kategorie_id' => $this->query('kategorie_id'),
|
||||
'klasse_id' => $this->query('klasse_id'),
|
||||
'nur_offene' => $this->query('nur_offene') === '1',
|
||||
];
|
||||
|
||||
$zeilen = Buchung::kontoblatt((int)$konto['id'], $filter);
|
||||
|
||||
// Die neuesten Buchungen zuerst zeigen, den Saldo aber chronologisch
|
||||
// aufbauen – deshalb erst rechnen, dann umdrehen.
|
||||
$zeilen = array_reverse($zeilen);
|
||||
$grenze = Einstellung::getInt('zeilen_pro_seite', 100);
|
||||
$gesamt = count($zeilen);
|
||||
$seite = max(1, (int)$this->query('seite', '1'));
|
||||
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
|
||||
|
||||
$this->render('buchungen/kontoblatt.php', [
|
||||
'titel' => 'Kontoblatt: ' . $konto['name'],
|
||||
'konto' => Konto::mitSalden($konto),
|
||||
'zeilen' => $zeilen,
|
||||
'gesamt' => $gesamt,
|
||||
'seite' => $seite,
|
||||
'seiten' => max(1, (int)ceil($gesamt / max(1, $grenze))),
|
||||
'filter' => $filter,
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
// Namen vorab, damit die Tabelle nicht je Zeile nachfragt.
|
||||
'kontenNamen' => array_column(Konto::alle(), 'name', 'id'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function neu(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$konto = Konto::find((int)$params['id']);
|
||||
if ($konto === null) {
|
||||
$this->nichtGefunden('/konten/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->maske($konto, null);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function anlegen(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$kontoId = (int)$params['id'];
|
||||
if (Konto::find($kontoId) === null) {
|
||||
$this->nichtGefunden('/konten/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
[$daten, $splits] = $this->formulardaten($kontoId);
|
||||
Buchung::anlegen($daten, $splits);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function bearbeiten(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->maske(Konto::find((int)$buchung['konto_id']), $buchung);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function speichern(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$id = (int)$params['id'];
|
||||
$buchung = Buchung::find($id);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
|
||||
Buchung::speichern($id, $daten, $splits);
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gespeichert.');
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
public function loeschen(array $params): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$buchung = Buchung::find((int)$params['id']);
|
||||
if ($buchung === null) {
|
||||
$this->nichtGefunden('/buchungen/' . $params['id']);
|
||||
return;
|
||||
}
|
||||
|
||||
Buchung::loeschen((int)$buchung['id']);
|
||||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed>|null $konto */
|
||||
private function maske(?array $konto, ?array $buchung): void
|
||||
{
|
||||
if ($konto === null) {
|
||||
$this->nichtGefunden('/konten');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render('buchungen/formular.php', [
|
||||
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
|
||||
'konto' => $konto,
|
||||
'buchung' => $buchung,
|
||||
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => Klasse::alle(),
|
||||
'konten' => Konto::alle(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<string,mixed>, 1: list<array<string,mixed>>}
|
||||
*/
|
||||
private function formulardaten(int $kontoId): array
|
||||
{
|
||||
$art = $this->post('art', 'ausgabe');
|
||||
$betrag = KontenController::zahl($this->post('betrag', '0'));
|
||||
|
||||
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
|
||||
// bestimmt die Auswahl Einnahme / Ausgabe / Umbuchung.
|
||||
$betrag = abs($betrag);
|
||||
if ($art === 'ausgabe' || $art === 'umbuchung_ab') {
|
||||
$betrag = -$betrag;
|
||||
}
|
||||
|
||||
$gegenkonto = in_array($art, ['umbuchung_ab', 'umbuchung_zu'], true)
|
||||
? $this->post('gegenkonto_id')
|
||||
: null;
|
||||
|
||||
$daten = [
|
||||
'konto_id' => $kontoId,
|
||||
'datum' => $this->post('datum'),
|
||||
'valuta' => $this->post('valuta'),
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => $this->post('empfaenger'),
|
||||
'buchungstext' => $this->post('buchungstext'),
|
||||
'nummer' => $this->post('nummer'),
|
||||
'abgeglichen' => isset($_POST['abgeglichen']),
|
||||
'kategorie_id' => $gegenkonto === null ? $this->post('kategorie_id') : null,
|
||||
'klasse_id' => $this->post('klasse_id'),
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'quelle' => 'manuell',
|
||||
];
|
||||
|
||||
$splits = [];
|
||||
$zeilen = $_POST['split'] ?? [];
|
||||
if (is_array($zeilen)) {
|
||||
foreach ($zeilen as $zeile) {
|
||||
if (!is_array($zeile)) {
|
||||
continue;
|
||||
}
|
||||
$wert = trim((string)($zeile['betrag'] ?? ''));
|
||||
if ($wert === '') {
|
||||
continue;
|
||||
}
|
||||
$splits[] = [
|
||||
'kategorie_id' => $zeile['kategorie_id'] ?? null,
|
||||
'klasse_id' => $zeile['klasse_id'] ?? null,
|
||||
'betrag' => KontenController::zahl($wert),
|
||||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [$daten, $splits];
|
||||
}
|
||||
|
||||
private function nichtGefunden(string $pfad): void
|
||||
{
|
||||
http_response_code(404);
|
||||
$this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']);
|
||||
}
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ class EinstellungenController extends BaseController
|
|||
'allgemein' => ['mandant_name' => 'text', 'waehrung' => 'text'],
|
||||
'darstellung' => ['darstellung_thema' => 'text', 'zeilen_pro_seite' => 'text'],
|
||||
'buchhaltung' => ['erster_monat' => 'text'],
|
||||
'import' => ['import_qif_verzeichnis' => 'text'],
|
||||
'import' => ['import_qif_verzeichnis' => 'text', 'import_stichtag' => 'text'],
|
||||
'diagnose' => ['debug_log_aktiv' => 'bool'],
|
||||
default => [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@ declare(strict_types=1);
|
|||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Services\QifBuchungsImport;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* QIF-Import der Stammdaten (Klassen, Kategorien, Konten).
|
||||
* QIF-Import: Stammdaten (Klassen, Kategorien, Konten) und Buchungen.
|
||||
*
|
||||
* Hochgeladene Dateien landen im Importverzeichnis und bleiben dort – so lässt
|
||||
* sich derselbe Export erst im Trockenlauf prüfen und danach übernehmen, ohne
|
||||
* ihn zweimal hochzuladen. Buchungen folgen in Phase 2.
|
||||
* ihn zweimal hochzuladen.
|
||||
*/
|
||||
class ImportController extends BaseController
|
||||
{
|
||||
|
|
@ -29,6 +30,9 @@ class ImportController extends BaseController
|
|||
'dateien' => $this->dateien(),
|
||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||
'ergebnis' => null,
|
||||
'buchungen' => null,
|
||||
'stichtag' => (string)Einstellung::get('import_stichtag', '2026-01-01'),
|
||||
'bis' => date('Y-m-d'),
|
||||
'datei' => '',
|
||||
]);
|
||||
}
|
||||
|
|
@ -76,10 +80,21 @@ class ImportController extends BaseController
|
|||
|
||||
$trockenlauf = $this->post('modus') !== 'import';
|
||||
|
||||
$stichtag = $this->post('stichtag') ?: (string)Einstellung::get('import_stichtag', '2026-01-01');
|
||||
$bis = $this->post('bis') ?: date('Y-m-d');
|
||||
|
||||
try {
|
||||
$parser = QifParser::ausDatei($pfad);
|
||||
$importer = new QifStammdatenImport($parser);
|
||||
$ergebnis = $importer->ausfuehren($trockenlauf);
|
||||
$ergebnis = (new QifStammdatenImport($parser))->ausfuehren($trockenlauf);
|
||||
|
||||
// Buchungen brauchen die Stammdaten – im Trockenlauf sind die noch
|
||||
// nicht angelegt, deshalb bleibt es dort bei der Stammdatenvorschau,
|
||||
// sobald die Konten fehlen.
|
||||
$buchungen = null;
|
||||
if (!$this->post('nur_stammdaten')) {
|
||||
$buchungen = (new QifBuchungsImport($parser, ['stichtag' => $stichtag, 'bis' => $bis]))
|
||||
->ausfuehren($trockenlauf);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
View::redirect('/verwaltung/import', 'Import fehlgeschlagen: ' . $e->getMessage(), 'fehler');
|
||||
return;
|
||||
|
|
@ -90,6 +105,9 @@ class ImportController extends BaseController
|
|||
'dateien' => $this->dateien(),
|
||||
'verzeichnis' => $this->verzeichnisAnzeige(),
|
||||
'ergebnis' => $ergebnis + ['abschnitte' => $parser->zusammenfassung()],
|
||||
'buchungen' => $buchungen,
|
||||
'stichtag' => $stichtag,
|
||||
'bis' => $bis,
|
||||
'datei' => $name,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
408
src/Models/Buchung.php
Normal file
408
src/Models/Buchung.php
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Models;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
|
||||
/**
|
||||
* Buchungen samt Splitzeilen.
|
||||
*
|
||||
* Eine Umbuchung ist EINE Buchung mit `gegenkonto_id`. Das Kontoblatt des
|
||||
* Gegenkontos zeigt sie mit umgekehrtem Vorzeichen — es gibt also zwei Sichten
|
||||
* auf denselben Datensatz, keine zwei Datensätze. Auch einzelne Splitzeilen
|
||||
* dürfen ein Gegenkonto tragen; dann erscheint auf jenem Konto nur der Betrag
|
||||
* dieser Zeile.
|
||||
*/
|
||||
class Buchung
|
||||
{
|
||||
public const QUELLEN = ['manuell' => 'manuell erfasst', 'qif' => 'QIF-Import', 'fints' => 'Bankabruf'];
|
||||
|
||||
/** Beträge gelten als gleich, wenn sie sich um weniger als einen halben Cent unterscheiden. */
|
||||
private const CENT = 0.005;
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
'SELECT * FROM buchungen WHERE id = ? AND benutzer_id = ?',
|
||||
[$id, Mandant::id()]
|
||||
);
|
||||
return $zeile === false ? null : $zeile;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public static function splits(int $buchungId): array
|
||||
{
|
||||
return Database::fetchAll(
|
||||
'SELECT * FROM buchung_splits WHERE buchung_id = ? ORDER BY sortierung, id',
|
||||
[$buchungId]
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function adressen(int $buchungId): array
|
||||
{
|
||||
return array_column(
|
||||
Database::fetchAll(
|
||||
'SELECT text FROM buchungsadressen WHERE buchung_id = ? ORDER BY zeile',
|
||||
[$buchungId]
|
||||
),
|
||||
'text'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $daten
|
||||
* @param list<array<string,mixed>> $splits
|
||||
* @param list<string> $adressen
|
||||
*/
|
||||
public static function anlegen(array $daten, array $splits = [], array $adressen = []): int
|
||||
{
|
||||
$felder = self::normalisiere($daten, $splits);
|
||||
|
||||
return (int)Database::transaktion(static function () use ($felder, $splits, $adressen): int {
|
||||
Database::execute(
|
||||
'INSERT INTO buchungen
|
||||
(benutzer_id, konto_id, datum, valuta, betrag, empfaenger, buchungstext, nummer,
|
||||
abgeglichen, kategorie_id, klasse_id, gegenkonto_id, gesplittet, quelle, import_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $felder['konto_id'], $felder['datum'], $felder['valuta'],
|
||||
$felder['betrag'], $felder['empfaenger'], $felder['buchungstext'], $felder['nummer'],
|
||||
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $felder['quelle'], $felder['import_hash'],
|
||||
]
|
||||
);
|
||||
$id = Database::lastInsertId();
|
||||
|
||||
self::speichereSplits($id, $splits);
|
||||
self::speichereAdressen($id, $adressen);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $daten
|
||||
* @param list<array<string,mixed>> $splits
|
||||
* @param list<string> $adressen
|
||||
*/
|
||||
public static function speichern(int $id, array $daten, array $splits = [], array $adressen = []): void
|
||||
{
|
||||
if (self::find($id) === null) {
|
||||
throw new \RuntimeException('Die Buchung gibt es nicht.');
|
||||
}
|
||||
|
||||
$felder = self::normalisiere($daten, $splits);
|
||||
|
||||
Database::transaktion(static function () use ($id, $felder, $splits, $adressen): void {
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET konto_id = ?, datum = ?, valuta = ?, betrag = ?, empfaenger = ?,
|
||||
buchungstext = ?, nummer = ?, abgeglichen = ?, kategorie_id = ?, klasse_id = ?,
|
||||
gegenkonto_id = ?, gesplittet = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$felder['konto_id'], $felder['datum'], $felder['valuta'], $felder['betrag'],
|
||||
$felder['empfaenger'], $felder['buchungstext'], $felder['nummer'],
|
||||
$felder['abgeglichen'], $felder['kategorie_id'], $felder['klasse_id'],
|
||||
$felder['gegenkonto_id'], $felder['gesplittet'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
|
||||
Database::execute('DELETE FROM buchung_splits WHERE buchung_id = ?', [$id]);
|
||||
Database::execute('DELETE FROM buchungsadressen WHERE buchung_id = ?', [$id]);
|
||||
self::speichereSplits($id, $splits);
|
||||
self::speichereAdressen($id, $adressen);
|
||||
});
|
||||
}
|
||||
|
||||
public static function loeschen(int $id): void
|
||||
{
|
||||
Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Kontoblatt: eigene Buchungen und die Gegenseiten fremder Umbuchungen,
|
||||
* chronologisch mit laufendem Saldo.
|
||||
*
|
||||
* @param array<string,mixed> $filter suche, von, bis, kategorie_id, klasse_id, nur_offene
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function kontoblatt(int $kontoId, array $filter = []): array
|
||||
{
|
||||
$params = [$kontoId, $kontoId, $kontoId];
|
||||
|
||||
$sql = 'SELECT * FROM (
|
||||
SELECT b.id, b.datum, b.valuta, b.betrag, b.empfaenger, b.buchungstext, b.nummer,
|
||||
b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
|
||||
b.gegenkonto_id, b.konto_id, 0 AS gegensicht, NULL AS split_id
|
||||
FROM buchungen b
|
||||
WHERE b.konto_id = ?
|
||||
UNION ALL
|
||||
SELECT b.id, b.datum, b.valuta, -b.betrag, b.empfaenger, b.buchungstext, b.nummer,
|
||||
b.abgeglichen, b.kategorie_id, b.klasse_id, b.gesplittet, b.quelle,
|
||||
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, NULL AS split_id
|
||||
FROM buchungen b
|
||||
WHERE b.gegenkonto_id = ? AND b.gesplittet = 0
|
||||
UNION ALL
|
||||
SELECT b.id, b.datum, b.valuta, -s.betrag, b.empfaenger, s.notiz, b.nummer,
|
||||
b.abgeglichen, s.kategorie_id, s.klasse_id, 0 AS gesplittet, b.quelle,
|
||||
b.konto_id AS gegenkonto_id, b.konto_id, 1 AS gegensicht, s.id AS split_id
|
||||
FROM buchung_splits s
|
||||
JOIN buchungen b ON b.id = s.buchung_id
|
||||
WHERE s.gegenkonto_id = ?
|
||||
) AS blatt';
|
||||
|
||||
$wo = [];
|
||||
if (!empty($filter['von'])) {
|
||||
$wo[] = 'datum >= ?';
|
||||
$params[] = $filter['von'];
|
||||
}
|
||||
if (!empty($filter['bis'])) {
|
||||
$wo[] = 'datum <= ?';
|
||||
$params[] = $filter['bis'];
|
||||
}
|
||||
if (!empty($filter['kategorie_id'])) {
|
||||
$wo[] = 'kategorie_id = ?';
|
||||
$params[] = (int)$filter['kategorie_id'];
|
||||
}
|
||||
if (!empty($filter['klasse_id'])) {
|
||||
$wo[] = 'klasse_id = ?';
|
||||
$params[] = (int)$filter['klasse_id'];
|
||||
}
|
||||
if (!empty($filter['nur_offene'])) {
|
||||
$wo[] = 'abgeglichen = 0';
|
||||
}
|
||||
if (($filter['suche'] ?? '') !== '') {
|
||||
$wo[] = '(empfaenger LIKE ? OR buchungstext LIKE ? OR nummer LIKE ?)';
|
||||
$muster = '%' . $filter['suche'] . '%';
|
||||
$params[] = $muster;
|
||||
$params[] = $muster;
|
||||
$params[] = $muster;
|
||||
}
|
||||
|
||||
if ($wo !== []) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $wo);
|
||||
}
|
||||
$sql .= ' ORDER BY datum, id, split_id';
|
||||
|
||||
$zeilen = Database::fetchAll($sql, $params);
|
||||
|
||||
// Laufender Saldo. Er beginnt beim Anfangssaldo des Kontos, aber nur in
|
||||
// der ungefilterten Ansicht – bei gesetztem Filter wäre er irreführend.
|
||||
$gefiltert = $wo !== [];
|
||||
$saldo = $gefiltert ? 0.0 : (float)(Konto::find($kontoId)['anfangssaldo'] ?? 0);
|
||||
|
||||
foreach ($zeilen as $i => $zeile) {
|
||||
$saldo += (float)$zeile['betrag'];
|
||||
$zeilen[$i]['saldo'] = $gefiltert ? null : $saldo;
|
||||
$zeilen[$i]['gegensicht'] = (int)$zeile['gegensicht'] === 1;
|
||||
}
|
||||
|
||||
return $zeilen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Salden aller Konten in einem Rutsch: Anfangssaldo plus Buchungen, plus die
|
||||
* Gegenseiten fremder Umbuchungen. Ohne Stichtag zählt alles (erwarteter
|
||||
* Saldo), mit Stichtag nur bis dahin (aktueller Saldo).
|
||||
*
|
||||
* @return array<int,float> konto_id => Saldo
|
||||
*/
|
||||
public static function saldenJeKonto(?string $bis = null): array
|
||||
{
|
||||
$benutzer = Mandant::id();
|
||||
$salden = [];
|
||||
|
||||
$grenze = $bis === null ? '' : ' AND b.datum <= ?';
|
||||
$p = $bis === null ? [$benutzer] : [$benutzer, $bis];
|
||||
|
||||
$quellen = [
|
||||
'SELECT b.konto_id AS k, SUM(b.betrag) AS s
|
||||
FROM buchungen b WHERE b.benutzer_id = ?' . $grenze . ' GROUP BY b.konto_id',
|
||||
|
||||
'SELECT b.gegenkonto_id AS k, -SUM(b.betrag) AS s
|
||||
FROM buchungen b
|
||||
WHERE b.benutzer_id = ? AND b.gegenkonto_id IS NOT NULL AND b.gesplittet = 0'
|
||||
. $grenze . ' GROUP BY b.gegenkonto_id',
|
||||
|
||||
'SELECT s.gegenkonto_id AS k, -SUM(s.betrag) AS s
|
||||
FROM buchung_splits s
|
||||
JOIN buchungen b ON b.id = s.buchung_id
|
||||
WHERE b.benutzer_id = ? AND s.gegenkonto_id IS NOT NULL'
|
||||
. $grenze . ' GROUP BY s.gegenkonto_id',
|
||||
];
|
||||
|
||||
foreach ($quellen as $sql) {
|
||||
foreach (Database::fetchAll($sql, $p) as $zeile) {
|
||||
$id = (int)$zeile['k'];
|
||||
$salden[$id] = ($salden[$id] ?? 0.0) + (float)$zeile['s'];
|
||||
}
|
||||
}
|
||||
|
||||
return $salden;
|
||||
}
|
||||
|
||||
/** Wie viele Buchungen hat das Konto (beide Sichten)? */
|
||||
public static function anzahlFuerKonto(int $kontoId): int
|
||||
{
|
||||
return (int)Database::fetchValue(
|
||||
'SELECT (SELECT COUNT(*) FROM buchungen WHERE konto_id = ?)
|
||||
+ (SELECT COUNT(*) FROM buchungen WHERE gegenkonto_id = ? AND gesplittet = 0)
|
||||
+ (SELECT COUNT(*) FROM buchung_splits WHERE gegenkonto_id = ?)',
|
||||
[$kontoId, $kontoId, $kontoId],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static function anzahl(): int
|
||||
{
|
||||
return (int)Database::fetchValue(
|
||||
'SELECT COUNT(*) FROM buchungen WHERE benutzer_id = ?',
|
||||
[Mandant::id()],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerabdruck für die Dublettenerkennung. Bewusst über die Merkmale, die
|
||||
* eine Bank liefert – eine zweite Buchung mit identischem Konto, Datum,
|
||||
* Betrag, Nummer und Text ist im Alltag dieselbe Buchung.
|
||||
*/
|
||||
public static function hash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string
|
||||
{
|
||||
return sha1(implode('|', [
|
||||
$kontoId, $datum, number_format($betrag, 2, '.', ''), $nummer, trim($text),
|
||||
]));
|
||||
}
|
||||
|
||||
public static function findeNachHash(string $hash): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
'SELECT * FROM buchungen WHERE import_hash = ? AND benutzer_id = ?',
|
||||
[$hash, Mandant::id()]
|
||||
);
|
||||
return $zeile === false ? null : $zeile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $daten
|
||||
* @param list<array<string,mixed>> $splits
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalisiere(array $daten, array $splits): array
|
||||
{
|
||||
$kontoId = (int)($daten['konto_id'] ?? 0);
|
||||
if ($kontoId === 0 || Konto::find($kontoId) === null) {
|
||||
throw new \InvalidArgumentException('Die Buchung braucht ein gültiges Konto.');
|
||||
}
|
||||
|
||||
$datum = trim((string)($daten['datum'] ?? ''));
|
||||
if (!self::istDatum($datum)) {
|
||||
throw new \InvalidArgumentException('Die Buchung braucht ein gültiges Datum (JJJJ-MM-TT).');
|
||||
}
|
||||
|
||||
$betrag = round((float)($daten['betrag'] ?? 0), 2);
|
||||
$gegenkonto = self::id($daten['gegenkonto_id'] ?? null);
|
||||
$kategorie = self::id($daten['kategorie_id'] ?? null);
|
||||
$gesplittet = $splits !== [];
|
||||
|
||||
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
|
||||
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
|
||||
}
|
||||
if ($gesplittet && ($kategorie !== null || $gegenkonto !== null)) {
|
||||
// Bei einer Splitbuchung steckt die Zuordnung in den Zeilen.
|
||||
$kategorie = null;
|
||||
$gegenkonto = null;
|
||||
}
|
||||
|
||||
if ($gesplittet) {
|
||||
$summe = 0.0;
|
||||
foreach ($splits as $split) {
|
||||
$summe += round((float)($split['betrag'] ?? 0), 2);
|
||||
}
|
||||
if (abs($summe - $betrag) > self::CENT) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Die Splitzeilen ergeben %s, die Buchung lautet über %s.',
|
||||
number_format($summe, 2, ',', '.'),
|
||||
number_format($betrag, 2, ',', '.')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$valuta = trim((string)($daten['valuta'] ?? ''));
|
||||
|
||||
return [
|
||||
'konto_id' => $kontoId,
|
||||
'datum' => $datum,
|
||||
'valuta' => self::istDatum($valuta) ? $valuta : null,
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
|
||||
'buchungstext' => trim((string)($daten['buchungstext'] ?? '')),
|
||||
'nummer' => mb_substr(trim((string)($daten['nummer'] ?? '')), 0, 32),
|
||||
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
|
||||
'kategorie_id' => $kategorie,
|
||||
'klasse_id' => self::id($daten['klasse_id'] ?? null),
|
||||
'gegenkonto_id' => $gegenkonto,
|
||||
'gesplittet' => $gesplittet ? 1 : 0,
|
||||
'quelle' => isset(self::QUELLEN[(string)($daten['quelle'] ?? '')])
|
||||
? (string)$daten['quelle'] : 'manuell',
|
||||
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $splits */
|
||||
private static function speichereSplits(int $buchungId, array $splits): void
|
||||
{
|
||||
foreach (array_values($splits) as $nr => $split) {
|
||||
Database::execute(
|
||||
'INSERT INTO buchung_splits
|
||||
(buchung_id, kategorie_id, klasse_id, gegenkonto_id, betrag, notiz, sortierung)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$buchungId,
|
||||
self::id($split['kategorie_id'] ?? null),
|
||||
self::id($split['klasse_id'] ?? null),
|
||||
self::id($split['gegenkonto_id'] ?? null),
|
||||
round((float)($split['betrag'] ?? 0), 2),
|
||||
mb_substr(trim((string)($split['notiz'] ?? '')), 0, 255),
|
||||
$nr,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $adressen */
|
||||
private static function speichereAdressen(int $buchungId, array $adressen): void
|
||||
{
|
||||
$zeile = 0;
|
||||
foreach ($adressen as $text) {
|
||||
$text = trim((string)$text);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
Database::execute(
|
||||
'INSERT INTO buchungsadressen (buchung_id, zeile, text) VALUES (?, ?, ?)',
|
||||
[$buchungId, ++$zeile, mb_substr($text, 0, 255)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function id(mixed $wert): ?int
|
||||
{
|
||||
if ($wert === null || $wert === '' || (int)$wert === 0) {
|
||||
return null;
|
||||
}
|
||||
return (int)$wert;
|
||||
}
|
||||
|
||||
public static function istDatum(string $datum): bool
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $datum, $t)) {
|
||||
return false;
|
||||
}
|
||||
return checkdate((int)$t[2], (int)$t[3], (int)$t[1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -107,13 +107,13 @@ class Konto
|
|||
Database::execute(
|
||||
'INSERT INTO konten
|
||||
(benutzer_id, kontengruppe_id, name, kontoart, bankname, kontonummer, iban, bic,
|
||||
beschreibung, anfangssaldo, kreditlimit, waehrung, aktiv, sortierung)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
beschreibung, anfangssaldo, kreditlimit, waehrung, aktiv, in_summe, sortierung)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
Mandant::id(), $felder['kontengruppe_id'], $name, $felder['kontoart'],
|
||||
$felder['bankname'], $felder['kontonummer'], $felder['iban'], $felder['bic'],
|
||||
$felder['beschreibung'], $felder['anfangssaldo'], $felder['kreditlimit'],
|
||||
$felder['waehrung'], $felder['aktiv'], $felder['sortierung'],
|
||||
$felder['waehrung'], $felder['aktiv'], $felder['in_summe'], $felder['sortierung'],
|
||||
]
|
||||
);
|
||||
return Database::lastInsertId();
|
||||
|
|
@ -139,13 +139,13 @@ class Konto
|
|||
Database::execute(
|
||||
'UPDATE konten SET kontengruppe_id = ?, name = ?, kontoart = ?, bankname = ?,
|
||||
kontonummer = ?, iban = ?, bic = ?, beschreibung = ?, anfangssaldo = ?,
|
||||
kreditlimit = ?, waehrung = ?, aktiv = ?, sortierung = ?
|
||||
kreditlimit = ?, waehrung = ?, aktiv = ?, in_summe = ?, sortierung = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$felder['kontengruppe_id'], $name, $felder['kontoart'], $felder['bankname'],
|
||||
$felder['kontonummer'], $felder['iban'], $felder['bic'], $felder['beschreibung'],
|
||||
$felder['anfangssaldo'], $felder['kreditlimit'], $felder['waehrung'],
|
||||
$felder['aktiv'], $felder['sortierung'], $id, Mandant::id(),
|
||||
$felder['aktiv'], $felder['in_summe'], $felder['sortierung'], $id, Mandant::id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -168,53 +168,86 @@ class Konto
|
|||
* Kontenübersicht: Konten nach Kontengruppe gebündelt, je Gruppe eine Summe
|
||||
* und darunter die Gesamtsumme.
|
||||
*
|
||||
* Die drei Saldospalten des FinanzManagers stehen schon jetzt in den Daten:
|
||||
* Online-Saldo (Stand des letzten Bankabrufs, Phase 5), aktueller Saldo
|
||||
* (gebuchte Umsätze bis heute) und erwarteter Saldo (einschließlich künftig
|
||||
* datierter Buchungen). Solange es keine Buchungen gibt, entsprechen alle
|
||||
* drei dem Anfangssaldo.
|
||||
* Die drei Saldospalten des FinanzManagers: Online-Saldo (Stand des letzten
|
||||
* Bankabrufs, Phase 5), aktueller Saldo (Buchungen bis heute) und erwarteter
|
||||
* Saldo (einschließlich künftig datierter Buchungen).
|
||||
*
|
||||
* @return array{gruppen: list<array<string,mixed>>, summe: float}
|
||||
* In die Summen zählen nur Konten mit gesetztem Kennzeichen `in_summe` –
|
||||
* wie die Σ-Spalte im FinanzManager. Rentenversicherungen und durchlaufende
|
||||
* Auslagen bleiben so außen vor, erscheinen aber weiter in der Liste.
|
||||
*
|
||||
* @return array{gruppen: list<array<string,mixed>>, summe: float, summe_online: float}
|
||||
*/
|
||||
public static function uebersicht(bool $nurAktive = true): array
|
||||
{
|
||||
$aktuell = Buchung::saldenJeKonto(date('Y-m-d'));
|
||||
$erwartet = Buchung::saldenJeKonto();
|
||||
|
||||
$gruppen = [];
|
||||
$summe = 0.0;
|
||||
$summeOnline = 0.0;
|
||||
|
||||
foreach (self::alle($nurAktive) as $konto) {
|
||||
$konto = self::mitSalden($konto);
|
||||
$konto = self::mitSalden($konto, $aktuell, $erwartet);
|
||||
$name = $konto['gruppenname'] ?? null;
|
||||
$name = ($name === null || $name === '') ? Kontengruppe::OHNE_GRUPPE : $name;
|
||||
|
||||
if (!isset($gruppen[$name])) {
|
||||
$gruppen[$name] = ['name' => $name, 'konten' => [], 'summe' => 0.0];
|
||||
$gruppen[$name] = ['name' => $name, 'konten' => [], 'summe' => 0.0, 'summe_online' => 0.0];
|
||||
}
|
||||
$gruppen[$name]['konten'][] = $konto;
|
||||
|
||||
if ((int)$konto['in_summe'] === 1) {
|
||||
$gruppen[$name]['summe'] += $konto['saldo_erwartet'];
|
||||
$gruppen[$name]['summe_online'] += (float)($konto['saldo_online'] ?? 0);
|
||||
$summe += $konto['saldo_erwartet'];
|
||||
$summeOnline += (float)($konto['saldo_online'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return ['gruppen' => array_values($gruppen), 'summe' => $summe];
|
||||
return [
|
||||
'gruppen' => array_values($gruppen),
|
||||
'summe' => $summe,
|
||||
'summe_online' => $summeOnline,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergänzt die Saldospalten. Ab Phase 2 kommen hier die Buchungen dazu –
|
||||
* die Aufrufer bleiben davon unberührt.
|
||||
* Ergänzt die Saldospalten eines Kontos. Die Salden können vorberechnet
|
||||
* übergeben werden – die Kontenübersicht holt sie für alle Konten in einem
|
||||
* Rutsch, statt je Konto zu fragen.
|
||||
*
|
||||
* @param array<string,mixed> $konto
|
||||
* @param array<int,float>|null $aktuell
|
||||
* @param array<int,float>|null $erwartet
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function mitSalden(array $konto): array
|
||||
public static function mitSalden(array $konto, ?array $aktuell = null, ?array $erwartet = null): array
|
||||
{
|
||||
$id = (int)$konto['id'];
|
||||
$anfang = (float)$konto['anfangssaldo'];
|
||||
|
||||
$konto['saldo_online'] = null;
|
||||
$konto['saldo_aktuell'] = $anfang;
|
||||
$konto['saldo_erwartet'] = $anfang;
|
||||
$aktuell ??= Buchung::saldenJeKonto(date('Y-m-d'));
|
||||
$erwartet ??= Buchung::saldenJeKonto();
|
||||
|
||||
$konto['saldo_aktuell'] = $anfang + ($aktuell[$id] ?? 0.0);
|
||||
$konto['saldo_erwartet'] = $anfang + ($erwartet[$id] ?? 0.0);
|
||||
|
||||
return $konto;
|
||||
}
|
||||
|
||||
/** Saldo eines einzelnen Kontos – für Kontoblatt und Buchungsmaske. */
|
||||
public static function saldo(int $kontoId, ?string $bis = null): float
|
||||
{
|
||||
$konto = self::find($kontoId);
|
||||
if ($konto === null) {
|
||||
return 0.0;
|
||||
}
|
||||
$salden = Buchung::saldenJeKonto($bis);
|
||||
|
||||
return (float)$konto['anfangssaldo'] + ($salden[$kontoId] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $daten
|
||||
* @return array<string,mixed>
|
||||
|
|
@ -244,6 +277,8 @@ class Konto
|
|||
'kreditlimit' => $limit,
|
||||
'waehrung' => strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR',
|
||||
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
|
||||
// Standard ist "zählt zur Summe"; nur ein ausdrückliches Nein schaltet es ab.
|
||||
'in_summe' => array_key_exists('in_summe', $daten) && empty($daten['in_summe']) ? 0 : 1,
|
||||
'sortierung' => (int)($daten['sortierung'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
511
src/Services/QifBuchungsImport.php
Normal file
511
src/Services/QifBuchungsImport.php
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
|
||||
/**
|
||||
* Übernimmt die Buchungen eines QIF-Exports.
|
||||
*
|
||||
* Zwei Eigenheiten des Formats bestimmen den Ablauf:
|
||||
*
|
||||
* 1. **Umbuchungen stehen nur einmal in der Datei** – in dem Konto, in dem sie
|
||||
* erfasst wurden; die Gegenseite ist allein durch `L[Zielkonto]` benannt.
|
||||
* Hier wird daraus eine Buchung mit `gegenkonto_id`, die auf beiden Konten
|
||||
* erscheint (auf dem Gegenkonto mit umgekehrtem Vorzeichen).
|
||||
* 2. **Der Export reicht weiter zurück, als gebraucht wird.** Alles vor dem
|
||||
* Stichtag fließt nicht als Buchung ein, sondern wird je Konto zum
|
||||
* Anfangssaldo aufsummiert – einschließlich der Gegenseiten. Die Salden
|
||||
* stimmen dadurch exakt, ohne die ganze Historie mitzuschleppen.
|
||||
*
|
||||
* Buchungen nach dem Enddatum sind Vorausberechnungen des FinanzManagers und
|
||||
* werden übersprungen.
|
||||
*/
|
||||
class QifBuchungsImport
|
||||
{
|
||||
/** Sektionen mit Geldbuchungen. Invst wird gesondert behandelt. */
|
||||
private const GELDTYPEN = ['Bank', 'CCard', 'Cash', 'Oth A', 'Oth L', 'Oth T', 'Oth P', 'Oth E', 'Oth F'];
|
||||
|
||||
private QifParser $parser;
|
||||
private string $stichtag;
|
||||
private string $bis;
|
||||
|
||||
/** @var array<string,int> Kontoname → id */
|
||||
private array $konten = [];
|
||||
/** @var array<string,int> Kategoriepfad → id */
|
||||
private array $kategorien = [];
|
||||
/** @var array<string,int> Klassenname → id */
|
||||
private array $klassen = [];
|
||||
/** @var array<string,int> Basis-Hash → wie oft schon gesehen */
|
||||
private array $hashZaehler = [];
|
||||
|
||||
/** @var list<array{typ: string, text: string}> */
|
||||
private array $meldungen = [];
|
||||
|
||||
/**
|
||||
* @param array{stichtag?: string, bis?: string} $optionen
|
||||
*/
|
||||
public function __construct(QifParser $parser, array $optionen = [])
|
||||
{
|
||||
$this->parser = $parser;
|
||||
$this->stichtag = $optionen['stichtag'] ?? '2026-01-01';
|
||||
$this->bis = $optionen['bis'] ?? date('Y-m-d');
|
||||
|
||||
if (!Buchung::istDatum($this->stichtag) || !Buchung::istDatum($this->bis)) {
|
||||
throw new \InvalidArgumentException('Stichtag und Enddatum müssen als JJJJ-MM-TT vorliegen.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function ausfuehren(bool $trockenlauf = false): array
|
||||
{
|
||||
$this->meldungen = [];
|
||||
$this->hashZaehler = [];
|
||||
$this->ladeStammdaten();
|
||||
|
||||
$lauf = function () use ($trockenlauf): array {
|
||||
$anfang = $this->anfangssalden($trockenlauf);
|
||||
$buchungen = $this->buchungen($trockenlauf);
|
||||
|
||||
return [
|
||||
'anfangssalden' => $anfang,
|
||||
'buchungen' => $buchungen,
|
||||
'stichtag' => $this->stichtag,
|
||||
'bis' => $this->bis,
|
||||
'trockenlauf' => $trockenlauf,
|
||||
];
|
||||
};
|
||||
|
||||
$ergebnis = $trockenlauf ? $lauf() : Database::transaktion($lauf);
|
||||
$ergebnis['meldungen'] = $this->meldungen;
|
||||
|
||||
return $ergebnis;
|
||||
}
|
||||
|
||||
public function vorschau(): array
|
||||
{
|
||||
return $this->ausfuehren(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summiert alles vor dem Stichtag je Konto auf – die Gegenseiten der
|
||||
* Umbuchungen eingeschlossen – und schreibt das Ergebnis als Anfangssaldo.
|
||||
*
|
||||
* @return array{konten: int, summe: float, salden: array<string,float>}
|
||||
*/
|
||||
private function anfangssalden(bool $trockenlauf): array
|
||||
{
|
||||
$salden = [];
|
||||
|
||||
$bucheAuf = static function (string $konto, float $betrag) use (&$salden): void {
|
||||
$salden[$konto] = ($salden[$konto] ?? 0.0) + $betrag;
|
||||
};
|
||||
|
||||
foreach ($this->parser->eintraege() as $eintrag) {
|
||||
if (!$this->istGeldbuchung($eintrag) && $eintrag['typ'] !== 'Invst') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||||
if ($datum === null || $datum >= $this->stichtag) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$konto = (string)$eintrag['konto'];
|
||||
|
||||
if ($eintrag['typ'] === 'Invst') {
|
||||
// Vor dem Stichtag zählt vom Wertpapiergeschäft nur der
|
||||
// Geldfluss auf dem Verrechnungskonto.
|
||||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||||
$wert = QifParser::feld($eintrag, '$');
|
||||
if ($ziel !== null && $wert !== null) {
|
||||
$bucheAuf($ziel, -QifParser::betrag($wert));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
|
||||
$bucheAuf($konto, $betrag);
|
||||
|
||||
foreach ($this->gegenbuchungen($eintrag, $konto, $betrag) as [$zielKonto, $zielBetrag]) {
|
||||
$bucheAuf($zielKonto, $zielBetrag);
|
||||
}
|
||||
}
|
||||
|
||||
$gesetzt = 0;
|
||||
foreach ($salden as $name => $betrag) {
|
||||
$kontoId = $this->kontoId($name);
|
||||
if ($kontoId === null) {
|
||||
$this->melde('fehler', 'Anfangssaldo für unbekanntes Konto „' . $name . '“ verworfen.');
|
||||
continue;
|
||||
}
|
||||
if (!$trockenlauf) {
|
||||
Database::execute(
|
||||
'UPDATE konten SET anfangssaldo = ? WHERE id = ?',
|
||||
[round($betrag, 2), $kontoId]
|
||||
);
|
||||
}
|
||||
$gesetzt++;
|
||||
}
|
||||
|
||||
return [
|
||||
'konten' => $gesetzt,
|
||||
'summe' => round(array_sum($salden), 2),
|
||||
'salden' => $salden,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function buchungen(bool $trockenlauf): array
|
||||
{
|
||||
$zaehler = [
|
||||
'neu' => 0, 'vorhanden' => 0, 'vor_stichtag' => 0,
|
||||
'zukunft' => 0, 'wertpapier' => 0, 'fehler' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->parser->eintraege() as $eintrag) {
|
||||
$istGeld = $this->istGeldbuchung($eintrag);
|
||||
if (!$istGeld && $eintrag['typ'] !== 'Invst') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$datum = QifParser::datum(QifParser::feld($eintrag, 'D'), $this->parser->datumsformat());
|
||||
if ($datum === null) {
|
||||
$zaehler['fehler']++;
|
||||
$this->melde('fehler', 'Buchung ohne lesbares Datum übersprungen (' . $eintrag['konto'] . ').');
|
||||
continue;
|
||||
}
|
||||
if ($datum < $this->stichtag) {
|
||||
$zaehler['vor_stichtag']++;
|
||||
continue;
|
||||
}
|
||||
if ($datum > $this->bis) {
|
||||
$zaehler['zukunft']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$angelegt = $istGeld
|
||||
? $this->geldbuchung($eintrag, $datum, $trockenlauf)
|
||||
: $this->wertpapierbuchung($eintrag, $datum, $trockenlauf);
|
||||
|
||||
if ($angelegt === null) {
|
||||
$zaehler['vorhanden']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$zaehler['neu']++;
|
||||
if (!$istGeld) {
|
||||
$zaehler['wertpapier']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zaehler['fehler']++;
|
||||
$this->melde('fehler', $datum . ' ' . $eintrag['konto'] . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ($zaehler['wertpapier'] > 0) {
|
||||
$this->melde('hinweis', $zaehler['wertpapier'] . ' Wertpapiergeschäfte: übernommen wurde nur der '
|
||||
. 'Geldfluss auf dem Verrechnungskonto. Bestände und Kurse folgen in Phase 6.');
|
||||
}
|
||||
|
||||
return $zaehler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{typ: string, konto: ?string, zeilen: list<array{0: string, 1: string}>} $eintrag
|
||||
* @return int|null Kennung der neuen Buchung, oder null wenn sie schon da war.
|
||||
*/
|
||||
private function geldbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||||
{
|
||||
$kontoName = (string)$eintrag['konto'];
|
||||
$kontoId = $this->kontoId($kontoName);
|
||||
if ($kontoId === null) {
|
||||
throw new \RuntimeException('Unbekanntes Konto „' . $kontoName . '“.');
|
||||
}
|
||||
|
||||
$betrag = QifParser::betrag(QifParser::feld($eintrag, 'U') ?? QifParser::feld($eintrag, 'T'));
|
||||
$text = (string)QifParser::feld($eintrag, 'M');
|
||||
$nummer = (string)QifParser::feld($eintrag, 'N');
|
||||
|
||||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $nummer, $text);
|
||||
if (Buchung::findeNachHash($hash) !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$zuordnung = $this->zuordnung((string)QifParser::feld($eintrag, 'L'), $kontoId);
|
||||
$splits = $this->splitzeilen($eintrag, $kontoId);
|
||||
|
||||
$daten = [
|
||||
'konto_id' => $kontoId,
|
||||
'datum' => $datum,
|
||||
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => (string)QifParser::feld($eintrag, 'P'),
|
||||
'buchungstext' => $text,
|
||||
'nummer' => $nummer,
|
||||
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $zuordnung['klasse_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'quelle' => 'qif',
|
||||
'import_hash' => $hash,
|
||||
];
|
||||
|
||||
if ($trockenlauf) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Buchung::anlegen($daten, $splits, QifParser::felder($eintrag, 'A'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wertpapiergeschäfte schlagen in Phase 2 nur auf dem Verrechnungskonto zu
|
||||
* Buche. Bestand, Einstandswert und Kurse folgen in Phase 6 – bis dahin
|
||||
* bliebe ein Betrag auf dem Depot ohne Gegenwert und würde die Summe der
|
||||
* Kontostände verfälschen.
|
||||
*/
|
||||
private function wertpapierbuchung(array $eintrag, string $datum, bool $trockenlauf): ?int
|
||||
{
|
||||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||||
$wert = QifParser::feld($eintrag, '$');
|
||||
if ($ziel === null || $wert === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$kontoId = $this->kontoId($ziel);
|
||||
if ($kontoId === null) {
|
||||
throw new \RuntimeException('Unbekanntes Verrechnungskonto „' . $ziel . '“.');
|
||||
}
|
||||
|
||||
$betrag = -QifParser::betrag($wert);
|
||||
$text = (string)QifParser::feld($eintrag, 'M');
|
||||
$vorgang = (string)QifParser::feld($eintrag, 'N');
|
||||
$papier = (string)QifParser::feld($eintrag, 'Y');
|
||||
|
||||
$hash = $this->eindeutigerHash($kontoId, $datum, $betrag, $vorgang, $text);
|
||||
if (Buchung::findeNachHash($hash) !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($trockenlauf) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Buchung::anlegen([
|
||||
'konto_id' => $kontoId,
|
||||
'datum' => $datum,
|
||||
'valuta' => QifParser::datum(QifParser::feld($eintrag, 'V'), $this->parser->datumsformat()),
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => $papier !== '' ? $papier : (string)$eintrag['konto'],
|
||||
'buchungstext' => trim($vorgang . ' ' . $text),
|
||||
'nummer' => '',
|
||||
'abgeglichen' => QifParser::feld($eintrag, 'C') !== null,
|
||||
'quelle' => 'qif',
|
||||
'import_hash' => $hash,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Konten bekommen aus dieser Buchung eine Gegenbewegung, und mit
|
||||
* welchem Betrag? Ohne Splits genau eines, mit Splits je Zeile eines.
|
||||
*
|
||||
* @return list<array{0: string, 1: float}>
|
||||
*/
|
||||
private function gegenbuchungen(array $eintrag, string $konto, float $betrag): array
|
||||
{
|
||||
$ziele = [];
|
||||
$splits = QifParser::felder($eintrag, 'S');
|
||||
|
||||
if ($splits !== []) {
|
||||
$betraege = QifParser::felder($eintrag, '$');
|
||||
foreach ($splits as $nr => $angabe) {
|
||||
$ziel = self::umbuchungsziel($angabe);
|
||||
if ($ziel !== null && $ziel !== $konto) {
|
||||
$ziele[] = [$ziel, -QifParser::betrag($betraege[$nr] ?? '0')];
|
||||
}
|
||||
}
|
||||
return $ziele;
|
||||
}
|
||||
|
||||
$ziel = self::umbuchungsziel(QifParser::feld($eintrag, 'L'));
|
||||
if ($ziel !== null && $ziel !== $konto) {
|
||||
$ziele[] = [$ziel, -$betrag];
|
||||
}
|
||||
|
||||
return $ziele;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function splitzeilen(array $eintrag, int $kontoId): array
|
||||
{
|
||||
$kategorien = QifParser::felder($eintrag, 'S');
|
||||
if ($kategorien === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$betraege = QifParser::felder($eintrag, '$');
|
||||
$notizen = QifParser::felder($eintrag, 'E');
|
||||
$zeilen = [];
|
||||
|
||||
foreach ($kategorien as $nr => $angabe) {
|
||||
$zuordnung = $this->zuordnung($angabe, $kontoId);
|
||||
$zeilen[] = [
|
||||
'kategorie_id' => $zuordnung['kategorie_id'],
|
||||
'klasse_id' => $zuordnung['klasse_id'],
|
||||
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
|
||||
'betrag' => QifParser::betrag($betraege[$nr] ?? '0'),
|
||||
'notiz' => $notizen[$nr] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $zeilen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst eine L- oder S-Angabe auf: Kategorie und Klasse, oder ein Gegenkonto.
|
||||
*
|
||||
* Verweist die Buchung auf ihr eigenes Konto, ist es die Eröffnungsbuchung
|
||||
* des FinanzManagers – daraus wird keine Umbuchung, sonst hübe sie sich auf.
|
||||
*
|
||||
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int}
|
||||
*/
|
||||
private function zuordnung(string $angabe, int $kontoId): array
|
||||
{
|
||||
$leer = ['kategorie_id' => null, 'klasse_id' => null, 'gegenkonto_id' => null];
|
||||
if (trim($angabe) === '') {
|
||||
return $leer;
|
||||
}
|
||||
|
||||
$teile = Kategorie::zerlegeQifAngabe($angabe);
|
||||
$klasseId = $teile['klasse'] !== '' ? $this->klasseId($teile['klasse']) : null;
|
||||
|
||||
$ziel = self::umbuchungsziel($teile['kategorie']);
|
||||
if ($ziel !== null) {
|
||||
$gegen = $this->kontoId($ziel);
|
||||
if ($gegen === null) {
|
||||
$this->melde('fehler', 'Umbuchung auf unbekanntes Konto „' . $ziel . '“ – ohne Gegenkonto übernommen.');
|
||||
return ['kategorie_id' => null, 'klasse_id' => $klasseId, 'gegenkonto_id' => null];
|
||||
}
|
||||
return [
|
||||
'kategorie_id' => null,
|
||||
'klasse_id' => $klasseId,
|
||||
'gegenkonto_id' => $gegen === $kontoId ? null : $gegen,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'kategorie_id' => $teile['kategorie'] !== '' ? $this->kategorieId($teile['kategorie']) : null,
|
||||
'klasse_id' => $klasseId,
|
||||
'gegenkonto_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/** `[Konto]` oder `|[Konto]` (Wertpapiere) → Kontoname, sonst null. */
|
||||
public static function umbuchungsziel(?string $angabe): ?string
|
||||
{
|
||||
if ($angabe === null) {
|
||||
return null;
|
||||
}
|
||||
$angabe = ltrim(trim($angabe), '|');
|
||||
if (!str_starts_with($angabe, '[')) {
|
||||
return null;
|
||||
}
|
||||
$ende = strpos($angabe, ']');
|
||||
return $ende === false ? null : substr($angabe, 1, $ende - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identische Buchungen am selben Tag kommen vor (zweimal dasselbe im selben
|
||||
* Laden). Der laufende Zähler hält sie auseinander und bleibt über Läufe
|
||||
* hinweg stabil, weil die Datei immer dieselbe Reihenfolge hat.
|
||||
*/
|
||||
private function eindeutigerHash(int $kontoId, string $datum, float $betrag, string $nummer, string $text): string
|
||||
{
|
||||
$basis = Buchung::hash($kontoId, $datum, $betrag, $nummer, $text);
|
||||
$n = $this->hashZaehler[$basis] = ($this->hashZaehler[$basis] ?? 0) + 1;
|
||||
|
||||
return $n === 1 ? $basis : sha1($basis . '#' . $n);
|
||||
}
|
||||
|
||||
private function ladeStammdaten(): void
|
||||
{
|
||||
$this->konten = [];
|
||||
$this->kategorien = [];
|
||||
$this->klassen = [];
|
||||
|
||||
foreach (Konto::alle() as $konto) {
|
||||
$this->konten[$konto['name']] = (int)$konto['id'];
|
||||
}
|
||||
foreach (Kategorie::alle() as $kategorie) {
|
||||
$this->kategorien[$kategorie['pfad']] = (int)$kategorie['id'];
|
||||
}
|
||||
foreach (Klasse::alle() as $klasse) {
|
||||
$this->klassen[$klasse['name']] = (int)$klasse['id'];
|
||||
}
|
||||
}
|
||||
|
||||
private function kontoId(string $name): ?int
|
||||
{
|
||||
return $this->konten[trim($name)] ?? null;
|
||||
}
|
||||
|
||||
/** Fehlt die Kategorie, wird sie angelegt – der Export nennt nicht immer alle. */
|
||||
private function kategorieId(string $pfad): ?int
|
||||
{
|
||||
$pfad = Kategorie::normalisierePfad($pfad);
|
||||
if ($pfad === '') {
|
||||
return null;
|
||||
}
|
||||
if (isset($this->kategorien[$pfad])) {
|
||||
return $this->kategorien[$pfad];
|
||||
}
|
||||
|
||||
$id = Kategorie::findenOderAnlegenNachPfad($pfad);
|
||||
$this->kategorien[$pfad] = $id;
|
||||
$this->melde('hinweis', 'Kategorie „' . $pfad . '“ war nicht angelegt und wurde ergänzt.');
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function klasseId(string $name): ?int
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
if (!isset($this->klassen[$name])) {
|
||||
$this->klassen[$name] = Klasse::findenOderAnlegen($name);
|
||||
}
|
||||
|
||||
return $this->klassen[$name];
|
||||
}
|
||||
|
||||
private function istGeldbuchung(array $eintrag): bool
|
||||
{
|
||||
return in_array($eintrag['typ'], self::GELDTYPEN, true) && $eintrag['konto'] !== null;
|
||||
}
|
||||
|
||||
private function melde(string $typ, string $text): void
|
||||
{
|
||||
// Dieselbe Meldung nicht hundertfach wiederholen.
|
||||
foreach ($this->meldungen as $vorhanden) {
|
||||
if ($vorhanden['text'] === $text) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->meldungen[] = ['typ' => $typ, 'text' => $text];
|
||||
}
|
||||
}
|
||||
198
src/Views/buchungen/formular.php
Normal file
198
src/Views/buchungen/formular.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $konto */
|
||||
/** @var array<string,mixed>|null $buchung */
|
||||
/** @var list<array<string,mixed>> $splits */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
$neu = $buchung === null;
|
||||
$ziel = $neu ? '/konten/' . (int)$konto['id'] . '/buchung' : '/buchungen/' . (int)$buchung['id'];
|
||||
$betrag = $neu ? 0.0 : (float)$buchung['betrag'];
|
||||
|
||||
$art = 'ausgabe';
|
||||
if (!$neu) {
|
||||
if (!empty($buchung['gegenkonto_id'])) {
|
||||
$art = $betrag < 0 ? 'umbuchung_ab' : 'umbuchung_zu';
|
||||
} else {
|
||||
$art = $betrag >= 0 ? 'einnahme' : 'ausgabe';
|
||||
}
|
||||
}
|
||||
|
||||
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
|
||||
$splitZeilen = $splits;
|
||||
for ($i = count($splitZeilen); $i < 4; $i++) {
|
||||
$splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'betrag' => '', 'notiz' => ''];
|
||||
}
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf">Zurück zum Kontoblatt</a>
|
||||
<span class="fuellung"></span>
|
||||
<span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit buchungsmaske">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="art">Art</label>
|
||||
<select id="art" name="art">
|
||||
<?php foreach ([
|
||||
'ausgabe' => 'Ausgabe',
|
||||
'einnahme' => 'Einnahme',
|
||||
'umbuchung_ab' => 'Umbuchung auf ein anderes Konto',
|
||||
'umbuchung_zu' => 'Umbuchung von einem anderen Konto',
|
||||
] as $schluessel => $beschriftung): ?>
|
||||
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
|
||||
<?= View::h($beschriftung) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="datum">Datum</label>
|
||||
<input type="date" id="datum" name="datum" required
|
||||
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
|
||||
|
||||
<label for="betrag">Betrag</label>
|
||||
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
|
||||
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
|
||||
<p class="feldhinweis">Immer positiv eingeben – die Richtung bestimmt die Art oben.</p>
|
||||
|
||||
<label for="empfaenger">Empfänger</label>
|
||||
<input type="text" id="empfaenger" name="empfaenger"
|
||||
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
|
||||
|
||||
<div class="nur-kategorie">
|
||||
<label for="kategorie_id">Kategorie</label>
|
||||
<select id="kategorie_id" name="kategorie_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="nur-umbuchung">
|
||||
<label for="gegenkonto_id">Gegenkonto</label>
|
||||
<select id="gegenkonto_id" name="gegenkonto_id">
|
||||
<option value="">— bitte wählen —</option>
|
||||
<?php foreach ($konten as $k): ?>
|
||||
<?php if ((int)$k['id'] === (int)$konto['id']) { continue; } ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['gegenkonto_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
Die Umbuchung wird einmal gespeichert und erscheint auf beiden Kontoblättern –
|
||||
auf dem Gegenkonto mit umgekehrtem Vorzeichen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label for="klasse_id">Klasse</label>
|
||||
<select id="klasse_id" name="klasse_id">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($buchung['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="buchungstext">Buchungstext</label>
|
||||
<input type="text" id="buchungstext" name="buchungstext"
|
||||
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
|
||||
|
||||
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
|
||||
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="abgeglichen" value="1"
|
||||
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
|
||||
Abgeglichen
|
||||
</label>
|
||||
|
||||
<h3>Splitbuchung <span class="optional">(optional)</span></h3>
|
||||
<p class="feldhinweis">
|
||||
Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und
|
||||
Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag
|
||||
entsprechen.
|
||||
</p>
|
||||
|
||||
<table class="liste splittabelle">
|
||||
<thead>
|
||||
<tr><th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Betrag</th></tr>
|
||||
</thead>
|
||||
<tbody id="splitzeilen">
|
||||
<?php foreach ($splitZeilen as $nr => $zeile): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][kategorie_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select name="split[<?= $nr ?>][klasse_id]">
|
||||
<option value="">— keine —</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($zeile['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" name="split[<?= $nr ?>][notiz]" value="<?= View::h((string)($zeile['notiz'] ?? '')) ?>"></td>
|
||||
<td>
|
||||
<input type="text" class="splitbetrag rechts" inputmode="decimal"
|
||||
name="split[<?= $nr ?>][betrag]"
|
||||
value="<?= $zeile['betrag'] === '' ? '' : View::h(View::betrag($zeile['betrag'], false)) ?>">
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="rechts">Summe der Zeilen / Restbetrag</td>
|
||||
<td class="rechts"><output id="splitsumme" class="betrag">0,00</output></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Buchung anlegen' : 'Speichern' ?></button>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/buchungen/<?= (int)$buchung['id'] ?>/loeschen" class="formular breit"
|
||||
data-bestaetigen="Buchung wirklich löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Buchung löschen</h3>
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf gefahr">Buchung löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<script src="/static/buchungsmaske.js" defer></script>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
175
src/Views/buchungen/kontoblatt.php
Normal file
175
src/Views/buchungen/kontoblatt.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,mixed> $konto */
|
||||
/** @var list<array<string,mixed>> $zeilen */
|
||||
/** @var int $gesamt */
|
||||
/** @var int $seite */
|
||||
/** @var int $seiten */
|
||||
/** @var array<string,mixed> $filter */
|
||||
/** @var list<array<string,mixed>> $kategorien */
|
||||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var array<int,string> $kontenNamen */
|
||||
|
||||
$kategorieNamen = [];
|
||||
foreach ($kategorien as $k) {
|
||||
$kategorieNamen[(int)$k['id']] = $k['pfad'];
|
||||
}
|
||||
$klassenNamen = [];
|
||||
foreach ($klassen as $k) {
|
||||
$klassenNamen[(int)$k['id']] = $k['name'];
|
||||
}
|
||||
|
||||
$gefiltert = ($filter['suche'] ?? '') !== '' || !empty($filter['von']) || !empty($filter['bis'])
|
||||
|| !empty($filter['kategorie_id']) || !empty($filter['klasse_id']) || !empty($filter['nur_offene']);
|
||||
|
||||
$seitenPfad = static function (int $nr) use ($filter): string {
|
||||
$p = array_filter([
|
||||
'suche' => $filter['suche'] ?? '',
|
||||
'von' => $filter['von'] ?? '',
|
||||
'bis' => $filter['bis'] ?? '',
|
||||
'kategorie_id' => $filter['kategorie_id'] ?? '',
|
||||
'klasse_id' => $filter['klasse_id'] ?? '',
|
||||
'nur_offene' => !empty($filter['nur_offene']) ? '1' : '',
|
||||
'seite' => (string)$nr,
|
||||
], static fn(string $v): bool => $v !== '');
|
||||
|
||||
return '?' . http_build_query($p);
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="tableiste">
|
||||
<span class="tab aktiv">Kontoblatt</span>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten" class="tab">Kontodaten</a>
|
||||
</div>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>/buchung" class="knopf primaer">Neue Buchung</a>
|
||||
<a href="/konten" class="knopf">Kontenübersicht</a>
|
||||
<span class="fuellung"></span>
|
||||
<span class="saldoanzeige">
|
||||
Aktueller Saldo
|
||||
<strong class="<?= View::betragKlasse($konto['saldo_aktuell']) ?>">
|
||||
<?= View::betrag($konto['saldo_aktuell']) ?>
|
||||
</strong>
|
||||
</span>
|
||||
<?php if (abs((float)$konto['saldo_erwartet'] - (float)$konto['saldo_aktuell']) > 0.005): ?>
|
||||
<span class="saldoanzeige">
|
||||
Erwartet
|
||||
<strong class="<?= View::betragKlasse($konto['saldo_erwartet']) ?>">
|
||||
<?= View::betrag($konto['saldo_erwartet']) ?>
|
||||
</strong>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/konten/<?= (int)$konto['id'] ?>" class="filterleiste">
|
||||
<input type="search" name="suche" placeholder="Empfänger, Text, Nummer"
|
||||
value="<?= View::h($filter['suche'] ?? '') ?>">
|
||||
<label>von <input type="date" name="von" value="<?= View::h($filter['von'] ?? '') ?>"></label>
|
||||
<label>bis <input type="date" name="bis" value="<?= View::h($filter['bis'] ?? '') ?>"></label>
|
||||
|
||||
<select name="kategorie_id">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<?php foreach ($kategorien as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($filter['kategorie_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['pfad']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<select name="klasse_id">
|
||||
<option value="">Alle Klassen</option>
|
||||
<?php foreach ($klassen as $k): ?>
|
||||
<option value="<?= (int)$k['id'] ?>"
|
||||
<?= (int)($filter['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
|
||||
<?= View::h($k['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="nur_offene" value="1" <?= !empty($filter['nur_offene']) ? 'checked' : '' ?>>
|
||||
nur nicht abgeglichene
|
||||
</label>
|
||||
|
||||
<button type="submit" class="knopf klein">Filtern</button>
|
||||
<?php if ($gefiltert): ?>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>" class="knopf klein">Filter zurücksetzen</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php if ($zeilen === []): ?>
|
||||
<p class="platzhalter">
|
||||
<?= $gefiltert ? 'Keine Buchung passt zum Filter.' : 'Für dieses Konto ist noch nichts gebucht.' ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<table class="liste kontoblatt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Nr.</th>
|
||||
<th>Empfänger</th>
|
||||
<th>Buchungstext</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Klasse</th>
|
||||
<th class="rechts">Betrag</th>
|
||||
<th class="rechts">Saldo</th>
|
||||
<th>✓</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($zeilen as $zeile): ?>
|
||||
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
|
||||
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
||||
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
|
||||
<td>
|
||||
<?php if ($zeile['gegensicht']): ?>
|
||||
<?= View::h($zeile['empfaenger']) ?>
|
||||
<span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto">
|
||||
Umbuchung
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext gekuerzt"><?= View::h(mb_substr((string)$zeile['buchungstext'], 0, 90)) ?></td>
|
||||
<td>
|
||||
<?php if ((int)$zeile['gesplittet'] === 1): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php elseif (!empty($zeile['gegenkonto_id']) && !$zeile['gegensicht']): ?>
|
||||
[<?= View::h($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') ?>]
|
||||
<?php else: ?>
|
||||
<?= View::h($kategorieNamen[(int)$zeile['kategorie_id']] ?? '') ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="nebentext"><?= View::h($klassenNamen[(int)$zeile['klasse_id']] ?? '') ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>"><?= View::betrag($zeile['betrag']) ?></td>
|
||||
<td class="rechts <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($zeile['saldo']) ?>">
|
||||
<?= $zeile['saldo'] === null ? '–' : View::betrag($zeile['saldo']) ?>
|
||||
</td>
|
||||
<td class="nebentext"><?= (int)$zeile['abgeglichen'] === 1 ? '✓' : '' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="blaettern">
|
||||
<span class="nebentext"><?= (int)$gesamt ?> Buchungen</span>
|
||||
<?php if ($seiten > 1): ?>
|
||||
<?php if ($seite > 1): ?>
|
||||
<a href="<?= View::h($seitenPfad($seite - 1)) ?>" class="knopf klein">Neuer</a>
|
||||
<?php endif; ?>
|
||||
<span class="nebentext">Seite <?= (int)$seite ?> von <?= (int)$seiten ?></span>
|
||||
<?php if ($seite < $seiten): ?>
|
||||
<a href="<?= View::h($seitenPfad($seite + 1)) ?>" class="knopf klein">Älter</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -65,8 +65,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<ul class="checkliste">
|
||||
<li class="erledigt">Phase 0 – Grundgerüst, Anmeldung, Einstellungen</li>
|
||||
<li class="erledigt">Phase 1 – Konten, Kategorien, Klassen, QIF-Stammdaten</li>
|
||||
<li>Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||
<li class="erledigt">Phase 2 – Buchungen, Kontoblatt, Splitbuchungen</li>
|
||||
<li>Phase 3 – Budgetplaner und Monatsabschluss</li>
|
||||
<li>Phase 4 – Auswertungen und Cockpit-Kacheln</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
werden kann.
|
||||
</p>
|
||||
|
||||
<label for="import_stichtag">Stichtag für Buchungen</label>
|
||||
<input type="date" id="import_stichtag" name="import_stichtag"
|
||||
value="<?= View::h($wert('import_stichtag', '2026-01-01')) ?>">
|
||||
<p class="feldhinweis">
|
||||
Buchungen ab diesem Tag werden übernommen. Alles davor fließt je Konto in den
|
||||
Anfangssaldo – die Salden stimmen also, ohne dass die ganze Historie mitkommt.
|
||||
Buchungen mit einem Datum nach dem Importtag sind Vorausberechnungen des
|
||||
FinanzManagers und bleiben draußen.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
|
||||
</div>
|
||||
|
|
@ -125,7 +135,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<?php if ($bereich === 'diagnose' && $logDateien !== []): ?>
|
||||
<form method="post" action="/einstellungen/log-leeren" class="formular breit"
|
||||
onsubmit="return confirm('Alle Logdateien löschen?');">
|
||||
data-bestaetigen="Alle Logdateien löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf">Logdateien löschen</button>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,17 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
Konto ist aktiv
|
||||
</label>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="in_summe" value="1"
|
||||
<?= $neu || (int)$konto['in_summe'] === 1 ? 'checked' : '' ?>>
|
||||
Zählt zur Summe der Kontostände
|
||||
</label>
|
||||
<p class="feldhinweis">
|
||||
Entspricht der Σ-Spalte im FinanzManager. Konten, die kein eigenes Vermögen
|
||||
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
|
||||
erscheinen aber weiter in der Übersicht.
|
||||
</p>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
|
||||
<a href="/konten" class="knopf">Abbrechen</a>
|
||||
|
|
@ -91,7 +102,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
|
||||
<?php if (!$neu): ?>
|
||||
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit"
|
||||
onsubmit="return confirm('Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?');">
|
||||
data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Konto löschen</h3>
|
||||
<p class="feldhinweis">
|
||||
|
|
|
|||
|
|
@ -52,10 +52,13 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php foreach ($gruppe['konten'] as $konto): ?>
|
||||
<tr<?= (int)$konto['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
|
||||
<td>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>/bearbeiten"><?= View::h($konto['name']) ?></a>
|
||||
<a href="/konten/<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></a>
|
||||
<?php if ((int)$konto['aktiv'] !== 1): ?>
|
||||
<span class="marke">inaktiv</span>
|
||||
<?php endif; ?>
|
||||
<?php if ((int)$konto['in_summe'] !== 1): ?>
|
||||
<span class="marke" title="Zählt nicht zur Summe der Kontostände">ohne Σ</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($konto['beschreibung'] !== ''): ?>
|
||||
<span class="nebentext"><?= View::h($konto['beschreibung']) ?></span>
|
||||
<?php endif; ?>
|
||||
|
|
@ -88,9 +91,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</table>
|
||||
|
||||
<p class="hinweis">
|
||||
Online-Saldo und die Trennung zwischen aktuellem und erwartetem Saldo füllen sich,
|
||||
sobald Buchungen (Phase 2) und der Bankabruf (Phase 5) da sind. Bis dahin zeigen die
|
||||
Spalten den Anfangssaldo.
|
||||
In die Summen zählen nur Konten mit Σ-Kennzeichen – wie im FinanzManager. Der
|
||||
Online-Saldo kommt mit dem Bankabruf in Phase 5, Depotwerte mit Phase 6.
|
||||
Ein Klick auf den Kontonamen öffnet das Kontoblatt.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ $istAktiv = static function (string $pfad) use ($aktuellerPfad): bool {
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= View::h($titel ?? 'Finanzmanager') ?> – Finanzmanager</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>€</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ use Finanzmanager\View;
|
|||
/** @var list<array{name: string, groesse: int, geaendert: int}> $dateien */
|
||||
/** @var string $verzeichnis */
|
||||
/** @var array<string,mixed>|null $ergebnis */
|
||||
/** @var array<string,mixed>|null $buchungen */
|
||||
/** @var string $stichtag */
|
||||
/** @var string $bis */
|
||||
/** @var string $datei */
|
||||
/** @var string $csrfToken */
|
||||
|
||||
|
|
@ -59,6 +62,47 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php if ($buchungen !== null): ?>
|
||||
<h3>Buchungen</h3>
|
||||
<table class="liste">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><?= $buchungen['trockenlauf'] ? 'Neu' : 'Angelegt' ?></td>
|
||||
<td class="rechts"><?= (int)$buchungen['buchungen']['neu'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nebentext">schon vorhanden</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vorhanden'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nebentext">
|
||||
vor dem Stichtag – in den Anfangssaldo von
|
||||
<?= (int)$buchungen['anfangssalden']['konten'] ?> Konten verrechnet
|
||||
</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['vor_stichtag'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nebentext">nach dem Enddatum – vorausberechnet, verworfen</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['zukunft'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="nebentext">darunter Wertpapiergeschäfte (nur Geldfluss)</td>
|
||||
<td class="rechts nebentext"><?= (int)$buchungen['buchungen']['wertpapier'] ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Fehler</td>
|
||||
<td class="rechts <?= (int)$buchungen['buchungen']['fehler'] > 0 ? 'betrag negativ' : 'nebentext' ?>">
|
||||
<?= (int)$buchungen['buchungen']['fehler'] ?>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="hinweis">
|
||||
Zeitraum <?= View::datum($buchungen['stichtag']) ?> bis <?= View::datum($buchungen['bis']) ?>.
|
||||
Alles davor ist nicht verloren: es steckt im Anfangssaldo der Konten.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($ergebnis['abschnitte'])): ?>
|
||||
<h3>Inhalt der Datei</h3>
|
||||
<p class="hinweis">
|
||||
|
|
@ -69,8 +113,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
echo implode(' · ', $teile); ?>
|
||||
</p>
|
||||
<p class="hinweis">
|
||||
Buchungen und Buchungsvorlagen werden in Phase 2 übernommen – dieser Lauf
|
||||
betrifft ausschließlich Klassen, Kategorien und Konten.
|
||||
Buchungsvorlagen (<code>Memorized</code>) folgen in Phase 7, Wertpapierbestände
|
||||
in Phase 6.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
@ -88,18 +132,21 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($datei) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Jetzt übernehmen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/kategorien" class="knopf primaer">Kategorien ansehen</a>
|
||||
<a href="/konten" class="knopf">Konten ansehen</a>
|
||||
<a href="/konten" class="knopf primaer">Kontenübersicht</a>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Die Anfangssalden stehen nicht im Export. Sie werden je Konto in der
|
||||
Kontenübersicht nachgetragen.
|
||||
Die Anfangssalden wurden aus dem Zeitraum vor dem Stichtag berechnet. In der
|
||||
Kontenübersicht lässt sich jeder Wert nachträglich ändern – ebenso das
|
||||
Kennzeichen, ob ein Konto zur Summe der Kontostände zählt.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
|
@ -124,6 +171,13 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<div class="kachel">
|
||||
<header><h2>Vorhandene Dateien</h2></header>
|
||||
|
||||
<p class="hinweis">
|
||||
Übernommen werden Buchungen ab <strong><?= View::datum($stichtag) ?></strong> bis
|
||||
<strong><?= View::datum($bis) ?></strong>. Alles davor fließt in den Anfangssaldo der
|
||||
Konten, alles danach gilt als Vorausberechnung des FinanzManagers und bleibt draußen.
|
||||
Der Stichtag steht in den <a href="/einstellungen?bereich=import">Einstellungen</a>.
|
||||
</p>
|
||||
|
||||
<?php if ($dateien === []): ?>
|
||||
<p class="platzhalter">
|
||||
Noch keine Datei in <code><?= View::h($verzeichnis) ?></code>.
|
||||
|
|
@ -144,18 +198,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="vorschau">
|
||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||
<button type="submit" class="knopf klein">Trockenlauf</button>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/import/ausfuehren" class="inline"
|
||||
onsubmit="return confirm('„<?= View::h($eintrag['name']) ?>“ jetzt importieren?');">
|
||||
data-bestaetigen="„<?= View::h($eintrag['name']) ?>“ jetzt importieren?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<input type="hidden" name="datei" value="<?= View::h($eintrag['name']) ?>">
|
||||
<input type="hidden" name="modus" value="import">
|
||||
<input type="hidden" name="stichtag" value="<?= View::h($stichtag) ?>">
|
||||
<input type="hidden" name="bis" value="<?= View::h($bis) ?>">
|
||||
<button type="submit" class="knopf klein primaer">Importieren</button>
|
||||
</form>
|
||||
<form method="post" action="/verwaltung/import/<?= View::h(rawurlencode($eintrag['name'])) ?>/loeschen"
|
||||
class="inline"
|
||||
onsubmit="return confirm('Datei „<?= View::h($eintrag['name']) ?>“ löschen?');">
|
||||
data-bestaetigen="Datei „<?= View::h($eintrag['name']) ?>“ löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf klein gefahr">Löschen</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</form>
|
||||
|
||||
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/zusammenfuehren" class="formular breit"
|
||||
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ in die gewählte Kategorie überführen?');">
|
||||
data-bestaetigen="„<?= View::h($kategorie['pfad']) ?>“ in die gewählte Kategorie überführen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Zusammenführen</h3>
|
||||
<label for="ziel_id">Zielkategorie</label>
|
||||
|
|
@ -148,7 +148,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</form>
|
||||
|
||||
<form method="post" action="/verwaltung/kategorien/<?= $id ?>/loeschen" class="formular breit"
|
||||
onsubmit="return confirm('„<?= View::h($kategorie['pfad']) ?>“ mit allen Unterkategorien löschen?');">
|
||||
data-bestaetigen="„<?= View::h($kategorie['pfad']) ?>“ mit allen Unterkategorien löschen?">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<h3>Löschen</h3>
|
||||
<p class="feldhinweis">
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>"></form>
|
||||
<form id="klasse-loeschen-<?= (int)$klasse['id'] ?>" method="post"
|
||||
action="/verwaltung/klassen/<?= (int)$klasse['id'] ?>/loeschen"
|
||||
onsubmit="return confirm('Klasse „<?= View::h($klasse['name']) ?>“ löschen?');"></form>
|
||||
data-bestaetigen="Klasse „<?= View::h($klasse['name']) ?>“ löschen?"></form>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="liste">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>"></form>
|
||||
<form id="gruppe-loeschen-<?= (int)$gruppe['id'] ?>" method="post"
|
||||
action="/verwaltung/kontengruppen/<?= (int)$gruppe['id'] ?>/loeschen"
|
||||
onsubmit="return confirm('Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten.');"></form>
|
||||
data-bestaetigen="Kontengruppe „<?= View::h($gruppe['name']) ?>“ löschen? Die Konten bleiben erhalten."></form>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<table class="liste">
|
||||
|
|
|
|||
298
tests/09_BuchungenTest.php
Normal file
298
tests/09_BuchungenTest.php
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
|
||||
class BuchungenTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 09: Buchungen, Umbuchungen und Salden';
|
||||
|
||||
private int $giro = 0;
|
||||
private int $tagesgeld = 0;
|
||||
private int $kategorie = 0;
|
||||
private int $klasse = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->einfacheBuchungen();
|
||||
$this->umbuchungHatZweiSichten();
|
||||
$this->splitbuchung();
|
||||
$this->splitSummeWirdGeprueft();
|
||||
$this->splitMitGegenkonto();
|
||||
$this->salden();
|
||||
$this->kontoblattFilter();
|
||||
$this->pruefungen();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->giro = Konto::anlegen([
|
||||
'name' => 'Testgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->tagesgeld = Konto::anlegen([
|
||||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000, 'aktiv' => 1,
|
||||
]);
|
||||
$this->kategorie = Kategorie::anlegen(['name' => 'Lebensmittel', 'art' => 'ausgabe']);
|
||||
$this->klasse = Klasse::anlegen('Privat');
|
||||
}
|
||||
|
||||
private function einfacheBuchungen(): void
|
||||
{
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => '2026-03-18',
|
||||
'betrag' => -49.90,
|
||||
'empfaenger' => 'Supermarkt',
|
||||
'buchungstext' => 'Wocheneinkauf',
|
||||
'kategorie_id' => $this->kategorie,
|
||||
'klasse_id' => $this->klasse,
|
||||
'abgeglichen' => true,
|
||||
]);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals('-49.90', $buchung['betrag'], 'Betrag wird übernommen');
|
||||
$this->assertEquals('2026-03-18', $buchung['datum'], 'Datum wird übernommen');
|
||||
$this->assertEquals(1, (int)$buchung['abgeglichen'], 'Abgleichstatus wird übernommen');
|
||||
$this->assertEquals(0, (int)$buchung['gesplittet'], 'Ohne Splitzeilen keine Splitbuchung');
|
||||
|
||||
Buchung::speichern($id, [
|
||||
'konto_id' => $this->giro, 'datum' => '2026-03-19', 'betrag' => -59.90,
|
||||
'empfaenger' => 'Supermarkt', 'kategorie_id' => $this->kategorie,
|
||||
]);
|
||||
$this->assertEquals('-59.90', Buchung::find($id)['betrag'], 'Buchung lässt sich ändern');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
$this->assertNull(Buchung::find($id), 'Buchung lässt sich löschen');
|
||||
}
|
||||
|
||||
private function umbuchungHatZweiSichten(): void
|
||||
{
|
||||
// 500 vom Giro aufs Tagesgeld.
|
||||
$id = Buchung::anlegen([
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => '2026-04-01',
|
||||
'betrag' => -500.00,
|
||||
'empfaenger' => 'Übertrag',
|
||||
'gegenkonto_id' => $this->tagesgeld,
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, Buchung::anzahl(), 'Die Umbuchung ist ein einziger Datensatz');
|
||||
|
||||
$giroBlatt = Buchung::kontoblatt($this->giro);
|
||||
$tagesBlatt = Buchung::kontoblatt($this->tagesgeld);
|
||||
|
||||
$this->assertEquals(1, count($giroBlatt), 'Sie steht im Kontoblatt des Ausgangskontos');
|
||||
$this->assertEquals(1, count($tagesBlatt), 'Und ebenso im Kontoblatt des Gegenkontos');
|
||||
$this->assertEquals(-500.0, (float)$giroBlatt[0]['betrag'], 'Auf dem Ausgangskonto negativ');
|
||||
$this->assertEquals(500.0, (float)$tagesBlatt[0]['betrag'], 'Auf dem Gegenkonto positiv');
|
||||
$this->assert(!$giroBlatt[0]['gegensicht'], 'Auf dem Ausgangskonto ist es die eigene Sicht');
|
||||
$this->assert($tagesBlatt[0]['gegensicht'], 'Auf dem Gegenkonto die Gegensicht');
|
||||
|
||||
$this->assertEquals(500.0, Konto::saldo($this->giro), 'Girosaldo: 1000 - 500');
|
||||
$this->assertEquals(5500.0, Konto::saldo($this->tagesgeld), 'Tagesgeldsaldo: 5000 + 500');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
$this->assertEquals(1000.0, Konto::saldo($this->giro), 'Nach dem Löschen ist der Saldo zurück');
|
||||
$this->assertEquals(5000.0, Konto::saldo($this->tagesgeld), 'Auch auf dem Gegenkonto');
|
||||
}
|
||||
|
||||
private function splitbuchung(): void
|
||||
{
|
||||
$versicherung = Kategorie::anlegen(['name' => 'Versicherung', 'art' => 'ausgabe']);
|
||||
|
||||
$id = Buchung::anlegen(
|
||||
[
|
||||
'konto_id' => $this->giro,
|
||||
'datum' => '2026-01-02',
|
||||
'betrag' => -260.49,
|
||||
'empfaenger' => 'Versicherung AG',
|
||||
],
|
||||
[
|
||||
['kategorie_id' => $versicherung, 'betrag' => -162.51, 'notiz' => 'Kasko'],
|
||||
['kategorie_id' => $versicherung, 'betrag' => -97.98, 'notiz' => 'Haftpflicht'],
|
||||
]
|
||||
);
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals(1, (int)$buchung['gesplittet'], 'Buchung ist als Splitbuchung gekennzeichnet');
|
||||
$this->assertNull($buchung['kategorie_id'], 'Bei Splits steht oben keine Kategorie');
|
||||
|
||||
$splits = Buchung::splits($id);
|
||||
$this->assertEquals(2, count($splits), 'Zwei Splitzeilen');
|
||||
$this->assertEquals('Kasko', $splits[0]['notiz'], 'Reihenfolge der Zeilen bleibt erhalten');
|
||||
$this->assertEquals('-97.98', $splits[1]['betrag'], 'Betrag der zweiten Zeile');
|
||||
|
||||
// Beim Speichern werden die Zeilen ersetzt, nicht ergänzt.
|
||||
Buchung::speichern($id, [
|
||||
'konto_id' => $this->giro, 'datum' => '2026-01-02', 'betrag' => -260.49,
|
||||
], [
|
||||
['kategorie_id' => $versicherung, 'betrag' => -260.49, 'notiz' => 'zusammengefasst'],
|
||||
]);
|
||||
$this->assertEquals(1, count(Buchung::splits($id)), 'Splitzeilen werden ersetzt');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
$this->assertEquals(0, count(Buchung::splits($id)), 'Mit der Buchung gehen die Splitzeilen');
|
||||
}
|
||||
|
||||
private function splitSummeWirdGeprueft(): void
|
||||
{
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
Buchung::anlegen(
|
||||
['konto_id' => $this->giro, 'datum' => '2026-02-01', 'betrag' => -100.00],
|
||||
[
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -60.00],
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -30.00],
|
||||
]
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
$this->assert($abgewiesen, 'Splitzeilen, die den Betrag verfehlen, werden abgewiesen');
|
||||
$this->assertEquals(0, Buchung::anzahl(), 'Es blieb nichts halb Gespeichertes zurück');
|
||||
|
||||
// Ein Cent Rundungsdifferenz aus der Betragsumrechnung ist zulässig.
|
||||
$id = Buchung::anlegen(
|
||||
['konto_id' => $this->giro, 'datum' => '2026-02-01', 'betrag' => -100.00],
|
||||
[
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -66.67],
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -33.33],
|
||||
]
|
||||
);
|
||||
$this->assertGreaterThan(0, $id, 'Aufgehende Splitzeilen werden angenommen');
|
||||
Buchung::loeschen($id);
|
||||
}
|
||||
|
||||
private function splitMitGegenkonto(): void
|
||||
{
|
||||
// Eine Zeile geht auf ein anderes Konto – das muss dort auftauchen.
|
||||
$id = Buchung::anlegen(
|
||||
['konto_id' => $this->giro, 'datum' => '2026-05-02', 'betrag' => -300.00],
|
||||
[
|
||||
['kategorie_id' => $this->kategorie, 'betrag' => -100.00, 'notiz' => 'Einkauf'],
|
||||
['gegenkonto_id' => $this->tagesgeld, 'betrag' => -200.00, 'notiz' => 'Rücklage'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertEquals(700.0, Konto::saldo($this->giro), 'Das Ausgangskonto verliert die volle Summe');
|
||||
$this->assertEquals(5200.0, Konto::saldo($this->tagesgeld), 'Nur die eine Zeile landet auf dem Gegenkonto');
|
||||
|
||||
$blatt = Buchung::kontoblatt($this->tagesgeld);
|
||||
$this->assertEquals(1, count($blatt), 'Die Splitzeile erscheint als eigene Zeile im Kontoblatt');
|
||||
$this->assertEquals(200.0, (float)$blatt[0]['betrag'], 'Mit umgekehrtem Vorzeichen');
|
||||
$this->assertEquals('Rücklage', $blatt[0]['buchungstext'], 'Die Notiz der Zeile wird gezeigt');
|
||||
|
||||
Buchung::loeschen($id);
|
||||
$this->assertEquals(5000.0, Konto::saldo($this->tagesgeld), 'Löschen räumt auch die Gegensicht ab');
|
||||
}
|
||||
|
||||
private function salden(): void
|
||||
{
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-01-15', 'betrag' => -100.00,
|
||||
'kategorie_id' => $this->kategorie,
|
||||
]);
|
||||
// Eine Buchung in der Zukunft zählt nur zum erwarteten Saldo.
|
||||
$zukunft = date('Y-m-d', strtotime('+30 days'));
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => $zukunft, 'betrag' => -250.00,
|
||||
'kategorie_id' => $this->kategorie,
|
||||
]);
|
||||
|
||||
$konto = Konto::mitSalden(Konto::find($this->giro));
|
||||
$this->assertEquals(900.0, $konto['saldo_aktuell'], 'Aktueller Saldo ohne künftige Buchungen');
|
||||
$this->assertEquals(650.0, $konto['saldo_erwartet'], 'Erwarteter Saldo einschließlich');
|
||||
|
||||
// Nur Konten mit Σ-Kennzeichen zählen zur Summe.
|
||||
Konto::speichern($this->tagesgeld, [
|
||||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000,
|
||||
'aktiv' => 1, 'in_summe' => 0,
|
||||
]);
|
||||
|
||||
$uebersicht = Konto::uebersicht();
|
||||
$this->assertEquals(650.0, $uebersicht['summe'], 'Das Konto ohne Kennzeichen bleibt außen vor');
|
||||
|
||||
Konto::speichern($this->tagesgeld, [
|
||||
'name' => 'Testtagesgeld', 'kontoart' => 'sparkonto', 'anfangssaldo' => 5000,
|
||||
'aktiv' => 1, 'in_summe' => 1,
|
||||
]);
|
||||
$this->assertEquals(5650.0, Konto::uebersicht()['summe'], 'Mit Kennzeichen zählt es wieder mit');
|
||||
}
|
||||
|
||||
private function kontoblattFilter(): void
|
||||
{
|
||||
$miete = Kategorie::anlegen(['name' => 'Miete', 'art' => 'ausgabe']);
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-06-01', 'betrag' => -800.00,
|
||||
'empfaenger' => 'Vermieterin', 'kategorie_id' => $miete, 'abgeglichen' => true,
|
||||
]);
|
||||
|
||||
$alle = Buchung::kontoblatt($this->giro);
|
||||
$this->assertGreaterThan(2, count($alle), 'Das Kontoblatt zeigt alle Buchungen');
|
||||
|
||||
$gesucht = Buchung::kontoblatt($this->giro, ['suche' => 'Vermieter']);
|
||||
$this->assertEquals(1, count($gesucht), 'Volltextsuche über den Empfänger');
|
||||
|
||||
$nachKategorie = Buchung::kontoblatt($this->giro, ['kategorie_id' => $miete]);
|
||||
$this->assertEquals(1, count($nachKategorie), 'Filter auf die Kategorie');
|
||||
|
||||
$zeitraum = Buchung::kontoblatt($this->giro, ['von' => '2026-06-01', 'bis' => '2026-06-30']);
|
||||
$this->assertEquals(1, count($zeitraum), 'Filter auf den Zeitraum');
|
||||
|
||||
$offene = Buchung::kontoblatt($this->giro, ['nur_offene' => true]);
|
||||
foreach ($offene as $zeile) {
|
||||
$this->assertEquals(0, (int)$zeile['abgeglichen'], 'Filter zeigt nur nicht abgeglichene');
|
||||
}
|
||||
|
||||
// Ohne Filter läuft der Saldo mit, mit Filter wäre er irreführend.
|
||||
$this->assertNotEmpty($alle[0]['saldo'], 'Ungefiltert gibt es einen laufenden Saldo');
|
||||
$this->assertNull($zeitraum[0]['saldo'], 'Gefiltert bleibt die Saldospalte leer');
|
||||
}
|
||||
|
||||
private function pruefungen(): void
|
||||
{
|
||||
$ohneKonto = false;
|
||||
try {
|
||||
Buchung::anlegen(['konto_id' => 0, 'datum' => '2026-01-01', 'betrag' => -1]);
|
||||
} catch (\Throwable) {
|
||||
$ohneKonto = true;
|
||||
}
|
||||
$this->assert($ohneKonto, 'Buchung ohne gültiges Konto wird abgewiesen');
|
||||
|
||||
$schlechtesDatum = false;
|
||||
try {
|
||||
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => '18.03.2026', 'betrag' => -1]);
|
||||
} catch (\Throwable) {
|
||||
$schlechtesDatum = true;
|
||||
}
|
||||
$this->assert($schlechtesDatum, 'Datum im falschen Format wird abgewiesen');
|
||||
|
||||
$selbst = false;
|
||||
try {
|
||||
Buchung::anlegen([
|
||||
'konto_id' => $this->giro, 'datum' => '2026-01-01', 'betrag' => -1,
|
||||
'gegenkonto_id' => $this->giro,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
$selbst = true;
|
||||
}
|
||||
$this->assert($selbst, 'Eine Umbuchung auf sich selbst wird abgewiesen');
|
||||
|
||||
$this->assert(Buchung::istDatum('2026-02-28'), 'Gültiges Datum');
|
||||
$this->assert(!Buchung::istDatum('2026-02-30'), 'Der 30. Februar wird abgelehnt');
|
||||
$this->assert(!Buchung::istDatum('2026-13-01'), 'Monat 13 wird abgelehnt');
|
||||
|
||||
$a = Buchung::hash(1, '2026-01-01', -10.0, '5', 'Text');
|
||||
$b = Buchung::hash(1, '2026-01-01', -10.0, '5', 'Text');
|
||||
$c = Buchung::hash(1, '2026-01-01', -10.01, '5', 'Text');
|
||||
$this->assertEquals($a, $b, 'Gleiche Merkmale ergeben denselben Fingerabdruck');
|
||||
$this->assert($a !== $c, 'Ein anderer Betrag ergibt einen anderen');
|
||||
}
|
||||
}
|
||||
201
tests/10_QifBuchungsImportTest.php
Normal file
201
tests/10_QifBuchungsImportTest.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\QifBuchungsImport;
|
||||
use Finanzmanager\Services\QifParser;
|
||||
use Finanzmanager\Services\QifStammdatenImport;
|
||||
|
||||
/**
|
||||
* Der Ausschnitt bildet die Eigenheiten des echten Exports nach: eine Umbuchung
|
||||
* steht nur einmal in der Datei, eine Eröffnungsbuchung verweist auf ihr eigenes
|
||||
* Konto, ein Teil der Buchungen liegt vor dem Stichtag, eine dahinter.
|
||||
*/
|
||||
class QifBuchungsImportTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 10: QIF-Buchungsimport mit Stichtag';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
|
||||
$this->importMitStichtag();
|
||||
$this->zweiterLaufAendertNichts();
|
||||
$this->umbuchungsziel();
|
||||
}
|
||||
|
||||
private function beispiel(): string
|
||||
{
|
||||
$zukunft = date('m.d.y', strtotime('+40 days'));
|
||||
|
||||
$zeilen = [
|
||||
'!Option:MDY',
|
||||
'!Type:Cat',
|
||||
'N400 - Leben:Essen',
|
||||
'E',
|
||||
'^',
|
||||
'!Option:AutoSwitch',
|
||||
'!Account',
|
||||
'NGiro',
|
||||
'TBank',
|
||||
'^',
|
||||
'NTagesgeld',
|
||||
'TOth T',
|
||||
'^',
|
||||
'!Clear:AutoSwitch',
|
||||
|
||||
// Girokonto
|
||||
'!Account',
|
||||
'NGiro',
|
||||
'TBank',
|
||||
'^',
|
||||
'!Type:Bank ',
|
||||
// Eröffnungsbuchung: verweist auf das eigene Konto
|
||||
'D1.2.18',
|
||||
'T1,000.00',
|
||||
'PEröffnungssaldo',
|
||||
'L[Giro]',
|
||||
'CX',
|
||||
'^',
|
||||
// vor dem Stichtag: fließt in den Anfangssaldo
|
||||
'D6.15.25',
|
||||
'T-100.00',
|
||||
'PBäcker',
|
||||
'L400 - Leben:Essen',
|
||||
'CX',
|
||||
'^',
|
||||
// Umbuchung vor dem Stichtag – die Gegenseite steht nirgends
|
||||
'D7.1.25',
|
||||
'T-400.00',
|
||||
'PÜbertrag',
|
||||
'L[Tagesgeld]',
|
||||
'CX',
|
||||
'^',
|
||||
// im Zeitraum
|
||||
'D3.18.26',
|
||||
'U-49.90',
|
||||
'T-49.90',
|
||||
'PSupermarkt',
|
||||
'MWocheneinkauf',
|
||||
'L400 - Leben:Essen/Privat',
|
||||
'CX',
|
||||
'N77',
|
||||
'^',
|
||||
// Umbuchung im Zeitraum
|
||||
'D4.1.26',
|
||||
'T-250.00',
|
||||
'PÜbertrag',
|
||||
'L[Tagesgeld]',
|
||||
'^',
|
||||
// Splitbuchung im Zeitraum
|
||||
'D5.2.26',
|
||||
'T-300.00',
|
||||
'PGemischt',
|
||||
'L400 - Leben:Essen',
|
||||
'S400 - Leben:Essen',
|
||||
'EEinkauf',
|
||||
'$-100.00',
|
||||
'S[Tagesgeld]',
|
||||
'ERücklage',
|
||||
'$-200.00',
|
||||
'^',
|
||||
// nach dem Enddatum: vorausberechnet
|
||||
'D' . $zukunft,
|
||||
'T-999.00',
|
||||
'PVorausschau',
|
||||
'L400 - Leben:Essen',
|
||||
'^',
|
||||
];
|
||||
|
||||
return implode("\r\n", $zeilen) . "\r\n";
|
||||
}
|
||||
|
||||
private function importMitStichtag(): void
|
||||
{
|
||||
$parser = new QifParser($this->beispiel());
|
||||
(new QifStammdatenImport($parser))->ausfuehren();
|
||||
|
||||
$ergebnis = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => '2026-01-01',
|
||||
'bis' => date('Y-m-d'),
|
||||
]))->ausfuehren();
|
||||
|
||||
$this->assertEquals(3, $ergebnis['buchungen']['neu'], 'Drei Buchungen im Zeitraum');
|
||||
$this->assertEquals(3, $ergebnis['buchungen']['vor_stichtag'], 'Drei Buchungen davor verrechnet');
|
||||
$this->assertEquals(1, $ergebnis['buchungen']['zukunft'], 'Eine vorausberechnete Buchung verworfen');
|
||||
$this->assertEquals(0, $ergebnis['buchungen']['fehler'], 'Keine Fehler');
|
||||
|
||||
$giro = Konto::findeNachName('Giro');
|
||||
$tagesgeld = Konto::findeNachName('Tagesgeld');
|
||||
|
||||
// Anfangssaldo Giro: 1000 (Eröffnung) - 100 (Bäcker) - 400 (Übertrag)
|
||||
$this->assertEquals('500.00', $giro['anfangssaldo'], 'Anfangssaldo aus der Vorgeschichte');
|
||||
// Die Gegenseite der Umbuchung steht nirgends in der Datei und muss
|
||||
// trotzdem auf dem Tagesgeldkonto ankommen.
|
||||
$this->assertEquals('400.00', $tagesgeld['anfangssaldo'], 'Erzeugte Gegenbuchung im Anfangssaldo');
|
||||
|
||||
// Saldo Giro: 500 - 49,90 - 250 - 300
|
||||
$this->assertEquals(-99.90, round(Konto::saldo((int)$giro['id']), 2), 'Girosaldo nach dem Import');
|
||||
// Tagesgeld: 400 + 250 (Umbuchung) + 200 (Splitzeile)
|
||||
$this->assertEquals(850.0, round(Konto::saldo((int)$tagesgeld['id']), 2), 'Tagesgeldsaldo nach dem Import');
|
||||
|
||||
$blatt = Buchung::kontoblatt((int)$tagesgeld['id']);
|
||||
$this->assertEquals(2, count($blatt), 'Beide Gegensichten stehen im Kontoblatt');
|
||||
foreach ($blatt as $zeile) {
|
||||
$this->assert($zeile['gegensicht'], 'Es sind Gegensichten, keine eigenen Buchungen');
|
||||
}
|
||||
|
||||
$supermarkt = null;
|
||||
foreach (Buchung::kontoblatt((int)$giro['id']) as $zeile) {
|
||||
if ($zeile['empfaenger'] === 'Supermarkt') {
|
||||
$supermarkt = $zeile;
|
||||
}
|
||||
}
|
||||
$this->assertNotEmpty($supermarkt, 'Die Buchung im Zeitraum ist da');
|
||||
$this->assertEquals('Wocheneinkauf', $supermarkt['buchungstext'], 'Buchungstext übernommen');
|
||||
$this->assertEquals('77', $supermarkt['nummer'], 'Nummer übernommen');
|
||||
$this->assertEquals(1, (int)$supermarkt['abgeglichen'], 'Abgleichstatus aus dem C-Feld');
|
||||
$this->assertNotEmpty($supermarkt['klasse_id'], 'Die Klasse hinter dem Schrägstrich wurde erkannt');
|
||||
$this->assertEquals(
|
||||
'400 - Leben:Essen',
|
||||
Kategorie::find((int)$supermarkt['kategorie_id'])['pfad'],
|
||||
'Kategorie zugeordnet'
|
||||
);
|
||||
|
||||
// Die Eröffnungsbuchung verweist auf ihr eigenes Konto und darf keine
|
||||
// Umbuchung geworden sein – sonst hübe sie sich selbst auf.
|
||||
$this->assertEquals(
|
||||
'500.00',
|
||||
Konto::findeNachName('Giro')['anfangssaldo'],
|
||||
'Die Eröffnungsbuchung zählt einfach, nicht doppelt'
|
||||
);
|
||||
}
|
||||
|
||||
private function zweiterLaufAendertNichts(): void
|
||||
{
|
||||
$vorher = Buchung::anzahl();
|
||||
$giro = (float)Konto::findeNachName('Giro')['anfangssaldo'];
|
||||
|
||||
$parser = new QifParser($this->beispiel());
|
||||
$ergebnis = (new QifBuchungsImport($parser, [
|
||||
'stichtag' => '2026-01-01', 'bis' => date('Y-m-d'),
|
||||
]))->ausfuehren();
|
||||
|
||||
$this->assertEquals(0, $ergebnis['buchungen']['neu'], 'Zweiter Lauf legt nichts an');
|
||||
$this->assertEquals(3, $ergebnis['buchungen']['vorhanden'], 'Alle drei als vorhanden erkannt');
|
||||
$this->assertEquals($vorher, Buchung::anzahl(), 'Die Zahl der Buchungen ist unverändert');
|
||||
$this->assertEquals($giro, (float)Konto::findeNachName('Giro')['anfangssaldo'], 'Anfangssaldo bleibt gleich');
|
||||
}
|
||||
|
||||
private function umbuchungsziel(): void
|
||||
{
|
||||
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('[DKB Girokonto]'), 'Einfache Umbuchung');
|
||||
$this->assertEquals('DKB Girokonto', QifBuchungsImport::umbuchungsziel('|[DKB Girokonto]'), 'Wertpapierschreibweise');
|
||||
$this->assertNull(QifBuchungsImport::umbuchungsziel('400 - Leben:Essen'), 'Kategorie ist kein Konto');
|
||||
$this->assertNull(QifBuchungsImport::umbuchungsziel(null), 'Fehlende Angabe');
|
||||
$this->assertNull(QifBuchungsImport::umbuchungsziel('[unvollständig'), 'Fehlende schließende Klammer');
|
||||
}
|
||||
}
|
||||
189
tests/e2e/specs/buchungen.spec.js
Normal file
189
tests/e2e/specs/buchungen.spec.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
const BENUTZER = process.env.E2E_USER || 'e2e'
|
||||
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
|
||||
|
||||
const MARKE = Date.now().toString().slice(-6)
|
||||
const GIRO = `E2E Giro ${MARKE}`
|
||||
const SPAR = `E2E Spar ${MARKE}`
|
||||
const KATEGORIE = `E2E Einkauf ${MARKE}`
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
if (page.url().includes('/einrichtung')) {
|
||||
throw new Error(
|
||||
'Instanz ist noch nicht eingerichtet: ' +
|
||||
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
|
||||
)
|
||||
}
|
||||
await page.fill('#benutzername', BENUTZER)
|
||||
await page.fill('#passwort', PASSWORT)
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
/** Legt ein Konto an und liefert dessen Kennung aus der URL. */
|
||||
async function kontoAnlegen(page, name, anfangssaldo) {
|
||||
await page.goto('/konten/neu')
|
||||
await page.fill('#name', name)
|
||||
await page.fill('#anfangssaldo', anfangssaldo)
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
|
||||
|
||||
const treffer = page.url().match(/\/konten\/(\d+)\//)
|
||||
return treffer ? treffer[1] : null
|
||||
}
|
||||
|
||||
test.describe('Buchungen', () => {
|
||||
let giroId = null
|
||||
let sparId = null
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage()
|
||||
await anmelden(page)
|
||||
|
||||
giroId = await kontoAnlegen(page, GIRO, '1.000,00')
|
||||
sparId = await kontoAnlegen(page, SPAR, '5.000,00')
|
||||
|
||||
await page.goto('/verwaltung/kategorien/neu')
|
||||
await page.fill('#name', KATEGORIE)
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await anmelden(page)
|
||||
})
|
||||
|
||||
test('Kontoblatt zeigt den Anfangssaldo', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}`)
|
||||
|
||||
await expect(page.locator('h1')).toContainText(GIRO)
|
||||
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
|
||||
})
|
||||
|
||||
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-03-18')
|
||||
await page.fill('#betrag', '49,90')
|
||||
await page.fill('#empfaenger', 'Supermarkt')
|
||||
await page.selectOption('#kategorie_id', { label: KATEGORIE })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: 'Supermarkt' })
|
||||
await expect(zeile).toContainText('-49,90')
|
||||
await expect(zeile).toContainText('950,10')
|
||||
await expect(page.locator('.saldoanzeige').first()).toContainText('950,10')
|
||||
})
|
||||
|
||||
test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.selectOption('#art', 'umbuchung_ab')
|
||||
await page.fill('#datum', '2026-04-01')
|
||||
await page.fill('#betrag', '500')
|
||||
await page.fill('#empfaenger', `Übertrag ${MARKE}`)
|
||||
await page.selectOption('#gegenkonto_id', { label: SPAR })
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Ausgangskonto: Abgang, mit dem Gegenkonto in eckigen Klammern.
|
||||
const hin = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
|
||||
await expect(hin).toContainText('-500,00')
|
||||
await expect(hin).toContainText(`[${SPAR}]`)
|
||||
|
||||
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen.
|
||||
await page.goto(`/konten/${sparId}`)
|
||||
const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
|
||||
await expect(her).toContainText('500,00')
|
||||
await expect(her).toContainText('Umbuchung')
|
||||
await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00')
|
||||
})
|
||||
|
||||
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.fill('#datum', '2026-05-02')
|
||||
await page.fill('#betrag', '260,49')
|
||||
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
|
||||
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
|
||||
await page.fill('input[name="split[0][betrag]"]', '-162,51')
|
||||
|
||||
await page.selectOption('select[name="split[1][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
|
||||
await page.fill('input[name="split[1][betrag]"]', '-97,98')
|
||||
|
||||
// Die Maske rechnet den Restbetrag mit; er muss aufgehen.
|
||||
await expect(page.locator('#splitsumme')).not.toContainText('Rest')
|
||||
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
|
||||
await expect(zeile).toContainText('Splitbuchung')
|
||||
await expect(zeile).toContainText('-260,49')
|
||||
})
|
||||
|
||||
test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
|
||||
await page.fill('#datum', '2026-05-03')
|
||||
await page.fill('#betrag', '100')
|
||||
await page.fill('#empfaenger', `Krumm ${MARKE}`)
|
||||
await page.selectOption('select[name="split[0][kategorie_id]"]', { label: KATEGORIE })
|
||||
await page.fill('input[name="split[0][betrag]"]', '-60')
|
||||
|
||||
await expect(page.locator('#splitsumme')).toContainText('Rest')
|
||||
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.meldung-fehler')).toBeVisible()
|
||||
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen')
|
||||
})
|
||||
|
||||
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}`)
|
||||
|
||||
await page.fill('input[name="suche"]', 'Supermarkt')
|
||||
await page.click('.filterleiste button[type="submit"]')
|
||||
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1)
|
||||
await expect(page.locator('tbody')).toContainText('Supermarkt')
|
||||
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
|
||||
await expect(page.locator('tbody tr td').nth(7)).toContainText('–')
|
||||
})
|
||||
|
||||
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
|
||||
await page.goto(`/konten/${giroId}`)
|
||||
await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/buchungen\/\d+/)
|
||||
|
||||
// Der Dialog MUSS kommen: die Bestätigung hängt an data-bestaetigen und
|
||||
// wird von /static/app.js gesetzt, weil Inline-Handler an der CSP
|
||||
// scheitern. Der Handler muss vor dem Klick stehen und den Dialog
|
||||
// sofort schließen – ein offener Dialog blockiert die Klickaktion.
|
||||
const gefragt = []
|
||||
page.on('dialog', async (dialog) => {
|
||||
gefragt.push(dialog.message())
|
||||
await dialog.accept()
|
||||
})
|
||||
|
||||
await page.locator('form[action$="/loeschen"] button').click()
|
||||
|
||||
expect(gefragt.join(' ')).toContain('löschen')
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
await expect(page.locator('tbody')).not.toContainText('Supermarkt')
|
||||
})
|
||||
})
|
||||
|
|
@ -123,7 +123,9 @@ test.describe('Stammdaten', () => {
|
|||
await expect(zeile).toHaveCount(1)
|
||||
await expect(zeile.locator('input[name="beschreibung"]')).toHaveValue('aus dem E2E-Test')
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
// Den Dialog sofort schließen: solange er offen ist, kehrt click() nicht
|
||||
// zurück. Die Bestätigung kommt aus /static/app.js (data-bestaetigen).
|
||||
page.on('dialog', async (dialog) => await dialog.accept())
|
||||
await zeile.getByRole('button', { name: 'Löschen' }).click()
|
||||
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
|
||||
|
|
@ -173,7 +175,7 @@ test.describe('Stammdaten', () => {
|
|||
|
||||
// Aufräumen – die Datei bleibt sonst im Importverzeichnis liegen.
|
||||
await page.goto('/verwaltung/import')
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
page.on('dialog', async (dialog) => await dialog.accept())
|
||||
await page.locator('tr', { hasText: dateiname })
|
||||
.getByRole('button', { name: 'Löschen' }).click()
|
||||
await expect(page.locator('body')).not.toContainText(dateiname)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue