Fix #123: Regelmäßige Buchungen automatisch abgleichen

Neuer Schalter „automatisch abgleichen“ unter „automatisch buchen“
(Vorgabe: eingeschaltet). Gesetzt wird die aus der Reihe entstandene
Buchung als abgeglichen markiert. Die Übersicht erhält die Spalte
„Abgleichen“ zwischen „Auto“ und „Aktiv“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-12 17:37:34 +02:00
parent eb4227aefb
commit 0e8000e3dc
8 changed files with 166 additions and 6 deletions

View file

@ -1,5 +1,13 @@
# Änderungen # Änderungen
## Regelmäßige Buchungen: automatisch abgleichen (12.10.2026)
- Anlass (#123): Buchungen aus regelmäßigen Buchungen mussten nach dem Buchen einzeln abgeglichen werden.
- Neuer Schalter „automatisch abgleichen“ unter „automatisch buchen“ im Dialog „Neue regelmäßige Buchung“, in der Vorgabe eingeschaltet. Gesetzt markiert er die entstandene Buchung als abgeglichen – beim Fälligkeitslauf ebenso wie beim Buchen von Hand.
- Die Übersicht der regelmäßigen Buchungen hat eine neue Spalte „Abgleichen“ zwischen „Auto“ und „Aktiv“, per Klick umschaltbar.
- Migration `029_regelmaessig_abgleichen.sql`; bestehende Reihen übernehmen die Vorgabe (eingeschaltet).
- Tests: `tests/60_RegelmaessigAbgleichenTest.php`, `tests/e2e/specs/module.spec.js`.
## Buchungen: bewusst ohne Kategorie (12.10.2026) ## Buchungen: bewusst ohne Kategorie (12.10.2026)
- Anlass (#134): Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie gebucht werden können, ohne immer wieder als offen angemahnt zu werden. Mit Kategorie belastete eine Abschreibung die Auswertung zusätzlich zum gesunkenen Kontowert. - Anlass (#134): Abschreibungen oder rein rechnerische Solarerträge sollen ohne Kategorie gebucht werden können, ohne immer wieder als offen angemahnt zu werden. Mit Kategorie belastete eine Abschreibung die Auswertung zusätzlich zum gesunkenen Kontowert.

View file

@ -35,6 +35,10 @@ Zwei Betriebsarten:
* **auf Bestätigung** – wird nur gemeldet und wartet auf Knopfdruck. Für alles, dessen * **auf Bestätigung** – wird nur gemeldet und wartet auf Knopfdruck. Für alles, dessen
Betrag schwankt. Betrag schwankt.
Unabhängig davon legt **automatisch abgleichen** (Vorgabe: eingeschaltet) fest, ob die
entstandene Buchung gleich als abgeglichen markiert wird. Die Übersicht zeigt den Schalter
in der Spalte „Abgleichen“.
Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine zurück, wird **jeder Der Lauf ist gutmütig gegenüber langen Pausen: Liegen mehrere Termine zurück, wird **jeder
einzeln** gebucht, nicht einer für alle – nur so stimmen die Monatssummen. Nach dem Buchen einzeln** gebucht, nicht einer für alle – nur so stimmen die Monatssummen. Nach dem Buchen
rückt die Fälligkeit so weit vor, bis sie in der Zukunft liegt. rückt die Fälligkeit so weit vor, bis sie in der Zukunft liegt.

View file

@ -0,0 +1,11 @@
-- Regelmäßige Buchungen: automatisch abgleichen (#123).
--
-- Neben „automatisch buchen“ bekommt jede Reihe den Schalter „automatisch
-- abgleichen“. Ist er gesetzt (Vorgabe), wird die aus der Reihe entstandene
-- Buchung gleich als abgeglichen markiert. Bestehende Reihen übernehmen die
-- Vorgabe.
--
-- Idempotent über IF NOT EXISTS.
ALTER TABLE regelmaessige_buchungen
ADD COLUMN IF NOT EXISTS abgleichen TINYINT(1) NOT NULL DEFAULT 1 AFTER automatisch;

View file

@ -202,6 +202,7 @@ class TermineController extends BaseController
'ende' => $this->post('ende'), 'ende' => $this->post('ende'),
'naechste_faelligkeit' => $this->post('naechste_faelligkeit'), 'naechste_faelligkeit' => $this->post('naechste_faelligkeit'),
'automatisch' => isset($_POST['automatisch']) ? 1 : 0, 'automatisch' => isset($_POST['automatisch']) ? 1 : 0,
'abgleichen' => isset($_POST['abgleichen']) ? 1 : 0,
'vorlauf_tage' => $this->post('vorlauf_tage', '7'), 'vorlauf_tage' => $this->post('vorlauf_tage', '7'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0, 'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
]; ];

View file

@ -14,6 +14,9 @@ use Finanzmanager\Mandant;
* ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle * ansteht, bucht es und schreibt das Datum fort. So braucht es keine Tabelle
* vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der * vorausberechneter Termine, die bei jeder Änderung neu zu erzeugen wäre – der
* Finanzkalender rechnet die kommenden Termine bei Bedarf aus. * Finanzkalender rechnet die kommenden Termine bei Bedarf aus.
*
* Mit „automatisch abgleichen“ (Vorgabe) ist die entstandene Buchung gleich
* als abgeglichen markiert.
*/ */
class RegelmaessigeBuchung class RegelmaessigeBuchung
{ {
@ -95,13 +98,13 @@ class RegelmaessigeBuchung
'INSERT INTO regelmaessige_buchungen 'INSERT INTO regelmaessige_buchungen
(benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, kategorie_id, (benutzer_id, name, konto_id, betrag, empfaenger, buchungstext, kategorie_id,
klasse_id, gegenkonto_id, splits, intervall, faktor, beginn, ende, klasse_id, gegenkonto_id, splits, intervall, faktor, beginn, ende,
naechste_faelligkeit, automatisch, vorlauf_tage, aktiv) naechste_faelligkeit, automatisch, abgleichen, vorlauf_tage, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], Mandant::id(), $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'],
$f['buchungstext'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['buchungstext'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
$f['splits'], $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $f['splits'], $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'],
$f['beginn'], $f['automatisch'], $f['vorlauf_tage'], $f['aktiv'], $f['beginn'], $f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'],
] ]
); );
@ -137,13 +140,13 @@ class RegelmaessigeBuchung
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?, 'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?, buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,
intervall = ?, faktor = ?, beginn = ?, ende = ?, naechste_faelligkeit = ?, intervall = ?, faktor = ?, beginn = ?, ende = ?, naechste_faelligkeit = ?,
automatisch = ?, vorlauf_tage = ?, aktiv = ? automatisch = ?, abgleichen = ?, vorlauf_tage = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?', WHERE id = ? AND benutzer_id = ?',
[ [
$f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'], $f['name'], $f['konto_id'], $f['betrag'], $f['empfaenger'], $f['buchungstext'],
$f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'], $f['splits'],
$f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $naechste, $f['intervall'], $f['faktor'], $f['beginn'], $f['ende'], $naechste,
$f['automatisch'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(), $f['automatisch'], $f['abgleichen'], $f['vorlauf_tage'], $f['aktiv'], $id, Mandant::id(),
] ]
); );
} }
@ -194,6 +197,7 @@ class RegelmaessigeBuchung
'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null, 'kategorie_id' => $splits === [] ? $reihe['kategorie_id'] : null,
'klasse_id' => $reihe['klasse_id'], 'klasse_id' => $reihe['klasse_id'],
'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null, 'gegenkonto_id' => $splits === [] ? $reihe['gegenkonto_id'] : null,
'abgeglichen' => (int)$reihe['abgleichen'] === 1 ? 1 : 0,
], ],
$splits $splits
); );
@ -383,6 +387,8 @@ class RegelmaessigeBuchung
'beginn' => $beginn, 'beginn' => $beginn,
'ende' => $ende, 'ende' => $ende,
'automatisch' => !empty($daten['automatisch']) ? 1 : 0, 'automatisch' => !empty($daten['automatisch']) ? 1 : 0,
// Vorgabe eingeschaltet: Fehlt der Schlüssel, wird abgeglichen.
'abgleichen' => array_key_exists('abgleichen', $daten) && empty($daten['abgleichen']) ? 0 : 1,
'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))), 'vorlauf_tage' => max(0, min(365, (int)($daten['vorlauf_tage'] ?? 7))),
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1, 'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
]; ];

View file

@ -106,7 +106,7 @@ include __DIR__ . '/../layout/kopf.php';
<thead> <thead>
<tr> <tr>
<th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th> <th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th> <th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Abgleichen</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -212,6 +212,13 @@ include __DIR__ . '/../layout/kopf.php';
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>> <?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
</div> </div>
</td> </td>
<td>
<div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['abgleichen'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="abgleichen" value="1" hidden
<?= (int)$reihe['abgleichen'] === 1 ? 'checked' : '' ?>>
</div>
</td>
<td> <td>
<div class="feld" data-schalter> <div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span> <span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span>
@ -297,6 +304,14 @@ include __DIR__ . '/../layout/kopf.php';
dort, wo der Betrag schwankt. dort, wo der Betrag schwankt.
</p> </p>
<label class="ankreuzfeld">
<input type="checkbox" name="abgleichen" value="1" checked>
automatisch abgleichen
</label>
<p class="feldhinweis">
Mit Haken ist die entstandene Buchung gleich als abgeglichen markiert.
</p>
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" checked> aktiv <input type="checkbox" name="aktiv" value="1" checked> aktiv
</label> </label>

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\RegelmaessigeBuchung;
use Finanzmanager\Services\Faelligkeitslauf;
/**
* Regelmäßige Buchungen: automatisch abgleichen (#123).
*
* Der Schalter ist in der Vorgabe eingeschaltet; die aus der Reihe entstandene
* Buchung ist dann gleich abgeglichen – beim Fälligkeitslauf wie beim Buchen
* von Hand. Ausgeschaltet bleibt sie offen.
*/
class RegelmaessigAbgleichenTest extends TestCase
{
protected string $testName = 'Test 60: Regelmäßige Buchungen automatisch abgleichen';
private int $giro = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->giro = Konto::anlegen([
'name' => 'Abgleichgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->vorgabeIstEingeschaltet();
$this->schalterLaesstSichSpeichern();
$this->buchenGleichtAb();
$this->ohneSchalterBleibtOffen();
$this->faelligkeitslaufGleichtAb();
}
/** @param array<string,mixed> $zusatz */
private function reihe(string $name, array $zusatz = []): int
{
return RegelmaessigeBuchung::anlegen($zusatz + [
'name' => $name, 'konto_id' => $this->giro, 'betrag' => -25,
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1,
]);
}
private function vorgabeIstEingeschaltet(): void
{
$id = $this->reihe('Ohne Angabe');
$this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ohne Angabe wird abgeglichen');
Database::execute(
'INSERT INTO regelmaessige_buchungen (benutzer_id, name, konto_id, beginn) VALUES (?, ?, ?, ?)',
[Mandant::id(), 'Spaltenvorgabe', $this->giro, '2026-06-01']
);
$this->assertEquals(
1,
(int)RegelmaessigeBuchung::find(Database::lastInsertId())['abgleichen'],
'Die Spalte steht in der Vorgabe auf eingeschaltet'
);
}
private function schalterLaesstSichSpeichern(): void
{
$daten = [
'name' => 'Umschalten', 'konto_id' => $this->giro, 'betrag' => -5,
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1, 'abgleichen' => 0,
];
$id = RegelmaessigeBuchung::anlegen($daten);
$this->assertEquals(0, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Ausgeschaltet wird gespeichert');
RegelmaessigeBuchung::speichern($id, ['abgleichen' => 1] + $daten);
$this->assertEquals(1, (int)RegelmaessigeBuchung::find($id)['abgleichen'], 'Einschalten wird gespeichert');
}
private function buchenGleichtAb(): void
{
$buchung = RegelmaessigeBuchung::buchen($this->reihe('Miete', ['abgleichen' => 1]));
$this->assertEquals(1, (int)Buchung::find($buchung)['abgeglichen'], 'Die Buchung ist abgeglichen');
}
private function ohneSchalterBleibtOffen(): void
{
$buchung = RegelmaessigeBuchung::buchen($this->reihe('Strom', ['abgleichen' => 0]));
$this->assertEquals(0, (int)Buchung::find($buchung)['abgeglichen'], 'Ohne Schalter bleibt sie offen');
}
private function faelligkeitslaufGleichtAb(): void
{
$mit = $this->reihe('Lauf mit', ['automatisch' => 1, 'abgleichen' => 1, 'beginn' => date('Y-m-d')]);
$ohne = $this->reihe('Lauf ohne', ['automatisch' => 1, 'abgleichen' => 0, 'beginn' => date('Y-m-d')]);
Faelligkeitslauf::ausfuehren();
$status = static fn(int $reihe): array => array_map('intval', array_column(Database::fetchAll(
'SELECT abgeglichen FROM buchungen WHERE regelmaessige_id = ? AND benutzer_id = ?',
[$reihe, Mandant::id()]
), 'abgeglichen'));
$this->assertEquals([1], $status($mit), 'Der Fälligkeitslauf bucht abgeglichen');
$this->assertEquals([0], $status($ohne), 'Ohne Schalter bucht der Lauf offen');
}
}

View file

@ -320,9 +320,19 @@ test.describe('Module der Phasen 4 bis 7', () => {
await page.fill('#name', 'E2E-Reihe') await page.fill('#name', 'E2E-Reihe')
await page.fill('#neu_betrag', '-12,50') await page.fill('#neu_betrag', '-12,50')
await page.fill('#neu_buchungstext', 'E2E-Text') await page.fill('#neu_buchungstext', 'E2E-Text')
await expect(page.locator('#neudialog input[name="abgleichen"]')).toBeChecked()
await page.click('form[action="/termine/neu"] button.primaer') await page.click('form[action="/termine/neu"] button.primaer')
const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }) const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
await expect(page.locator('table.reihen thead th').nth(7)).toHaveText('Abgleichen')
const abgleichen = zeile.locator('.feld:has(input[name="abgleichen"])')
await expect(abgleichen.locator('.anzeige')).toHaveText('Ja')
await abgleichen.locator('.anzeige').click()
await expect(abgleichen.locator('.anzeige')).toHaveText('Nein')
await zeile.locator('[data-speichern]').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
.locator('.feld:has(input[name="abgleichen"]) .anzeige')).toHaveText('Nein')
await expect(zeile.locator('input[name="betrag"]')).toBeHidden() await expect(zeile.locator('input[name="betrag"]')).toBeHidden()
await expect(zeile.locator('[data-speichern]')).toBeHidden() await expect(zeile.locator('[data-speichern]')).toBeHidden()