Merge pull request 'Fix #21: Abgleich mit dem Bargeld-Tool' (#38) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/38
This commit is contained in:
commit
be61514093
20 changed files with 1824 additions and 9 deletions
51
Changes.md
51
Changes.md
|
|
@ -1,5 +1,56 @@
|
|||
# Änderungen
|
||||
|
||||
## Abgleich mit dem Bargeld-Tool (19.08.2026)
|
||||
|
||||
Bargeldausgaben werden in einem zweiten Programm erfasst; im Finanzmanager kommen sie als
|
||||
Kartenumsatz oder Abhebung an. **Beim Zuweisen einer Kategorie fragt der Finanzmanager
|
||||
jetzt dort nach**, ob es zum Betrag schon eine Buchung gibt – und zeigt die Antwort im
|
||||
Kontoblatt.
|
||||
|
||||
Eingerichtet wird das unter **Verwaltung → Bargeld-Tool**, einer neuen Kachel: Adresse,
|
||||
Benutzerkennung, Passwort (AES-verschlüsselt wie die FinTS-PIN), Suchzeitraum in Tagen und
|
||||
die Auswahl der Kategorien, die überhaupt abgeglichen werden. Die Vormerkung steht als
|
||||
`bargeld_abgleich` an der Kategorie selbst und nicht als Liste von Nummern in den
|
||||
Einstellungen – so verschwindet sie mit der Kategorie, statt als tote Nummer liegenzubleiben.
|
||||
Ein Knopf *Verbindung testen* sagt, ob Adresse und Zugangsdaten stimmen, ohne dass dafür
|
||||
eine Buchung herhalten muss.
|
||||
|
||||
Geprüft wird nur, was geprüft werden soll: eine Kategorie an der Buchung (keine Umbuchung,
|
||||
keine Splitbuchung), diese Kategorie vorgemerkt, eine Adresse konfiguriert. Fällt eines weg,
|
||||
verschwindet der Status – auch beim Umkategorisieren und beim Abwählen einer Kategorie in der
|
||||
Verwaltung, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
|
||||
Gesucht wird rückwärts ab dem Buchungsdatum (Vorgabe zehn Tage): Bargeld wird ausgegeben,
|
||||
bevor der Umsatz auf dem Konto ankommt, nie danach.
|
||||
|
||||
Im Kontoblatt steht das Ergebnis in einer schmalen Spalte **zwischen Klasse und Betrag**:
|
||||
grauer Kreis (wird nicht abgeglichen), grüner Haken (gefunden), rotes X (nicht gefunden),
|
||||
Ausrufezeichen (Abruf gescheitert). Das Ausrufezeichen behält ausdrücklich die Textfarbe des
|
||||
Farbschemas – der Fehler liegt beim Abruf, nicht bei der Buchung; rot oder grün wäre eine
|
||||
Aussage über die Buchung, die niemand treffen kann. Der Tooltip nennt den Treffer bzw. die
|
||||
Meldung der Gegenstelle. Ohne hinterlegte Adresse entfällt die Spalte ganz; sie zeigte sonst
|
||||
in jeder Zeile denselben grauen Kreis. Der Status steht an der Buchung, das Kontoblatt geht
|
||||
also nicht bei jedem Blättern erneut übers Netz.
|
||||
|
||||
Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Weder ein abgeschaltetes Bargeld-Tool noch ein
|
||||
Netzfehler verhindert, dass die Kategorie gespeichert wird. Der Abruf bricht nach acht
|
||||
Sekunden ab. Null Treffer sind kein Fehler – ein 404 dagegen schon, denn er kann ebenso gut
|
||||
heißen, dass es den Endpunkt nicht gibt.
|
||||
|
||||
Die Anforderung an die Gegenstelle (zwei lesende Aufrufe, Basic Auth, JSON) steht im Ticket
|
||||
und in `docs/buchungen.md`. Nebenbei: Test 02 verlangte bisher für jeden
|
||||
Einstellungsbereich einen Reiter im Dialog. Die Bargeld-Einstellungen haben stattdessen eine
|
||||
eigene Seite – deshalb gibt es jetzt `Einstellung::EIGENE_SEITEN`, und der Test prüft die
|
||||
Regel, die eigentlich gemeint war: dass jeder Bereich erreichbar bleibt.
|
||||
|
||||
Der E2E-Test geht denselben Weg im Browser – Kachel, Formular, Zuordnung im Kontoblatt –
|
||||
gegen eine Adresse, die es nicht gibt: Genau daran zeigt sich, dass ein gescheiterter Abruf
|
||||
die Zuordnung nicht aufhält, sondern nur das Ausrufezeichen setzt.
|
||||
|
||||
Test 34 prüft den Abruf gegen einen echten HTTP-Server (der eingebaute Server von PHP mit
|
||||
einem Router, der sich wie das Bargeld-Tool verhält). Nachgebildete Antworten allein würden
|
||||
genau das übergehen, woran ein Abruf scheitert: Basic Auth, HTTP-Status, ein Rumpf, der kein
|
||||
JSON ist.
|
||||
|
||||
## Berichte: Tabellen so breit wie nötig, Zahlen rechtsbündig (18.08.2026)
|
||||
|
||||
Ein Bericht mit zwei oder vier Spalten wurde bisher über die gesamte Fensterbreite gezogen –
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ Haushalt wird beides parallel genutzt.
|
|||
* **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
|
||||
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
|
||||
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.
|
||||
* **Abgleich mit dem Bargeld-Tool** – wer Bargeldausgaben in einem zweiten Programm
|
||||
erfasst, sieht beim Zuweisen einer Kategorie sofort, ob die Buchung dort schon steht:
|
||||
grüner Haken, rotes X oder Ausrufezeichen in einer schmalen Spalte des Kontoblatts.
|
||||
Adresse, Zugangsdaten und die abzugleichenden Kategorien stehen unter
|
||||
*Verwaltung → Bargeld-Tool*.
|
||||
|
||||
Einzelheiten je Modul in `docs/`, die Entwicklung in `Changes.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -201,3 +201,64 @@ verwirft sie stillschweigend, und gelöscht wird ohne Nachfrage. Stattdessen:
|
|||
|
||||
`public/static/app.js` hängt den Dialog daran. Ein E2E-Test besteht auf dem Dialog, damit
|
||||
das nicht unbemerkt zurückfällt.
|
||||
|
||||
## Abgleich mit dem Bargeld-Tool
|
||||
|
||||
Bargeldausgaben werden in einem zweiten Programm (FSCash) erfasst; im Finanzmanager kommen
|
||||
sie als Kartenumsatz oder Abhebung an. Wer einer solchen Buchung eine Kategorie zuweist,
|
||||
soll sehen, ob sie dort schon steht.
|
||||
|
||||
**Eingerichtet wird das unter Verwaltung → Bargeld-Tool**: Adresse, Benutzerkennung,
|
||||
Passwort (AES-verschlüsselt wie die FinTS-PIN), Suchzeitraum in Tagen und die Auswahl der
|
||||
Kategorien, die überhaupt abgeglichen werden. Die Auswahl steht als `bargeld_abgleich` an
|
||||
der Kategorie selbst und nicht als Liste von Nummern in den Einstellungen – so verschwindet
|
||||
sie mit der Kategorie, statt als tote Nummer liegenzubleiben. Die übrigen Angaben liegen im
|
||||
Einstellungsbereich `bargeld`, der bewusst keinen Reiter im Einstellungsdialog hat; er steht
|
||||
dafür in `Einstellung::EIGENE_SEITEN` (Test 02 prüft, dass jeder Bereich erreichbar bleibt).
|
||||
|
||||
**Geprüft wird beim Zuweisen einer Kategorie** – aus der Schnellzuordnung des Kontoblatts
|
||||
ebenso wie aus der Buchungsmaske. `Services\BargeldAbgleich::pruefe()` verlangt drei Dinge:
|
||||
eine Kategorie an der Buchung (keine Umbuchung, keine Splitbuchung), diese Kategorie
|
||||
vorgemerkt, eine Adresse konfiguriert. Fällt eines weg, wird der Status gelöscht – auch beim
|
||||
Umkategorisieren, sonst bliebe ein alter Haken an einer Buchung stehen, die niemand mehr
|
||||
abgleicht. Gesucht wird über `Services\BargeldTool` mit dem Betrag der Buchung, rückwärts ab
|
||||
ihrem Datum: Bargeld wird ausgegeben, bevor der Umsatz auf dem Konto ankommt, nie danach.
|
||||
|
||||
Das Ergebnis steht als `bargeld_status`, `bargeld_hinweis` und `bargeld_geprueft_am` an der
|
||||
Buchung. Das Kontoblatt liest es von dort und geht nicht bei jedem Blättern erneut übers
|
||||
Netz. In der schmalen Spalte zwischen Klasse und Betrag steht:
|
||||
|
||||
| Symbol | Status | Bedeutung |
|
||||
|---|---|---|
|
||||
| ● grau | `NULL` | Die Buchung wird nicht abgeglichen |
|
||||
| ✓ grün | `gefunden` | Im Bargeld-Tool steht eine Buchung mit demselben Betrag |
|
||||
| ✗ rot | `fehlt` | Dort steht keine |
|
||||
| ! | `fehler` | Der Abruf ist gescheitert |
|
||||
|
||||
Das Ausrufezeichen behält ausdrücklich die Textfarbe des Farbschemas: Der Fehler liegt beim
|
||||
Abruf, nicht bei der Buchung – rot oder grün wäre eine Aussage über die Buchung, die niemand
|
||||
treffen kann. Der Tooltip nennt den Treffer bzw. die Meldung der Gegenstelle. Ist keine
|
||||
Adresse hinterlegt, entfällt die Spalte ganz; sie zeigte sonst in jeder Zeile denselben
|
||||
grauen Kreis.
|
||||
|
||||
**Ein Fehlschlag ist ein Ergebnis, kein Abbruch.** Weder ein abgeschaltetes Bargeld-Tool
|
||||
noch ein Netzfehler darf verhindern, dass die Kategorie gespeichert wird; der Abruf bricht
|
||||
nach acht Sekunden ab. Null Treffer sind ausdrücklich kein Fehler – ein 404 dagegen schon,
|
||||
denn er kann ebenso gut heißen, dass es den Endpunkt nicht gibt.
|
||||
|
||||
### Die Schnittstelle
|
||||
|
||||
Zwei lesende Aufrufe, beschrieben im Ticket „automatischer Abgleich mit Bargeld-Tool“:
|
||||
|
||||
```
|
||||
GET {adresse}/api/v1/buchungen?von=2026-08-09&bis=2026-08-19&betrag=-12.50&waehrung=EUR&limit=20
|
||||
GET {adresse}/api/v1/status
|
||||
```
|
||||
|
||||
Beide mit HTTP Basic Auth. Die Suche antwortet mit `{"anzahl": n, "buchungen": [...]}`; je
|
||||
Buchung sind `id`, `datum` und `betrag` nötig, `zweck`, `empfaenger`, `waehrung`, `kategorie`
|
||||
und `konto` gehen in den Tooltip. Der Statusaufruf ist freiwillig – kennt die Gegenstelle ihn
|
||||
nicht (404), prüft der Verbindungstest über eine Suchabfrage.
|
||||
|
||||
Der Betrag geht vorzeichenbehaftet über die Leitung, so wie er im Finanzmanager steht:
|
||||
Ausgaben negativ. Führt das Bargeld-Tool seine Beträge positiv, muss es umrechnen.
|
||||
|
|
|
|||
33
migrations/016_bargeld_abgleich.sql
Normal file
33
migrations/016_bargeld_abgleich.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- Abgleich mit dem Bargeld-Tool (FSCash).
|
||||
--
|
||||
-- Bargeldausgaben werden in einem zweiten Programm erfasst. Beim Zuweisen
|
||||
-- einer Kategorie fragt der Finanzmanager dort nach, ob es zum Betrag eine
|
||||
-- Buchung gibt, und merkt sich die Antwort an der Buchung – das Kontoblatt
|
||||
-- zeigt sie als Symbol, ohne bei jedem Blättern erneut übers Netz zu gehen.
|
||||
--
|
||||
-- `bargeld_status` bleibt NULL, solange eine Buchung nichts mit einer zum
|
||||
-- Abgleich vorgemerkten Kategorie zu tun hat; das ist der graue Kreis.
|
||||
|
||||
ALTER TABLE kategorien
|
||||
-- Welche Kategorien überhaupt abgeglichen werden, steht an der Kategorie
|
||||
-- selbst und nicht als Liste von IDs in den Einstellungen: So verschwindet
|
||||
-- die Vormerkung mit der Kategorie, statt als tote Nummer liegenzubleiben.
|
||||
ADD COLUMN IF NOT EXISTS bargeld_abgleich TINYINT(1) NOT NULL DEFAULT 0 AFTER aktiv;
|
||||
|
||||
ALTER TABLE buchungen
|
||||
ADD COLUMN IF NOT EXISTS bargeld_status ENUM('gefunden','fehlt','fehler') NULL AFTER klasse_id,
|
||||
-- Der Treffer bzw. die Fehlermeldung im Klartext – das Kontoblatt hängt
|
||||
-- sie als Tooltip an das Symbol.
|
||||
ADD COLUMN IF NOT EXISTS bargeld_hinweis VARCHAR(255) NULL AFTER bargeld_status,
|
||||
ADD COLUMN IF NOT EXISTS bargeld_geprueft_am DATETIME NULL AFTER bargeld_hinweis;
|
||||
|
||||
-- Die Zugangsdaten des Bargeld-Tools. Sie stehen nicht im zentralen
|
||||
-- Einstellungsdialog, sondern unter Verwaltung → Bargeld-Tool: Dort steht
|
||||
-- auch die Auswahl der abzugleichenden Kategorien, und beides gehört
|
||||
-- zusammen. Das Passwort ist AES-verschlüsselt (Services\Crypto).
|
||||
INSERT INTO einstellungen (schluessel, wert, bereich) VALUES
|
||||
('bargeld_url', '', 'bargeld'),
|
||||
('bargeld_benutzer', '', 'bargeld'),
|
||||
('bargeld_passwort', '', 'bargeld'),
|
||||
('bargeld_tage', '10', 'bargeld')
|
||||
ON DUPLICATE KEY UPDATE schluessel = schluessel;
|
||||
|
|
@ -132,6 +132,11 @@ $router->post('/verwaltung/kontengruppen/{id}',
|
|||
$router->post('/verwaltung/kontengruppen/{id}/loeschen',
|
||||
fn(array $p) => (new VerwaltungController())->kontengruppeLoeschen($p));
|
||||
|
||||
// Die Konfiguration des Bargeld-Tools samt Auswahl der abzugleichenden Kategorien.
|
||||
$router->get('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeld());
|
||||
$router->post('/verwaltung/bargeld', fn() => (new VerwaltungController())->bargeldSpeichern());
|
||||
$router->post('/verwaltung/bargeld/test', fn() => (new VerwaltungController())->bargeldTest());
|
||||
|
||||
// Das Bankleitzahlen-Verzeichnis: nachschlagen und die Institutsliste einlesen.
|
||||
$router->get('/verwaltung/bankinstitute', fn() => (new BankinstituteController())->index());
|
||||
$router->post('/verwaltung/bankinstitute/import', fn() => (new BankinstituteController())->importieren());
|
||||
|
|
|
|||
|
|
@ -1268,6 +1268,38 @@ table.kostentabelle select { max-width: 16rem; width: 100%; }
|
|||
table.kostentabelle th[scope="row"] { font-weight: 400; text-align: left; }
|
||||
table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
|
||||
|
||||
/* Die Abgleichspalte zwischen Klasse und Betrag: so schmal wie ihr Zeichen.
|
||||
Sie erscheint nur, wenn ein Bargeld-Tool eingerichtet ist. */
|
||||
table.liste th.bargeldspalte,
|
||||
table.liste td.bargeldspalte {
|
||||
width: 1.8rem;
|
||||
padding-left: .2rem;
|
||||
padding-right: .2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.liste th.bargeldspalte {
|
||||
font-size: .78rem;
|
||||
color: var(--text-gedaempft);
|
||||
}
|
||||
|
||||
.bargeldmarke {
|
||||
font-size: .95rem;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.bargeld-gefunden { color: var(--positiv); }
|
||||
.bargeld-fehlt { color: var(--negativ); }
|
||||
|
||||
/* Der graue Kreis ist eine Aussage über nichts und tritt entsprechend zurück. */
|
||||
.bargeld-neutral { color: var(--text-gedaempft); font-size: .6rem; }
|
||||
|
||||
/* Das Ausrufezeichen behält ausdrücklich die Textfarbe des Farbschemas: Der
|
||||
Fehler liegt beim Abruf, nicht bei der Buchung – rot oder grün wäre eine
|
||||
Aussage über die Buchung, die niemand treffen kann. */
|
||||
.bargeld-fehler { color: inherit; font-weight: 700; }
|
||||
|
||||
/* ── Formulare ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.formular {
|
||||
|
|
@ -1398,6 +1430,16 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
|
|||
|
||||
.auswahlliste .nebentext { margin-left: .35rem; }
|
||||
|
||||
/* Kategorien in der Auswahlliste stehen als Baum. Eingerückt wird über
|
||||
data-ebene wie in der Kategorienverwaltung – ein style-Attribut verwirft die
|
||||
Content-Security-Policy (style-src 'self'). */
|
||||
.auswahlliste .baumwahl[data-ebene="1"] { padding-left: 1.2rem; }
|
||||
.auswahlliste .baumwahl[data-ebene="2"] { padding-left: 2.4rem; }
|
||||
.auswahlliste .baumwahl[data-ebene="3"] { padding-left: 3.6rem; }
|
||||
.auswahlliste .baumwahl[data-ebene="4"] { padding-left: 4.8rem; }
|
||||
.auswahlliste .baumwahl[data-ebene="5"] { padding-left: 6rem; }
|
||||
.auswahlliste .baumwahl[data-ebene="6"] { padding-left: 7.2rem; }
|
||||
|
||||
.formular input[type="text"],
|
||||
.formular input[type="password"],
|
||||
.formular input[type="number"],
|
||||
|
|
@ -1423,6 +1465,8 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
|
|||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.feldhinweis.warnend { color: var(--warnung); }
|
||||
|
||||
.feldhinweis {
|
||||
font-size: .85rem;
|
||||
color: var(--text-gedaempft);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use Finanzmanager\Models\Kategorie;
|
|||
use Finanzmanager\Models\Klasse;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Models\Wertpapier;
|
||||
use Finanzmanager\Services\BargeldAbgleich;
|
||||
use Finanzmanager\Services\Wertpapiergeschaeft;
|
||||
use Finanzmanager\Services\Wertpapierkosten;
|
||||
use Finanzmanager\View;
|
||||
|
|
@ -78,6 +79,9 @@ class BuchungenController extends BaseController
|
|||
'splits' => Buchung::splitsJeBuchung($gesplittete),
|
||||
// Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet.
|
||||
'wertpapiere' => Wertpapier::alle(true),
|
||||
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
|
||||
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
|
||||
'bargeld' => BargeldAbgleich::eingerichtet(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +124,9 @@ class BuchungenController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.');
|
||||
$abgleich = BargeldAbgleich::pruefe($id);
|
||||
|
||||
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich));
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
|
|
@ -166,7 +172,12 @@ class BuchungenController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gespeichert.');
|
||||
$abgleich = BargeldAbgleich::pruefe($id);
|
||||
|
||||
View::redirect(
|
||||
'/konten/' . $buchung['konto_id'],
|
||||
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $params */
|
||||
|
|
@ -224,7 +235,12 @@ class BuchungenController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
View::redirect($ziel, 'Zuordnung gespeichert.');
|
||||
// Die zugewiesene Kategorie kann eine sein, die gegen das Bargeld-Tool
|
||||
// zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach
|
||||
// als Symbol im Kontoblatt.
|
||||
$abgleich = BargeldAbgleich::pruefe((int)$buchung['id']);
|
||||
|
||||
View::redirect($ziel, 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -286,6 +302,11 @@ class BuchungenController extends BaseController
|
|||
return;
|
||||
}
|
||||
|
||||
// Eine Splitbuchung trägt keine eigene Kategorie mehr; ein früherer
|
||||
// Abgleichstatus wäre danach eine Aussage über eine Zuordnung, die es
|
||||
// nicht mehr gibt. `pruefe()` räumt ihn weg.
|
||||
BargeldAbgleich::pruefe((int)$buchung['id']);
|
||||
|
||||
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Finanzmanager\Controllers;
|
||||
|
||||
use Finanzmanager\Models\Bankinstitut;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Erinnerung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Klasse;
|
||||
|
|
@ -12,6 +13,9 @@ use Finanzmanager\Models\Konto;
|
|||
use Finanzmanager\Models\Kontengruppe;
|
||||
use Finanzmanager\Models\RegelmaessigeBuchung;
|
||||
use Finanzmanager\Models\Vertrag;
|
||||
use Finanzmanager\Services\BargeldAbgleich;
|
||||
use Finanzmanager\Services\BargeldTool;
|
||||
use Finanzmanager\Services\Crypto;
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
|
|
@ -39,7 +43,9 @@ class VerwaltungController extends BaseController
|
|||
'erinnerungen' => Erinnerung::anzahlOffen(),
|
||||
'vertraege' => count(Vertrag::alle(true)),
|
||||
'fristen' => count(Vertrag::fristenLaufenAb(90)),
|
||||
'bargeld' => count(Kategorie::bargeldAuswahl()),
|
||||
],
|
||||
'bargeldAktiv' => BargeldAbgleich::eingerichtet(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -190,4 +196,79 @@ class VerwaltungController extends BaseController
|
|||
'Kontengruppe gelöscht. Die Konten stehen jetzt unter „' . Kontengruppe::OHNE_GRUPPE . '“.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Konfiguration des Bargeld-Tools: Adresse, Zugangsdaten und die
|
||||
* Auswahl der Kategorien, die damit abgeglichen werden.
|
||||
*
|
||||
* Diese Einstellungen stehen bewusst nicht im zentralen
|
||||
* Einstellungsdialog: Ihr größerer Teil ist die Kategorienauswahl, und die
|
||||
* gehört in die Verwaltung, wo die Kategorien selbst gepflegt werden.
|
||||
*/
|
||||
public function bargeld(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$this->render('verwaltung/bargeld.php', [
|
||||
'titel' => 'Bargeld-Tool',
|
||||
'url' => (string)Einstellung::get('bargeld_url', ''),
|
||||
// Nicht 'benutzer': So heißt im Layout der angemeldete Mensch.
|
||||
'benutzerkennung' => (string)Einstellung::get('bargeld_benutzer', ''),
|
||||
'hatPasswort' => (string)Einstellung::get('bargeld_passwort', '') !== '',
|
||||
'tage' => BargeldAbgleich::fensterTage(),
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'auswahl' => Kategorie::bargeldAuswahl(),
|
||||
'schluessel' => Crypto::einsatzbereit(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bargeldSpeichern(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$url = trim($this->post('bargeld_url'));
|
||||
if ($url !== '' && !preg_match('#^https?://#i', $url)) {
|
||||
View::redirect('/verwaltung/bargeld', 'Die Adresse muss mit http:// oder https:// beginnen.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
Einstellung::set('bargeld_url', rtrim($url, '/'), 'bargeld');
|
||||
Einstellung::set('bargeld_benutzer', trim($this->post('bargeld_benutzer')), 'bargeld');
|
||||
|
||||
// Wie bei der FinTS-PIN: Ein leeres Feld heißt „unverändert“, sonst
|
||||
// ließe sich das Passwort nie speichern, ohne es erneut einzutippen.
|
||||
$passwort = $this->post('bargeld_passwort');
|
||||
if ($passwort !== '') {
|
||||
Einstellung::set('bargeld_passwort', Crypto::verschluessle($passwort), 'bargeld');
|
||||
}
|
||||
if ($this->post('passwort_loeschen') !== '') {
|
||||
Einstellung::set('bargeld_passwort', '', 'bargeld');
|
||||
}
|
||||
|
||||
$tage = (int)$this->post('bargeld_tage', (string)BargeldAbgleich::TAGE_STANDARD);
|
||||
Einstellung::set('bargeld_tage', (string)($tage > 0 ? min($tage, 365) : BargeldAbgleich::TAGE_STANDARD), 'bargeld');
|
||||
|
||||
$auswahl = $this->postIds('kategorien');
|
||||
Kategorie::bargeldAuswahlSetzen($auswahl);
|
||||
|
||||
// Was nicht mehr abgeglichen wird, darf auch keinen Haken mehr tragen.
|
||||
BargeldAbgleich::statusAufraeumen($auswahl);
|
||||
|
||||
View::redirect('/verwaltung/bargeld', 'Einstellungen zum Bargeld-Tool gespeichert.');
|
||||
}
|
||||
|
||||
/** Der Verbindungstest – erreichbar? Zugangsdaten richtig? */
|
||||
public function bargeldTest(): void
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
||||
$zugang = BargeldTool::ausEinstellungen();
|
||||
if ($zugang === null) {
|
||||
View::redirect('/verwaltung/bargeld', 'Es ist keine Adresse hinterlegt.', 'fehler');
|
||||
return;
|
||||
}
|
||||
|
||||
$ergebnis = $zugang->test();
|
||||
View::redirect('/verwaltung/bargeld', $ergebnis['meldung'], $ergebnis['erfolg'] ? 'erfolg' : 'fehler');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,18 +280,21 @@ class Buchung
|
|||
$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.bargeld_status, b.bargeld_hinweis,
|
||||
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,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis,
|
||||
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,
|
||||
NULL AS bargeld_status, NULL AS bargeld_hinweis,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,18 @@ class Einstellung
|
|||
'diagnose' => 'Diagnose',
|
||||
];
|
||||
|
||||
/**
|
||||
* Bereiche, die keinen Reiter im Dialog haben, weil sie eine eigene Seite
|
||||
* bekommen haben – dort steht mehr als nur ein paar Felder.
|
||||
*
|
||||
* Die Zuordnung ist nicht bloß Dokumentation: Test 02 prüft, dass jeder in
|
||||
* der Datenbank benutzte Bereich entweder einen Reiter oder hier eine Seite
|
||||
* hat. Sonst lägen Werte in der Datenbank, an die niemand mehr herankommt.
|
||||
*/
|
||||
public const EIGENE_SEITEN = [
|
||||
'bargeld' => '/verwaltung/bargeld',
|
||||
];
|
||||
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function get(string $schluessel, ?string $standard = null): ?string
|
||||
|
|
|
|||
|
|
@ -37,6 +37,46 @@ class Kategorie
|
|||
return Database::fetchAll($sql . ' ORDER BY pfad', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kategorien, die mit dem Bargeld-Tool abgeglichen werden.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function bargeldAuswahl(): array
|
||||
{
|
||||
return array_map('intval', array_column(Database::fetchAll(
|
||||
'SELECT id FROM kategorien WHERE benutzer_id = ? AND bargeld_abgleich = 1 ORDER BY pfad',
|
||||
[Mandant::id()]
|
||||
), 'id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Auswahl der abzugleichenden Kategorien ersetzen – die Liste ist
|
||||
* vollständig, was fehlt, wird abgewählt.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public static function bargeldAuswahlSetzen(array $ids): void
|
||||
{
|
||||
$benutzer = Mandant::id();
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
|
||||
Database::transaktion(static function () use ($ids, $benutzer): void {
|
||||
Database::execute(
|
||||
'UPDATE kategorien SET bargeld_abgleich = 0 WHERE benutzer_id = ?',
|
||||
[$benutzer]
|
||||
);
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
Database::execute(
|
||||
'UPDATE kategorien SET bargeld_abgleich = 1
|
||||
WHERE benutzer_id = ? AND id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')',
|
||||
array_merge([$benutzer], $ids)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$zeile = Database::fetchOne(
|
||||
|
|
|
|||
172
src/Services/BargeldAbgleich.php
Normal file
172
src/Services/BargeldAbgleich.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Database;
|
||||
use Finanzmanager\Mandant;
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
|
||||
/**
|
||||
* Der Abgleich einer Buchung gegen das Bargeld-Tool.
|
||||
*
|
||||
* Bargeldausgaben werden dort erfasst, im Finanzmanager kommen sie als
|
||||
* Kartenumsatz oder Abhebung an. Wer einer solchen Buchung eine Kategorie
|
||||
* zuweist, will wissen, ob sie im Bargeld-Tool schon steht – genau das prüft
|
||||
* diese Klasse und schreibt das Ergebnis an die Buchung. Das Kontoblatt liest
|
||||
* es von dort und geht nicht bei jedem Blättern erneut übers Netz.
|
||||
*
|
||||
* Geprüft wird nur, was geprüft werden soll:
|
||||
*
|
||||
* 1. Die Buchung trägt eine Kategorie (keine Umbuchung, keine Splitbuchung).
|
||||
* 2. Diese Kategorie ist in `Verwaltung → Bargeld-Tool` vorgemerkt.
|
||||
* 3. Es ist eine Adresse konfiguriert.
|
||||
*
|
||||
* Fällt eine der drei Bedingungen weg, wird der Status gelöscht: Die Buchung
|
||||
* hat mit dem Bargeld-Tool nichts zu tun und zeigt im Kontoblatt den grauen
|
||||
* Kreis. Das gilt ausdrücklich auch für eine Umkategorisierung – sonst bliebe
|
||||
* ein alter Haken an einer Buchung stehen, die niemand mehr abgleicht.
|
||||
*
|
||||
* Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Weder ein abgeschaltetes
|
||||
* Bargeld-Tool noch ein Netzfehler darf verhindern, dass die Kategorie
|
||||
* gespeichert wird.
|
||||
*/
|
||||
class BargeldAbgleich
|
||||
{
|
||||
public const GEFUNDEN = 'gefunden';
|
||||
public const FEHLT = 'fehlt';
|
||||
public const FEHLER = 'fehler';
|
||||
|
||||
/** Standardbreite des Suchfensters in Tagen, wenn nichts eingestellt ist. */
|
||||
public const TAGE_STANDARD = 10;
|
||||
|
||||
/** Ist überhaupt ein Bargeld-Tool hinterlegt? */
|
||||
public static function eingerichtet(): bool
|
||||
{
|
||||
return trim((string)Einstellung::get('bargeld_url', '')) !== '';
|
||||
}
|
||||
|
||||
/** Das Suchfenster in Tagen, rückwärts ab dem Buchungsdatum. */
|
||||
public static function fensterTage(): int
|
||||
{
|
||||
$tage = Einstellung::getInt('bargeld_tage', self::TAGE_STANDARD);
|
||||
|
||||
return $tage > 0 ? min($tage, 365) : self::TAGE_STANDARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Buchung prüfen und das Ergebnis an ihr festhalten.
|
||||
*
|
||||
* @return string|null der gesetzte Status, oder null, wenn die Buchung
|
||||
* nichts mit dem Bargeld-Tool zu tun hat
|
||||
*/
|
||||
public static function pruefe(int $buchungId): ?string
|
||||
{
|
||||
$buchung = Buchung::find($buchungId);
|
||||
if ($buchung === null || !self::istZuPruefen($buchung)) {
|
||||
self::merke($buchungId, null, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
$zugang = BargeldTool::ausEinstellungen();
|
||||
if ($zugang === null) {
|
||||
self::merke($buchungId, null, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rückwärts ab dem Buchungsdatum: Bargeld wird ausgegeben, bevor der
|
||||
// Umsatz auf dem Konto ankommt – nie danach.
|
||||
$bis = substr((string)$buchung['datum'], 0, 10);
|
||||
$von = date('Y-m-d', strtotime($bis . ' -' . self::fensterTage() . ' days'));
|
||||
|
||||
try {
|
||||
$ergebnis = $zugang->suche((float)$buchung['betrag'], $von, $bis);
|
||||
} catch (\Throwable $e) {
|
||||
self::merke($buchungId, self::FEHLER, $e->getMessage());
|
||||
return self::FEHLER;
|
||||
}
|
||||
|
||||
$status = match (true) {
|
||||
$ergebnis['fehler'] => self::FEHLER,
|
||||
$ergebnis['gefunden'] => self::GEFUNDEN,
|
||||
default => self::FEHLT,
|
||||
};
|
||||
|
||||
self::merke($buchungId, $status, $ergebnis['meldung']);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Rückmeldung an den Benutzer, angehängt an die Meldung der Zuordnung.
|
||||
* Ohne Abgleich bleibt sie leer – wer kein Bargeld-Tool benutzt, soll
|
||||
* davon auch nichts lesen.
|
||||
*/
|
||||
public static function rueckmeldung(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::GEFUNDEN => ' Im Bargeld-Tool gefunden.',
|
||||
self::FEHLT => ' Im Bargeld-Tool nicht gefunden.',
|
||||
self::FEHLER => ' Der Abgleich mit dem Bargeld-Tool ist fehlgeschlagen.',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Buchungen einer Kategorie zurücksetzen – nötig, wenn eine Kategorie
|
||||
* aus der Auswahl genommen wird: Ihre Haken und Kreuze wären danach
|
||||
* Aussagen über einen Abgleich, den es nicht mehr gibt.
|
||||
*
|
||||
* @param list<int> $kategorieIds Kategorien, die weiter abgeglichen werden
|
||||
*/
|
||||
public static function statusAufraeumen(array $kategorieIds): void
|
||||
{
|
||||
$sql = 'UPDATE buchungen SET bargeld_status = NULL, bargeld_hinweis = NULL,
|
||||
bargeld_geprueft_am = NULL
|
||||
WHERE benutzer_id = ? AND bargeld_status IS NOT NULL';
|
||||
$params = [Mandant::id()];
|
||||
|
||||
if ($kategorieIds !== []) {
|
||||
$sql .= ' AND (kategorie_id IS NULL OR kategorie_id NOT IN ('
|
||||
. implode(',', array_fill(0, count($kategorieIds), '?')) . '))';
|
||||
$params = array_merge($params, $kategorieIds);
|
||||
}
|
||||
|
||||
Database::execute($sql, $params);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $buchung */
|
||||
private static function istZuPruefen(array $buchung): bool
|
||||
{
|
||||
if ((int)($buchung['gesplittet'] ?? 0) === 1 || !empty($buchung['gegenkonto_id'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$kategorieId = (int)($buchung['kategorie_id'] ?? 0);
|
||||
if ($kategorieId === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$kategorie = Kategorie::find($kategorieId);
|
||||
|
||||
return $kategorie !== null && (int)($kategorie['bargeld_abgleich'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
private static function merke(int $buchungId, ?string $status, ?string $hinweis): void
|
||||
{
|
||||
Database::execute(
|
||||
'UPDATE buchungen SET bargeld_status = ?, bargeld_hinweis = ?, bargeld_geprueft_am = ?
|
||||
WHERE id = ? AND benutzer_id = ?',
|
||||
[
|
||||
$status,
|
||||
$hinweis === null ? null : mb_substr($hinweis, 0, 255),
|
||||
$status === null ? null : date('Y-m-d H:i:s'),
|
||||
$buchungId,
|
||||
Mandant::id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
300
src/Services/BargeldTool.php
Normal file
300
src/Services/BargeldTool.php
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Finanzmanager\Services;
|
||||
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
|
||||
/**
|
||||
* Der Zugang zum Bargeld-Tool (FSCash), in dem Bargeldausgaben erfasst werden.
|
||||
*
|
||||
* Die Schnittstelle ist strikt lesend und besteht aus zwei Aufrufen: einer
|
||||
* Buchungssuche über Zeitraum und Betrag und einem Statusaufruf für den
|
||||
* Verbindungstest. Beschrieben ist sie in `docs/bargeld-abgleich.md`; die
|
||||
* abgestimmte Fassung steht im Ticket „automatischer Abgleich mit
|
||||
* Bargeld-Tool“.
|
||||
*
|
||||
* Diese Klasse macht nichts als fragen und antworten. Ob überhaupt gefragt
|
||||
* wird und was mit der Antwort geschieht, entscheidet `BargeldAbgleich`.
|
||||
*
|
||||
* Zwei Dinge sind hier wichtig:
|
||||
*
|
||||
* - **Null Treffer sind kein Fehler.** Die Suche antwortet mit HTTP 200 und
|
||||
* leerer Liste; ein 404 gilt als Abrufproblem, weil er ebenso gut heißen
|
||||
* kann, dass es den Endpunkt nicht gibt.
|
||||
* - **Nichts fliegt nach oben.** Ein Netzfehler darf das Zuweisen einer
|
||||
* Kategorie nicht scheitern lassen; jeder Weg endet in einem Ergebnis mit
|
||||
* Status `fehler` und lesbarer Meldung.
|
||||
*
|
||||
* Die Auswertung der Antwort steht in statischen Methoden ohne Netzzugriff –
|
||||
* nur so lässt sie sich prüfen, ohne ein laufendes Bargeld-Tool zu haben.
|
||||
*/
|
||||
class BargeldTool
|
||||
{
|
||||
public const PFAD_BUCHUNGEN = '/api/v1/buchungen';
|
||||
public const PFAD_STATUS = '/api/v1/status';
|
||||
|
||||
/** Zeitgrenze einer Abfrage in Sekunden – die Zuordnung wartet darauf. */
|
||||
private const ZEITGRENZE = 8;
|
||||
|
||||
/** Höchstzahl der Treffer, die die Gegenstelle liefern soll. */
|
||||
private const GRENZE = 20;
|
||||
|
||||
public function __construct(
|
||||
private string $basisUrl,
|
||||
private string $benutzer = '',
|
||||
private string $passwort = ''
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Der konfigurierte Zugang – oder null, wenn keine Adresse hinterlegt ist.
|
||||
* Ohne Adresse findet kein Abgleich statt; das ist der Normalfall für
|
||||
* alle, die kein Bargeld-Tool benutzen.
|
||||
*/
|
||||
public static function ausEinstellungen(): ?self
|
||||
{
|
||||
$url = trim((string)Einstellung::get('bargeld_url', ''));
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$passwort = (string)Einstellung::get('bargeld_passwort', '');
|
||||
|
||||
return new self(
|
||||
$url,
|
||||
trim((string)Einstellung::get('bargeld_benutzer', '')),
|
||||
$passwort === '' ? '' : Crypto::entschluessle($passwort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Buchung mit diesem Betrag im Zeitraum – beide Tage zählen mit.
|
||||
*
|
||||
* @return array{gefunden: bool, treffer: array<string,mixed>|null,
|
||||
* anzahl: int, meldung: string, fehler: bool}
|
||||
*/
|
||||
public function suche(float $betrag, string $von, string $bis): array
|
||||
{
|
||||
$antwort = $this->hole(self::PFAD_BUCHUNGEN, [
|
||||
'von' => $von,
|
||||
'bis' => $bis,
|
||||
'betrag' => number_format($betrag, 2, '.', ''),
|
||||
'waehrung' => (string)Einstellung::get('waehrung', 'EUR'),
|
||||
'limit' => (string)self::GRENZE,
|
||||
]);
|
||||
|
||||
if ($antwort['fehler'] !== '') {
|
||||
return self::misserfolg($antwort['fehler']);
|
||||
}
|
||||
|
||||
return self::trefferAus($antwort['rumpf']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Verbindungstest der Konfigurationsseite. Er fragt den Statusaufruf
|
||||
* ab; kennt die Gegenstelle ihn nicht, gilt eine beantwortete Suchabfrage
|
||||
* als Beweis genug – der Statusaufruf ist laut Anforderung freiwillig.
|
||||
*
|
||||
* @return array{erfolg: bool, meldung: string}
|
||||
*/
|
||||
public function test(): array
|
||||
{
|
||||
$antwort = $this->hole(self::PFAD_STATUS, []);
|
||||
|
||||
if ($antwort['fehler'] === '') {
|
||||
$daten = json_decode($antwort['rumpf'], true);
|
||||
$name = is_array($daten) ? trim((string)($daten['name'] ?? '')) : '';
|
||||
$fassung = is_array($daten) ? trim((string)($daten['version'] ?? '')) : '';
|
||||
|
||||
return [
|
||||
'erfolg' => true,
|
||||
'meldung' => 'Verbindung steht'
|
||||
. ($name === '' ? '' : ' – ' . $name)
|
||||
. ($fassung === '' ? '' : ' ' . $fassung) . '.',
|
||||
];
|
||||
}
|
||||
|
||||
// Kein Statusaufruf vorhanden: dann eben über die Suche, die es geben
|
||||
// muss. Alles andere (401, Verbindungsfehler) ist ein echtes Problem
|
||||
// und wird unverändert gemeldet.
|
||||
if ($antwort['status'] !== 404) {
|
||||
return ['erfolg' => false, 'meldung' => $antwort['fehler']];
|
||||
}
|
||||
|
||||
$heute = date('Y-m-d');
|
||||
$probe = $this->hole(self::PFAD_BUCHUNGEN, [
|
||||
'von' => $heute,
|
||||
'bis' => $heute,
|
||||
'betrag' => '0.00',
|
||||
'limit' => '1',
|
||||
]);
|
||||
|
||||
return $probe['fehler'] === ''
|
||||
? ['erfolg' => true, 'meldung' => 'Verbindung steht (ohne Statusaufruf, über die Buchungssuche geprüft).']
|
||||
: ['erfolg' => false, 'meldung' => $probe['fehler']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antwort der Buchungssuche auswerten. Ohne Netz, damit prüfbar.
|
||||
*
|
||||
* @return array{gefunden: bool, treffer: array<string,mixed>|null,
|
||||
* anzahl: int, meldung: string, fehler: bool}
|
||||
*/
|
||||
public static function trefferAus(string $rumpf): array
|
||||
{
|
||||
$daten = json_decode($rumpf, true);
|
||||
if (!is_array($daten)) {
|
||||
return self::misserfolg('Die Antwort des Bargeld-Tools ist kein JSON.');
|
||||
}
|
||||
|
||||
$buchungen = $daten['buchungen'] ?? null;
|
||||
if (!is_array($buchungen)) {
|
||||
return self::misserfolg('Die Antwort des Bargeld-Tools enthält kein Feld „buchungen“.');
|
||||
}
|
||||
|
||||
// Die Zahl im Feld „anzahl“ gilt: Sie nennt alle Treffer des
|
||||
// Zeitraums, auch die, die das Limit abgeschnitten hat.
|
||||
$anzahl = array_key_exists('anzahl', $daten) ? (int)$daten['anzahl'] : count($buchungen);
|
||||
$erster = null;
|
||||
foreach ($buchungen as $eintrag) {
|
||||
if (is_array($eintrag)) {
|
||||
$erster = $eintrag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($erster === null) {
|
||||
return [
|
||||
'gefunden' => false,
|
||||
'treffer' => null,
|
||||
'anzahl' => 0,
|
||||
'meldung' => 'Keine Buchung mit diesem Betrag im Bargeld-Tool.',
|
||||
'fehler' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'gefunden' => true,
|
||||
'treffer' => $erster,
|
||||
'anzahl' => max(1, $anzahl),
|
||||
'meldung' => self::trefferText($erster, max(1, $anzahl)),
|
||||
'fehler' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/** Der Treffer als eine Zeile für den Tooltip im Kontoblatt. */
|
||||
private static function trefferText(array $treffer, int $anzahl): string
|
||||
{
|
||||
$teile = [];
|
||||
|
||||
$datum = trim((string)($treffer['datum'] ?? ''));
|
||||
if ($datum !== '') {
|
||||
$zeit = strtotime($datum);
|
||||
$teile[] = $zeit === false ? $datum : date('d.m.Y', $zeit);
|
||||
}
|
||||
if (isset($treffer['betrag']) && is_numeric($treffer['betrag'])) {
|
||||
$teile[] = number_format((float)$treffer['betrag'], 2, ',', '.') . ' '
|
||||
. trim((string)($treffer['waehrung'] ?? 'EUR'));
|
||||
}
|
||||
foreach (['zweck', 'empfaenger'] as $feld) {
|
||||
$wert = trim((string)($treffer[$feld] ?? ''));
|
||||
if ($wert !== '') {
|
||||
$teile[] = $wert;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$text = 'Im Bargeld-Tool gefunden: ' . ($teile === [] ? 'Buchung ohne Angaben' : implode(' · ', $teile));
|
||||
|
||||
return $anzahl > 1 ? $text . ' (' . $anzahl . ' Treffer)' : $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Aufruf der Schnittstelle.
|
||||
*
|
||||
* @param array<string,string> $parameter
|
||||
* @return array{rumpf: string, status: int, fehler: string}
|
||||
*/
|
||||
private function hole(string $pfad, array $parameter): array
|
||||
{
|
||||
$adresse = rtrim($this->basisUrl, '/') . $pfad;
|
||||
if ($parameter !== []) {
|
||||
$adresse .= '?' . http_build_query($parameter);
|
||||
}
|
||||
|
||||
$verbindung = curl_init($adresse);
|
||||
if ($verbindung === false) {
|
||||
return ['rumpf' => '', 'status' => 0, 'fehler' => 'Die Adresse des Bargeld-Tools ist unbrauchbar.'];
|
||||
}
|
||||
|
||||
$optionen = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_TIMEOUT => self::ZEITGRENZE,
|
||||
CURLOPT_CONNECTTIMEOUT => self::ZEITGRENZE,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
];
|
||||
if ($this->benutzer !== '' || $this->passwort !== '') {
|
||||
$optionen[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
|
||||
$optionen[CURLOPT_USERPWD] = $this->benutzer . ':' . $this->passwort;
|
||||
}
|
||||
curl_setopt_array($verbindung, $optionen);
|
||||
|
||||
$rumpf = curl_exec($verbindung);
|
||||
$status = (int)curl_getinfo($verbindung, CURLINFO_HTTP_CODE);
|
||||
$curlFehler = curl_error($verbindung);
|
||||
curl_close($verbindung);
|
||||
|
||||
if (!is_string($rumpf)) {
|
||||
DebugLog::schreibe('bargeld', 'Abruf fehlgeschlagen: ' . $curlFehler);
|
||||
return [
|
||||
'rumpf' => '',
|
||||
'status' => $status,
|
||||
'fehler' => 'Das Bargeld-Tool ist nicht erreichbar' . ($curlFehler === '' ? '.' : ': ' . $curlFehler),
|
||||
];
|
||||
}
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
DebugLog::schreibe('bargeld', 'HTTP-Status ' . $status . ' vom Bargeld-Tool.');
|
||||
return [
|
||||
'rumpf' => $rumpf,
|
||||
'status' => $status,
|
||||
'fehler' => self::fehlertext($status, $rumpf),
|
||||
];
|
||||
}
|
||||
|
||||
return ['rumpf' => $rumpf, 'status' => $status, 'fehler' => ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldung zu einem HTTP-Fehler. Nennt die Gegenstelle im Feld `fehler`
|
||||
* einen Grund, hat der Vorrang – sie weiß mehr über sich als wir.
|
||||
*/
|
||||
private static function fehlertext(int $status, string $rumpf): string
|
||||
{
|
||||
$daten = json_decode($rumpf, true);
|
||||
$grund = is_array($daten) ? trim((string)($daten['fehler'] ?? '')) : '';
|
||||
|
||||
if ($grund !== '') {
|
||||
return 'Bargeld-Tool: ' . mb_substr($grund, 0, 180) . ' (HTTP ' . $status . ')';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$status === 401 || $status === 403 => 'Das Bargeld-Tool weist die Zugangsdaten ab (HTTP ' . $status . ').',
|
||||
$status === 404 => 'Das Bargeld-Tool kennt die Schnittstelle nicht (HTTP 404).',
|
||||
$status === 429 => 'Das Bargeld-Tool bremst die Abfragen aus (HTTP 429).',
|
||||
$status >= 500 => 'Das Bargeld-Tool meldet einen Fehler (HTTP ' . $status . ').',
|
||||
default => 'Unerwartete Antwort des Bargeld-Tools (HTTP ' . $status . ').',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{gefunden: bool, treffer: null, anzahl: int, meldung: string, fehler: bool}
|
||||
*/
|
||||
private static function misserfolg(string $meldung): array
|
||||
{
|
||||
return ['gefunden' => false, 'treffer' => null, 'anzahl' => 0, 'meldung' => $meldung, 'fehler' => true];
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use Finanzmanager\View;
|
|||
/** @var list<array<string,mixed>> $klassen */
|
||||
/** @var list<array<string,mixed>> $konten */
|
||||
/** @var array<int, list<array<string,mixed>>> $splits */
|
||||
/** @var bool $bargeld Ist ein Bargeld-Tool eingerichtet? */
|
||||
|
||||
$kategorieNamen = [];
|
||||
foreach ($kategorien as $k) {
|
||||
|
|
@ -104,6 +105,33 @@ $splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $k
|
|||
return implode("\n", $texte);
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Symbol der Abgleichspalte. Vier Zustände, in dieser Reihenfolge zu lesen:
|
||||
* gefunden, nicht gefunden, Abruf gescheitert – und der graue Kreis für alles,
|
||||
* was mit dem Bargeld-Tool nichts zu tun hat.
|
||||
*
|
||||
* Die Zeichen stehen als Text und nicht als Grafik da: Sie erben damit
|
||||
* Schriftgröße und – beim Ausrufezeichen ausdrücklich gewünscht – die Farbe
|
||||
* des übrigen Textes, in jedem Farbschema.
|
||||
*
|
||||
* @param array<string,mixed> $zeile
|
||||
*/
|
||||
$bargeldmarke = static function (array $zeile): string {
|
||||
$status = (string)($zeile['bargeld_status'] ?? '');
|
||||
$hinweis = trim((string)($zeile['bargeld_hinweis'] ?? ''));
|
||||
|
||||
[$zeichen, $klasse, $standardtitel] = match ($status) {
|
||||
'gefunden' => ['✓', 'gefunden', 'Im Bargeld-Tool gefunden'],
|
||||
'fehlt' => ['✗', 'fehlt', 'Im Bargeld-Tool nicht gefunden'],
|
||||
'fehler' => ['!', 'fehler', 'Der Abruf beim Bargeld-Tool ist gescheitert'],
|
||||
default => ['●', 'neutral', 'Wird nicht mit dem Bargeld-Tool abgeglichen'],
|
||||
};
|
||||
|
||||
return '<span class="bargeldmarke bargeld-' . $klasse . '" title="'
|
||||
. View::h($hinweis !== '' ? $hinweis : $standardtitel) . '" role="img" aria-label="'
|
||||
. View::h($standardtitel) . '">' . $zeichen . '</span>';
|
||||
};
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
|
|
@ -199,6 +227,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<th>Buchungstext</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Klasse</th>
|
||||
<?php if ($bargeld): ?>
|
||||
<th class="bargeldspalte" title="Abgleich mit dem Bargeld-Tool">Bar</th>
|
||||
<?php endif; ?>
|
||||
<th class="rechts">Betrag</th>
|
||||
<th class="rechts">Saldo</th>
|
||||
<th>✓</th>
|
||||
|
|
@ -318,6 +349,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<?php if ($bargeld): ?>
|
||||
<td class="bargeldspalte"><?= $bargeldmarke($zeile) ?></td>
|
||||
<?php endif; ?>
|
||||
<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']) ?>
|
||||
|
|
|
|||
125
src/Views/verwaltung/bargeld.php
Normal file
125
src/Views/verwaltung/bargeld.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
use Finanzmanager\View;
|
||||
|
||||
/**
|
||||
* Die Konfiguration des Bargeld-Tools: Adresse, Zugangsdaten und die Auswahl
|
||||
* der Kategorien, deren Buchungen dort nachgeschlagen werden.
|
||||
*
|
||||
* @var string $url
|
||||
* @var string $benutzerkennung
|
||||
* @var bool $hatPasswort
|
||||
* @var int $tage
|
||||
* @var list<array<string,mixed>> $kategorien Baum, flach mit 'ebene'
|
||||
* @var list<int> $auswahl
|
||||
* @var bool $schluessel APP_KEY vorhanden?
|
||||
* @var string $csrfToken
|
||||
*/
|
||||
|
||||
$gewaehlt = array_fill_keys($auswahl, true);
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
||||
<div class="seitenaktionen">
|
||||
<a href="/verwaltung" class="knopf">Zurück zur Verwaltung</a>
|
||||
<a href="/verwaltung/kategorien" class="knopf">Kategorien</a>
|
||||
<?php if ($url !== ''): ?>
|
||||
<form method="post" action="/verwaltung/bargeld/test" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
<button type="submit" class="knopf">Verbindung testen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<p class="feldhinweis">
|
||||
Bargeldausgaben werden in einem eigenen Programm erfasst. Ist hier eine Adresse
|
||||
hinterlegt, fragt der Finanzmanager beim Zuweisen einer der unten gewählten
|
||||
Kategorien dort nach, ob es im Suchzeitraum vor dem Buchungsdatum eine Buchung
|
||||
mit demselben Betrag gibt. Das Ergebnis steht danach als Symbol im Kontoblatt –
|
||||
grüner Haken gefunden, rotes X nicht gefunden, Ausrufezeichen Abruf gescheitert.
|
||||
</p>
|
||||
|
||||
<?php if (!$schluessel): ?>
|
||||
<p class="feldhinweis warnend">
|
||||
<code>APP_KEY</code> ist nicht gesetzt. Ohne ihn lässt sich kein Passwort
|
||||
speichern – die übrigen Angaben schon.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/verwaltung/bargeld" class="formular breit">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
||||
<label for="bargeld_url">Adresse</label>
|
||||
<input type="text" id="bargeld_url" name="bargeld_url" value="<?= View::h($url) ?>"
|
||||
placeholder="https://…" inputmode="url">
|
||||
<p class="feldhinweis">
|
||||
Die Basisadresse des Bargeld-Tools ohne Pfad; der Finanzmanager hängt
|
||||
<code>/api/v1/buchungen</code> selbst an. Leer lassen schaltet den Abgleich ab –
|
||||
das Kontoblatt zeigt dann überall den grauen Kreis.
|
||||
</p>
|
||||
|
||||
<label for="bargeld_benutzer">Benutzerkennung</label>
|
||||
<input type="text" id="bargeld_benutzer" name="bargeld_benutzer"
|
||||
value="<?= View::h($benutzerkennung) ?>" autocomplete="off">
|
||||
|
||||
<label for="bargeld_passwort">Passwort</label>
|
||||
<input type="password" id="bargeld_passwort" name="bargeld_passwort" autocomplete="off"
|
||||
<?= $hatPasswort ? 'placeholder="unverändert lassen"' : '' ?>>
|
||||
<p class="feldhinweis">
|
||||
Wird AES-verschlüsselt gespeichert (Schlüssel <code>APP_KEY</code>) und nie wieder
|
||||
angezeigt.
|
||||
<?php if ($hatPasswort): ?>
|
||||
Leer lassen heißt: bisheriges Passwort behalten.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<?php if ($hatPasswort): ?>
|
||||
<label class="ankreuzfeld">
|
||||
<input type="checkbox" name="passwort_loeschen" value="1">
|
||||
<span>Gespeichertes Passwort löschen</span>
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="bargeld_tage">Suchzeitraum in Tagen</label>
|
||||
<input type="number" id="bargeld_tage" name="bargeld_tage" value="<?= (int)$tage ?>"
|
||||
min="1" max="365" class="schmal">
|
||||
<p class="feldhinweis">
|
||||
So viele Tage wird rückwärts ab dem Buchungsdatum gesucht. Bargeld wird
|
||||
ausgegeben, bevor der Umsatz auf dem Konto ankommt – nie danach.
|
||||
</p>
|
||||
|
||||
<h3>Abzugleichende Kategorien</h3>
|
||||
<p class="feldhinweis">
|
||||
Nur Buchungen dieser Kategorien werden nachgeschlagen. Alle anderen bleiben
|
||||
unberührt und zeigen im Kontoblatt den grauen Kreis. Wird eine Kategorie hier
|
||||
abgewählt, verlieren ihre Buchungen den bisherigen Abgleichstatus.
|
||||
</p>
|
||||
<div class="auswahlliste">
|
||||
<?php if ($kategorien === []): ?>
|
||||
<p class="platzhalter">Es gibt noch keine Kategorien.</p>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($kategorien as $kategorie): ?>
|
||||
<?php /* Eingerückt über data-ebene, nicht über ein style-Attribut:
|
||||
Das verwirft die Content-Security-Policy (style-src 'self'). */ ?>
|
||||
<label class="ankreuzfeld baumwahl" data-ebene="<?= min(6, (int)$kategorie['ebene']) ?>">
|
||||
<input type="checkbox" name="kategorien[]" value="<?= (int)$kategorie['id'] ?>"
|
||||
<?= isset($gewaehlt[(int)$kategorie['id']]) ? 'checked' : '' ?>>
|
||||
<span>
|
||||
<?= View::h($kategorie['name']) ?>
|
||||
<span class="nebentext">
|
||||
<?= (string)$kategorie['art'] === 'einnahme' ? 'Einnahme' : 'Ausgabe' ?>
|
||||
<?= (int)$kategorie['aktiv'] === 1 ? '' : '· inaktiv' ?>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="knopfleiste">
|
||||
<button type="submit" class="knopf primaer">Speichern</button>
|
||||
<a href="/verwaltung" class="knopf">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php include __DIR__ . '/../layout/fuss.php'; ?>
|
||||
|
|
@ -4,6 +4,7 @@ use Finanzmanager\Services\Dateiformat;
|
|||
use Finanzmanager\View;
|
||||
|
||||
/** @var array<string,int> $zahlen */
|
||||
/** @var bool $bargeldAktiv */
|
||||
|
||||
include __DIR__ . '/../layout/kopf.php';
|
||||
?>
|
||||
|
|
@ -83,6 +84,23 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Bargeld-Tool</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['bargeld'] ?></p>
|
||||
<p class="platzhalter">
|
||||
<?php if ($bargeldAktiv): ?>
|
||||
Kategorien werden beim Zuweisen gegen das Bargeld-Tool geprüft; das
|
||||
Ergebnis steht im Kontoblatt.
|
||||
<?php else: ?>
|
||||
Bargeldausgaben aus dem Zusatzprogramm gegenprüfen – dazu fehlt noch
|
||||
die Adresse.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="knopfleiste">
|
||||
<a href="/verwaltung/bargeld" class="knopf klein primaer">Bargeld-Tool einrichten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kachel">
|
||||
<header><h2>Termine</h2></header>
|
||||
<p class="kennzahl"><?= (int)$zahlen['reihen'] ?></p>
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ class EinstellungenTest extends TestCase
|
|||
|
||||
private function bereicheVollstaendig(): void
|
||||
{
|
||||
// Jeder in der Datenbank benutzte Bereich braucht einen Reiter im Dialog,
|
||||
// sonst wären die Werte nicht mehr erreichbar.
|
||||
// Jeder in der Datenbank benutzte Bereich braucht einen Reiter im Dialog
|
||||
// oder eine eigene Seite (Einstellung::EIGENE_SEITEN), sonst wären die
|
||||
// Werte nicht mehr erreichbar.
|
||||
$bereiche = array_column(
|
||||
Finanzmanager\Database::fetchAll('SELECT DISTINCT bereich FROM einstellungen'),
|
||||
'bereich'
|
||||
|
|
@ -74,8 +75,9 @@ class EinstellungenTest extends TestCase
|
|||
|
||||
foreach ($bereiche as $bereich) {
|
||||
$this->assert(
|
||||
array_key_exists($bereich, Einstellung::BEREICHE),
|
||||
"Bereich '{$bereich}' hat einen Reiter im Einstellungsdialog"
|
||||
array_key_exists($bereich, Einstellung::BEREICHE)
|
||||
|| array_key_exists($bereich, Einstellung::EIGENE_SEITEN),
|
||||
"Bereich '{$bereich}' ist erreichbar – als Reiter oder über eine eigene Seite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ class NavigationTest extends TestCase
|
|||
/** Die Zahlen, die die Verwaltungsseite für ihre Kacheln erwartet. */
|
||||
private const ZAHLEN = [
|
||||
'kategorien' => 0, 'einnahmen' => 0, 'ausgaben' => 0, 'klassen' => 0,
|
||||
'konten' => 0, 'kontengruppen' => 0, 'reihen' => 3, 'erinnerungen' => 2,
|
||||
'vertraege' => 7, 'fristen' => 1,
|
||||
'konten' => 0, 'kontengruppen' => 0, 'bankinstitute' => 0, 'bargeld' => 0,
|
||||
'reihen' => 3, 'erinnerungen' => 2, 'vertraege' => 7, 'fristen' => 1,
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
|
|
@ -180,6 +180,7 @@ class NavigationTest extends TestCase
|
|||
$seite = $this->rendere('verwaltung/index.php', [
|
||||
'aktuellerPfad' => '/verwaltung',
|
||||
'zahlen' => self::ZAHLEN,
|
||||
'bargeldAktiv' => false,
|
||||
]);
|
||||
|
||||
return substr($seite, (int)strpos($seite, '<main'));
|
||||
|
|
|
|||
627
tests/34_BargeldAbgleichTest.php
Normal file
627
tests/34_BargeldAbgleichTest.php
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Models\Buchung;
|
||||
use Finanzmanager\Models\Einstellung;
|
||||
use Finanzmanager\Models\Kategorie;
|
||||
use Finanzmanager\Models\Konto;
|
||||
use Finanzmanager\Services\BargeldAbgleich;
|
||||
use Finanzmanager\Services\BargeldTool;
|
||||
use Finanzmanager\Services\Crypto;
|
||||
|
||||
/**
|
||||
* Der Abgleich mit dem Bargeld-Tool: Konfiguration, Kategorienauswahl, der
|
||||
* Abruf selbst und der Status, den das Kontoblatt als Symbol zeigt.
|
||||
*
|
||||
* Der Abruf wird gegen einen echten HTTP-Server geprüft – den eingebauten
|
||||
* Server von PHP mit einem Router, der sich wie das Bargeld-Tool verhält.
|
||||
* Nachgebildete Antworten allein würden genau das übergehen, woran ein Abruf
|
||||
* scheitert: Basic Auth, HTTP-Status, ein Rumpf, der kein JSON ist. Lässt sich
|
||||
* der Server nicht starten, entfallen die Netzprüfungen; alles Übrige läuft
|
||||
* ohne ihn.
|
||||
*/
|
||||
class BargeldAbgleichTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 34: Abgleich mit dem Bargeld-Tool';
|
||||
|
||||
private int $konto = 0;
|
||||
private int $bargeldKategorie = 0;
|
||||
private int $andereKategorie = 0;
|
||||
|
||||
/** @var resource|null */
|
||||
private $server = null;
|
||||
private string $adresse = '';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->mandantAnlegen();
|
||||
$this->stammdaten();
|
||||
|
||||
$this->antwortAuswerten();
|
||||
$this->kategorienauswahl();
|
||||
$this->ohneAdresseKeinAbgleich();
|
||||
$this->passwortStehtVerschluesselt();
|
||||
$this->suchfensterBleibtInGrenzen();
|
||||
|
||||
$this->mitServer();
|
||||
|
||||
$this->statusAufraeumenNimmtDenHakenWeg();
|
||||
$this->kontoblattFuehrtDenStatusMit();
|
||||
$this->kontoblattZeigtDieViervierSymbole();
|
||||
$this->konfigurationsseiteZeigtDieAuswahl();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->serverBeenden();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function stammdaten(): void
|
||||
{
|
||||
$this->konto = Konto::anlegen([
|
||||
'name' => 'Bargeld Giro', 'kontoart' => 'bank', 'anfangssaldo' => 500, 'aktiv' => 1,
|
||||
]);
|
||||
$this->bargeldKategorie = Kategorie::anlegen(['name' => 'Bargeld Haushalt', 'art' => 'ausgabe']);
|
||||
$this->andereKategorie = Kategorie::anlegen(['name' => 'Bargeld Versicherung', 'art' => 'ausgabe']);
|
||||
|
||||
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]);
|
||||
}
|
||||
|
||||
private function buchung(float $betrag = -12.50, string $datum = '2026-03-10'): int
|
||||
{
|
||||
return Buchung::anlegen([
|
||||
'konto_id' => $this->konto,
|
||||
'datum' => $datum,
|
||||
'betrag' => $betrag,
|
||||
'empfaenger' => 'Bargeldabhebung',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Auswertung der Antwort, ohne Netz ────────────────────────────────────
|
||||
|
||||
private function antwortAuswerten(): void
|
||||
{
|
||||
$treffer = BargeldTool::trefferAus(
|
||||
'{"anzahl":1,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50,'
|
||||
. '"waehrung":"EUR","zweck":"Wocheneinkauf"}]}'
|
||||
);
|
||||
$this->assert($treffer['gefunden'], 'Eine Buchung in der Antwort gilt als gefunden');
|
||||
$this->assert(!$treffer['fehler'], 'Ein Treffer ist kein Fehler');
|
||||
$this->assertEquals(1, $treffer['anzahl'], 'Die Trefferzahl steht im Ergebnis');
|
||||
$this->assert(
|
||||
str_contains($treffer['meldung'], '08.03.2026') && str_contains($treffer['meldung'], 'Wocheneinkauf'),
|
||||
'Die Meldung nennt Datum und Zweck des Treffers'
|
||||
);
|
||||
|
||||
$leer = BargeldTool::trefferAus('{"anzahl":0,"buchungen":[]}');
|
||||
$this->assert(!$leer['gefunden'], 'Eine leere Liste heißt: nicht gefunden');
|
||||
$this->assert(!$leer['fehler'], 'Null Treffer sind kein Fehler');
|
||||
|
||||
$mehrere = BargeldTool::trefferAus(
|
||||
'{"anzahl":3,"buchungen":[{"id":"a1","datum":"2026-03-08","betrag":-12.50}]}'
|
||||
);
|
||||
$this->assertEquals(3, $mehrere['anzahl'], 'Das Feld „anzahl“ zählt auch abgeschnittene Treffer');
|
||||
$this->assert(str_contains($mehrere['meldung'], '3 Treffer'), 'Mehrere Treffer stehen in der Meldung');
|
||||
|
||||
$kaputt = BargeldTool::trefferAus('<html>Fehlerseite</html>');
|
||||
$this->assert($kaputt['fehler'], 'Eine Antwort ohne JSON ist ein Fehler');
|
||||
$this->assert(!$kaputt['gefunden'], 'Eine kaputte Antwort gilt nicht als Treffer');
|
||||
|
||||
$ohneFeld = BargeldTool::trefferAus('{"anzahl":1}');
|
||||
$this->assert($ohneFeld['fehler'], 'Ohne Feld „buchungen“ ist die Antwort unbrauchbar');
|
||||
}
|
||||
|
||||
// ── Auswahl der Kategorien ───────────────────────────────────────────────
|
||||
|
||||
private function kategorienauswahl(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
[$this->bargeldKategorie],
|
||||
Kategorie::bargeldAuswahl(),
|
||||
'Die vorgemerkte Kategorie steht in der Auswahl'
|
||||
);
|
||||
|
||||
$kategorie = Kategorie::find($this->bargeldKategorie);
|
||||
$this->assertEquals(1, (int)$kategorie['bargeld_abgleich'], 'Die Vormerkung steht an der Kategorie');
|
||||
|
||||
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie, $this->andereKategorie]);
|
||||
$this->assertEquals(2, count(Kategorie::bargeldAuswahl()), 'Die Auswahl lässt sich erweitern');
|
||||
|
||||
Kategorie::bargeldAuswahlSetzen([]);
|
||||
$this->assertEquals([], Kategorie::bargeldAuswahl(), 'Eine leere Liste wählt alles ab');
|
||||
|
||||
Kategorie::bargeldAuswahlSetzen([$this->bargeldKategorie]);
|
||||
$this->assertEquals(
|
||||
[$this->bargeldKategorie],
|
||||
Kategorie::bargeldAuswahl(),
|
||||
'Die Auswahl wird ersetzt, nicht ergänzt'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ohne Konfiguration bleibt alles ruhig ────────────────────────────────
|
||||
|
||||
private function ohneAdresseKeinAbgleich(): void
|
||||
{
|
||||
Einstellung::set('bargeld_url', '', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$this->assert(!BargeldAbgleich::eingerichtet(), 'Ohne Adresse gilt der Abgleich als nicht eingerichtet');
|
||||
|
||||
$id = $this->buchung();
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertNull(BargeldAbgleich::pruefe($id), 'Ohne Adresse wird nicht abgeglichen');
|
||||
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Ohne Adresse bleibt der Status leer');
|
||||
}
|
||||
|
||||
private function passwortStehtVerschluesselt(): void
|
||||
{
|
||||
if (!Crypto::einsatzbereit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Einstellung::set('bargeld_passwort', Crypto::verschluessle('geheim123'), 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$gespeichert = (string)Einstellung::get('bargeld_passwort');
|
||||
$this->assert(
|
||||
Crypto::istVerschluesselt($gespeichert),
|
||||
'Das Passwort des Bargeld-Tools steht verschlüsselt in der Datenbank'
|
||||
);
|
||||
$this->assertEquals('geheim123', Crypto::entschluessle($gespeichert), 'Es lässt sich wieder lesen');
|
||||
}
|
||||
|
||||
private function suchfensterBleibtInGrenzen(): void
|
||||
{
|
||||
Einstellung::set('bargeld_tage', '', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
$this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Ohne Angabe gilt das Fenster von zehn Tagen');
|
||||
|
||||
Einstellung::set('bargeld_tage', '0', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
$this->assertEquals(10, BargeldAbgleich::fensterTage(), 'Null Tage ergäben kein Fenster – der Standard greift');
|
||||
|
||||
Einstellung::set('bargeld_tage', '9999', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
$this->assertEquals(365, BargeldAbgleich::fensterTage(), 'Das Fenster wird bei einem Jahr gekappt');
|
||||
|
||||
Einstellung::set('bargeld_tage', '5', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
$this->assertEquals(5, BargeldAbgleich::fensterTage(), 'Eine eigene Angabe gilt');
|
||||
}
|
||||
|
||||
// ── Mit laufendem Bargeld-Tool ───────────────────────────────────────────
|
||||
|
||||
private function mitServer(): void
|
||||
{
|
||||
if (!$this->serverStarten()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Einstellung::set('bargeld_url', $this->adresse, 'bargeld');
|
||||
Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld');
|
||||
Einstellung::set(
|
||||
'bargeld_passwort',
|
||||
Crypto::einsatzbereit() ? Crypto::verschluessle('offen') : 'offen',
|
||||
'bargeld'
|
||||
);
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$this->assert(BargeldAbgleich::eingerichtet(), 'Mit Adresse gilt der Abgleich als eingerichtet');
|
||||
|
||||
$this->verbindungstest();
|
||||
$this->gefunden();
|
||||
$this->nichtGefunden();
|
||||
$this->serverfehler();
|
||||
$this->falscheZugangsdaten();
|
||||
$this->umbuchungBleibtUnberuehrt();
|
||||
$this->andereKategorieBleibtUnberuehrt();
|
||||
$this->suchfensterWirdMitgeschickt();
|
||||
}
|
||||
|
||||
private function verbindungstest(): void
|
||||
{
|
||||
$zugang = BargeldTool::ausEinstellungen();
|
||||
$this->assertNotEmpty($zugang, 'Aus den Einstellungen entsteht ein Zugang');
|
||||
|
||||
$ergebnis = $zugang->test();
|
||||
$this->assert($ergebnis['erfolg'], 'Der Verbindungstest erreicht das Bargeld-Tool');
|
||||
$this->assert(
|
||||
str_contains($ergebnis['meldung'], 'FSCash-Test'),
|
||||
'Der Test nennt Namen und Fassung der Gegenstelle'
|
||||
);
|
||||
}
|
||||
|
||||
private function gefunden(): void
|
||||
{
|
||||
$id = $this->buchung(-12.50);
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertEquals(BargeldAbgleich::GEFUNDEN, BargeldAbgleich::pruefe($id), 'Der Betrag wird gefunden');
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assertEquals('gefunden', $buchung['bargeld_status'], 'Der Status steht an der Buchung');
|
||||
$this->assert(
|
||||
str_contains((string)$buchung['bargeld_hinweis'], 'Wocheneinkauf'),
|
||||
'Der Treffer steht als Hinweis an der Buchung'
|
||||
);
|
||||
$this->assertNotEmpty($buchung['bargeld_geprueft_am'], 'Der Zeitpunkt der Prüfung wird festgehalten');
|
||||
}
|
||||
|
||||
private function nichtGefunden(): void
|
||||
{
|
||||
$id = $this->buchung(-99.00);
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertEquals(BargeldAbgleich::FEHLT, BargeldAbgleich::pruefe($id), 'Ein unbekannter Betrag fehlt');
|
||||
$this->assertEquals('fehlt', Buchung::find($id)['bargeld_status'], 'Der Status „fehlt“ steht an der Buchung');
|
||||
}
|
||||
|
||||
private function serverfehler(): void
|
||||
{
|
||||
$id = $this->buchung(-500.00);
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertEquals(BargeldAbgleich::FEHLER, BargeldAbgleich::pruefe($id), 'HTTP 500 ergibt den Status Fehler');
|
||||
|
||||
$buchung = Buchung::find($id);
|
||||
$this->assert(
|
||||
str_contains((string)$buchung['bargeld_hinweis'], 'Kasse steht still'),
|
||||
'Die Meldung der Gegenstelle steht im Hinweis'
|
||||
);
|
||||
}
|
||||
|
||||
private function falscheZugangsdaten(): void
|
||||
{
|
||||
Einstellung::set('bargeld_benutzer', 'niemand', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$id = $this->buchung(-12.50);
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertEquals(
|
||||
BargeldAbgleich::FEHLER,
|
||||
BargeldAbgleich::pruefe($id),
|
||||
'Abgewiesene Zugangsdaten ergeben einen Fehler, kein „nicht gefunden“'
|
||||
);
|
||||
|
||||
Einstellung::set('bargeld_benutzer', 'kasse', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
}
|
||||
|
||||
private function umbuchungBleibtUnberuehrt(): void
|
||||
{
|
||||
$spar = Konto::anlegen([
|
||||
'name' => 'Bargeld Spar', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||||
]);
|
||||
|
||||
$id = $this->buchung(-12.50);
|
||||
Buchung::zuordnen($id, null, $spar);
|
||||
|
||||
$this->assertNull(BargeldAbgleich::pruefe($id), 'Eine Umbuchung wird nicht abgeglichen');
|
||||
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Eine Umbuchung trägt keinen Status');
|
||||
}
|
||||
|
||||
private function andereKategorieBleibtUnberuehrt(): void
|
||||
{
|
||||
$id = $this->buchung(-12.50);
|
||||
Buchung::zuordnen($id, $this->andereKategorie, null);
|
||||
|
||||
$this->assertNull(BargeldAbgleich::pruefe($id), 'Eine nicht vorgemerkte Kategorie wird nicht abgeglichen');
|
||||
$this->assertNull(Buchung::find($id)['bargeld_status'], 'Sie bleibt ohne Status – der graue Kreis');
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Fenster reicht rückwärts ab dem Buchungsdatum. Der Testserver
|
||||
* antwortet nur, wenn `von` und `bis` genau dazu passen.
|
||||
*/
|
||||
private function suchfensterWirdMitgeschickt(): void
|
||||
{
|
||||
Einstellung::set('bargeld_tage', '10', 'bargeld');
|
||||
Einstellung::cacheLeeren();
|
||||
|
||||
$id = $this->buchung(-7.77, '2026-03-20');
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
|
||||
$this->assertEquals(
|
||||
BargeldAbgleich::GEFUNDEN,
|
||||
BargeldAbgleich::pruefe($id),
|
||||
'Gesucht wird von 2026-03-10 bis 2026-03-20 – zehn Tage rückwärts ab dem Buchungsdatum'
|
||||
);
|
||||
}
|
||||
|
||||
// ── Aufräumen und Kontoblatt ─────────────────────────────────────────────
|
||||
|
||||
private function statusAufraeumenNimmtDenHakenWeg(): void
|
||||
{
|
||||
$id = $this->buchung(-12.50);
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
Finanzmanager\Database::execute(
|
||||
"UPDATE buchungen SET bargeld_status = 'gefunden', bargeld_hinweis = 'x' WHERE id = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
BargeldAbgleich::statusAufraeumen([$this->bargeldKategorie]);
|
||||
$this->assertEquals(
|
||||
'gefunden',
|
||||
Buchung::find($id)['bargeld_status'],
|
||||
'Eine weiterhin vorgemerkte Kategorie behält ihren Status'
|
||||
);
|
||||
|
||||
BargeldAbgleich::statusAufraeumen([$this->andereKategorie]);
|
||||
$this->assertNull(
|
||||
Buchung::find($id)['bargeld_status'],
|
||||
'Wird die Kategorie abgewählt, verschwindet der Status'
|
||||
);
|
||||
$this->assertNull(Buchung::find($id)['bargeld_hinweis'], 'Der Hinweis verschwindet mit');
|
||||
}
|
||||
|
||||
private function kontoblattFuehrtDenStatusMit(): void
|
||||
{
|
||||
$id = $this->buchung(-33.00, '2026-04-01');
|
||||
Buchung::zuordnen($id, $this->bargeldKategorie, null);
|
||||
Finanzmanager\Database::execute(
|
||||
"UPDATE buchungen SET bargeld_status = 'fehlt', bargeld_hinweis = 'nicht da' WHERE id = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
$zeilen = Buchung::kontoblatt($this->konto, ['von' => '2026-04-01', 'bis' => '2026-04-01']);
|
||||
$this->assertEquals(1, count($zeilen), 'Die Buchung steht im Kontoblatt');
|
||||
$this->assertEquals('fehlt', $zeilen[0]['bargeld_status'], 'Das Kontoblatt liefert den Abgleichstatus mit');
|
||||
$this->assertEquals('nicht da', $zeilen[0]['bargeld_hinweis'], 'Der Hinweis kommt für den Tooltip mit');
|
||||
}
|
||||
|
||||
// ── Die Anzeige ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Die schmale Spalte zwischen Klasse und Betrag trägt genau vier Zustände.
|
||||
* Geprüft wird am gerenderten Kontoblatt, nicht an der Absicht.
|
||||
*/
|
||||
private function kontoblattZeigtDieViervierSymbole(): void
|
||||
{
|
||||
$zeilen = [];
|
||||
foreach ([null, 'gefunden', 'fehlt', 'fehler'] as $nr => $status) {
|
||||
$zeilen[] = [
|
||||
'id' => 900 + $nr, 'datum' => '2026-05-0' . ($nr + 1), 'valuta' => null,
|
||||
'betrag' => -10.0, 'empfaenger' => 'Zeile ' . $nr, 'buchungstext' => '',
|
||||
'nummer' => '', 'abgeglichen' => 0, 'kategorie_id' => $this->bargeldKategorie,
|
||||
'klasse_id' => null, 'gesplittet' => 0, 'quelle' => 'manuell',
|
||||
'bargeld_status' => $status, 'bargeld_hinweis' => $status === 'fehler' ? 'Kasse steht still.' : null,
|
||||
'gegenkonto_id' => null, 'konto_id' => $this->konto, 'gegensicht' => false,
|
||||
'split_id' => null, 'saldo' => 100.0,
|
||||
];
|
||||
}
|
||||
|
||||
$mitSpalte = $this->kontoblattHtml($zeilen, true);
|
||||
|
||||
$this->assert(
|
||||
(bool)preg_match('#<th class="bargeldspalte"[^>]*>Bar</th>\s*<th class="rechts">Betrag</th>#', $mitSpalte),
|
||||
'Die Abgleichspalte steht zwischen Klasse und Betrag'
|
||||
);
|
||||
$this->assert((bool)preg_match('#bargeld-neutral"[^>]*>●<#u', $mitSpalte), 'Ohne Status steht dort der graue Kreis');
|
||||
$this->assert((bool)preg_match('#bargeld-gefunden"[^>]*>✓<#u', $mitSpalte), 'Gefunden ergibt den Haken');
|
||||
$this->assert((bool)preg_match('#bargeld-fehlt"[^>]*>✗<#u', $mitSpalte), 'Nicht gefunden ergibt das X');
|
||||
$this->assert((bool)preg_match('#bargeld-fehler"[^>]*>!<#u', $mitSpalte), 'Ein Abrufproblem ergibt das Ausrufezeichen');
|
||||
$this->assert(
|
||||
str_contains($mitSpalte, 'title="Kasse steht still."'),
|
||||
'Die Meldung der Gegenstelle steht im Tooltip'
|
||||
);
|
||||
|
||||
$stil = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
|
||||
$this->assert(
|
||||
(bool)preg_match('#\.bargeld-gefunden\s*\{[^}]*var\(--positiv\)#', $stil),
|
||||
'Der Haken nimmt die Farbe des Farbschemas für Positives'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match('#\.bargeld-fehlt\s*\{[^}]*var\(--negativ\)#', $stil),
|
||||
'Das X nimmt die Farbe für Negatives'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match('#\.bargeld-fehler\s*\{[^}]*color:\s*inherit#', $stil),
|
||||
'Das Ausrufezeichen erbt die Textfarbe – je nach Farbschema'
|
||||
);
|
||||
|
||||
$ohneSpalte = $this->kontoblattHtml($zeilen, false);
|
||||
$this->assert(
|
||||
!str_contains($ohneSpalte, 'bargeldspalte'),
|
||||
'Ohne eingerichtetes Bargeld-Tool fehlt die Spalte ganz'
|
||||
);
|
||||
}
|
||||
|
||||
private function konfigurationsseiteZeigtDieAuswahl(): void
|
||||
{
|
||||
$html = $this->rendere('verwaltung/bargeld.php', [
|
||||
'url' => 'https://kasse.example.net',
|
||||
'benutzerkennung' => 'kasse',
|
||||
'hatPasswort' => true,
|
||||
'tage' => 10,
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'auswahl' => [$this->bargeldKategorie],
|
||||
'schluessel' => true,
|
||||
]);
|
||||
|
||||
foreach (['bargeld_url', 'bargeld_benutzer', 'bargeld_passwort', 'bargeld_tage'] as $feld) {
|
||||
$this->assert(str_contains($html, 'name="' . $feld . '"'), 'Das Feld ' . $feld . ' steht im Formular');
|
||||
}
|
||||
$this->assert(
|
||||
(bool)preg_match(
|
||||
'#name="kategorien\[\]" value="' . $this->bargeldKategorie . '"\s+checked#',
|
||||
$html
|
||||
),
|
||||
'Die vorgemerkte Kategorie ist angekreuzt'
|
||||
);
|
||||
$this->assert(
|
||||
(bool)preg_match(
|
||||
'#name="kategorien\[\]" value="' . $this->andereKategorie . '"\s*>#',
|
||||
$html
|
||||
),
|
||||
'Die übrigen Kategorien stehen zur Wahl, aber ohne Kreuz'
|
||||
);
|
||||
$this->assert(
|
||||
!str_contains($html, 'style="'),
|
||||
'Kein style-Attribut – die Content-Security-Policy verwirft es'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($html, 'action="/verwaltung/bargeld/test"'),
|
||||
'Der Verbindungstest ist erreichbar'
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $zeilen */
|
||||
private function kontoblattHtml(array $zeilen, bool $bargeld): string
|
||||
{
|
||||
return $this->rendere('buchungen/kontoblatt.php', [
|
||||
'konto' => Konto::mitSalden(Konto::find($this->konto)),
|
||||
'zeilen' => $zeilen,
|
||||
'gesamt' => count($zeilen),
|
||||
'seite' => 1,
|
||||
'seiten' => 1,
|
||||
'filter' => [],
|
||||
'kategorien' => Kategorie::baumFlach(),
|
||||
'klassen' => [],
|
||||
'konten' => Konto::alle(),
|
||||
'splits' => [],
|
||||
'wertpapiere' => [],
|
||||
'bargeld' => $bargeld,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine View rendern wie der Controller es täte – ohne Webserver.
|
||||
*
|
||||
* @param array<string,mixed> $daten
|
||||
*/
|
||||
private function rendere(string $view, array $daten): string
|
||||
{
|
||||
$daten += ['titel' => 'Test', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x',
|
||||
'aktuellerPfad' => '/verwaltung'];
|
||||
|
||||
extract($daten, EXTR_SKIP);
|
||||
|
||||
ob_start();
|
||||
include dirname(__DIR__) . '/src/Views/' . $view;
|
||||
return (string)ob_get_clean();
|
||||
}
|
||||
|
||||
// ── Der Testserver ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Der eingebaute PHP-Server mit einem Router, der sich wie das Bargeld-Tool
|
||||
* verhält: Basic Auth, Statusaufruf, Buchungssuche, ein Betrag, der einen
|
||||
* Serverfehler auslöst.
|
||||
*/
|
||||
private function serverStarten(): bool
|
||||
{
|
||||
$verzeichnis = $this->tempPfad();
|
||||
if (!mkdir($verzeichnis, 0o777, true) && !is_dir($verzeichnis)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($verzeichnis . '/router.php', $this->routerQuelltext());
|
||||
|
||||
$port = random_int(18000, 18999);
|
||||
$prozess = @proc_open(
|
||||
sprintf('exec php -S 127.0.0.1:%d -t %s %s/router.php', $port, escapeshellarg($verzeichnis), escapeshellarg($verzeichnis)),
|
||||
[1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
|
||||
$rohre
|
||||
);
|
||||
|
||||
if (!is_resource($prozess)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->server = $prozess;
|
||||
$this->adresse = 'http://127.0.0.1:' . $port;
|
||||
|
||||
// Der Server braucht einen Augenblick, bis er den Port annimmt.
|
||||
for ($versuch = 0; $versuch < 50; $versuch++) {
|
||||
$verbindung = @fsockopen('127.0.0.1', $port, $fehler, $meldung, 0.2);
|
||||
if (is_resource($verbindung)) {
|
||||
fclose($verbindung);
|
||||
return true;
|
||||
}
|
||||
usleep(100_000);
|
||||
}
|
||||
|
||||
$this->serverBeenden();
|
||||
return false;
|
||||
}
|
||||
|
||||
private function serverBeenden(): void
|
||||
{
|
||||
if (is_resource($this->server)) {
|
||||
proc_terminate($this->server);
|
||||
proc_close($this->server);
|
||||
}
|
||||
$this->server = null;
|
||||
}
|
||||
|
||||
private function routerQuelltext(): string
|
||||
{
|
||||
return <<<'PHP'
|
||||
<?php
|
||||
|
||||
// Ein Bargeld-Tool im Kleinformat – nur so viel, wie der Abgleich sieht.
|
||||
$pfad = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$benutzer = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||
if ($benutzer === '' && isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$roh = base64_decode(substr((string)$_SERVER['HTTP_AUTHORIZATION'], 6), true);
|
||||
$benutzer = $roh === false ? '' : (string)strstr($roh, ':', true);
|
||||
}
|
||||
if ($benutzer !== 'kasse') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['fehler' => 'Unbekannte Kennung.']);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($pfad === '/api/v1/status') {
|
||||
echo json_encode(['name' => 'FSCash-Test', 'version' => '0.1']);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($pfad !== '/api/v1/buchungen') {
|
||||
http_response_code(404);
|
||||
echo json_encode(['fehler' => 'Unbekannter Pfad.']);
|
||||
return true;
|
||||
}
|
||||
|
||||
$betrag = (string)($_GET['betrag'] ?? '');
|
||||
$von = (string)($_GET['von'] ?? '');
|
||||
$bis = (string)($_GET['bis'] ?? '');
|
||||
|
||||
if ($betrag === '-500.00') {
|
||||
http_response_code(500);
|
||||
echo json_encode(['fehler' => 'Kasse steht still.']);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Der Betrag mit dem Prüfdatum: Er wird nur gefunden, wenn das Fenster
|
||||
// genau zehn Tage rückwärts ab dem Buchungsdatum reicht.
|
||||
if ($betrag === '-7.77') {
|
||||
$treffer = ($von === '2026-03-10' && $bis === '2026-03-20')
|
||||
? [['id' => 'f1', 'datum' => '2026-03-15', 'betrag' => -7.77]]
|
||||
: [];
|
||||
echo json_encode(['anzahl' => count($treffer), 'buchungen' => $treffer]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($betrag === '-12.50') {
|
||||
echo json_encode(['anzahl' => 1, 'buchungen' => [[
|
||||
'id' => 'a1',
|
||||
'datum' => '2026-03-08',
|
||||
'betrag' => -12.50,
|
||||
'zweck' => 'Wocheneinkauf',
|
||||
]]]);
|
||||
return true;
|
||||
}
|
||||
|
||||
echo json_encode(['anzahl' => 0, 'buchungen' => []]);
|
||||
return true;
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
180
tests/e2e/specs/bargeld.spec.js
Normal file
180
tests/e2e/specs/bargeld.spec.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// @ts-check
|
||||
const { test, expect } = require('@playwright/test')
|
||||
|
||||
/*
|
||||
* Der Abgleich mit dem Bargeld-Tool im Browser: die Kachel in der Verwaltung,
|
||||
* das Formular dahinter und die Wirkung im Kontoblatt.
|
||||
*
|
||||
* Als Gegenstelle dient eine Adresse, die es nicht gibt. Das genügt: Der Abruf
|
||||
* selbst steckt in der PHP-Suite, wo ein Testserver antwortet – hier geht es um
|
||||
* den Weg und darum, dass ein gescheiterter Abruf die Zuordnung nicht aufhält.
|
||||
*/
|
||||
|
||||
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 Bargeld Giro ${MARKE}`
|
||||
const KATEGORIE = `E2E Bargeld Haushalt ${MARKE}`
|
||||
const ADRESSE = 'https://kasse.beispiel.invalid'
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
|
||||
if (page.url().includes('/einrichtung')) {
|
||||
throw new Error(
|
||||
'Instanz ist noch nicht eingerichtet. Zuerst einen Benutzer anlegen: ' +
|
||||
'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(/\/$/)
|
||||
}
|
||||
|
||||
/** Adresse und Kategorienauswahl zurücksetzen – die Instanz gehört nicht dem Test. */
|
||||
async function zuruecksetzen(page) {
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
await page.fill('#bargeld_url', '')
|
||||
for (const feld of await page.locator('input[name="kategorien[]"]:checked').all()) {
|
||||
await feld.uncheck()
|
||||
}
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||||
}
|
||||
|
||||
test.describe('Bargeld-Tool', () => {
|
||||
let giroId = null
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage()
|
||||
await anmelden(page)
|
||||
|
||||
await page.goto('/konten/neu')
|
||||
await page.fill('#name', GIRO)
|
||||
await page.selectOption('#kontoart', 'bank')
|
||||
await page.fill('#anfangssaldo', '1.000,00')
|
||||
await page.click('.formular button[type="submit"]')
|
||||
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
|
||||
const treffer = page.url().match(/\/konten\/(\d+)\//)
|
||||
giroId = treffer ? treffer[1] : null
|
||||
|
||||
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.afterEach(async ({ page }) => {
|
||||
await zuruecksetzen(page)
|
||||
})
|
||||
|
||||
test('Die Verwaltung führt zur Konfiguration', async ({ page }) => {
|
||||
await page.goto('/verwaltung')
|
||||
|
||||
const kachel = page.locator('.kachel', { hasText: 'Bargeld-Tool' })
|
||||
await kachel.getByRole('link', { name: /einrichten/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/verwaltung\/bargeld$/)
|
||||
await expect(page.locator('h1')).toContainText('Bargeld-Tool')
|
||||
await expect(page.locator('#bargeld_url')).toBeVisible()
|
||||
await expect(page.locator('#bargeld_benutzer')).toBeVisible()
|
||||
await expect(page.locator('#bargeld_passwort')).toHaveAttribute('type', 'password')
|
||||
await expect(page.getByRole('checkbox', { name: KATEGORIE })).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('Eine Adresse ohne Schema wird abgewiesen', async ({ page }) => {
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
|
||||
await page.fill('#bargeld_url', 'kasse.beispiel.invalid')
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
|
||||
await expect(page.locator('.meldung')).toContainText('http://')
|
||||
await expect(page.locator('#bargeld_url')).toHaveValue('')
|
||||
})
|
||||
|
||||
test('Der Verbindungstest meldet eine unerreichbare Gegenstelle', async ({ page }) => {
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
await page.fill('#bargeld_url', ADRESSE)
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||||
|
||||
await page.getByRole('button', { name: 'Verbindung testen' }).click()
|
||||
await expect(page.locator('.meldung-fehler')).toContainText('nicht erreichbar')
|
||||
})
|
||||
|
||||
test('Erst mit Adresse zeigt das Kontoblatt die Abgleichspalte', async ({ page }) => {
|
||||
const empfaenger = `Ohne Bargeld ${MARKE}`
|
||||
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-04-03')
|
||||
await page.fill('#betrag', '12,30')
|
||||
await page.fill('#empfaenger', empfaenger)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
// Ohne Adresse gibt es die Spalte nicht – sie zeigte nur graue Kreise.
|
||||
await expect(page.locator('th.bargeldspalte')).toHaveCount(0)
|
||||
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
await page.fill('#bargeld_url', ADRESSE)
|
||||
await page.fill('#bargeld_tage', '10')
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||||
|
||||
await page.goto(`/konten/${giroId}`)
|
||||
await expect(page.locator('th.bargeldspalte')).toHaveCount(1)
|
||||
|
||||
// Die Spalte steht zwischen Klasse und Betrag.
|
||||
const spalten = await page.locator('table.kontoblatt thead th').allTextContents()
|
||||
expect(spalten.indexOf('Bar')).toBe(spalten.indexOf('Klasse') + 1)
|
||||
expect(spalten.indexOf('Betrag')).toBe(spalten.indexOf('Bar') + 1)
|
||||
|
||||
// Ohne vorgemerkte Kategorie steht dort der graue Kreis.
|
||||
const zeile = page.locator('tr', { hasText: empfaenger })
|
||||
await expect(zeile.locator('td.bargeldspalte .bargeld-neutral')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Eine vorgemerkte Kategorie wird beim Zuordnen geprüft', async ({ page }) => {
|
||||
// Der Abruf läuft ins Leere; genau das soll das Ausrufezeichen zeigen –
|
||||
// und die Zuordnung muss trotzdem stehen.
|
||||
test.setTimeout(60_000)
|
||||
|
||||
const empfaenger = `Bargeldabhebung ${MARKE}`
|
||||
|
||||
await page.goto('/verwaltung/bargeld')
|
||||
await page.fill('#bargeld_url', ADRESSE)
|
||||
await page.getByRole('checkbox', { name: KATEGORIE }).check()
|
||||
await page.getByRole('button', { name: 'Speichern' }).click()
|
||||
await expect(page.locator('.meldung')).toContainText('gespeichert')
|
||||
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.selectOption('#art', 'ausgabe')
|
||||
await page.fill('#datum', '2026-04-04')
|
||||
await page.fill('#betrag', '50,00')
|
||||
await page.fill('#empfaenger', empfaenger)
|
||||
await page.click('.buchungsmaske button[type="submit"]')
|
||||
await expect(page.locator('.meldung-erfolg')).toBeVisible()
|
||||
|
||||
const zeile = page.locator('tr', { hasText: empfaenger })
|
||||
await zeile.locator('a.zuordnungsfeld').click()
|
||||
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: KATEGORIE })
|
||||
|
||||
// Die Zuordnung steht, der gescheiterte Abruf hat sie nicht aufgehalten.
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung gespeichert')
|
||||
await expect(page.locator('.meldung-erfolg')).toContainText('fehlgeschlagen')
|
||||
|
||||
const danach = page.locator('tr', { hasText: empfaenger })
|
||||
await expect(danach).toContainText(KATEGORIE)
|
||||
await expect(danach.locator('td.bargeldspalte .bargeld-fehler')).toBeVisible()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue