Finanzmanager/tests/e2e/specs/buchungen.spec.js
fds2610-bot fe40a29f35 Buchungen: Gegenkonto sichtbar, Zuordnung direkt im Kontoblatt
Eine Umbuchung war auf dem Gegenkonto nicht mehr zuzuordnen: Die Zeile trug die
Marke „Umbuchung“, die Kategoriespalte daneben blieb leer. Die Daten waren
richtig, die Anzeige verschwieg sie. In der Kategoriespalte steht jetzt auch in
der Gegensicht das Konto in eckigen Klammern, von dem die Umbuchung kommt – die
Abfrage lieferte es längst mit.

Kategorie und Gegenkonto stehen in einem Feld (`Views/buchungen/zuordnungsliste.php`):
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern, wie im
FinanzManager, dessen Kategoriefeld `[Konto]` annimmt. Eines davon macht die
Buchung zur Umbuchung; die Auswahl „Ausgabe / Einnahme“ bestimmt seither nur
noch die Richtung. Dieselbe Liste steht in den Splitzeilen – eine Splitzeile mit
Gegenkonto ließ sich bisher nur im Import anlegen und ging beim Bearbeiten der
Buchung stillschweigend verloren.

Schnellzuordnung: Ein Klick auf die Kategoriespalte öffnet die Liste in der
Zelle, die Auswahl speichert sofort (POST /buchungen/{id}/zuordnung) und kommt
in dieselbe Ansicht zurück. Die Liste gibt es einmal und wandert in die
angeklickte Zelle – eine Liste je Zeile wären bei hundert Zeilen ebenso viele
vollständige Kategoriebäume im HTML. Feld und Liste sind gleich breit und gleich
hoch, sonst rückte beim Anklicken die halbe Zeile. Ohne JavaScript führt
derselbe Klick in die Buchungsmaske: Die Zelle ist ein Link. Die Gegenseite
einer fremden Umbuchung führt zu ihrer Buchung auf dem anderen Konto.

Der Eintrag „Splitbuchung“ öffnet ein modales Fenster mit Zeilen für Kategorie
oder Gegenkonto, Klasse, Notiz und Betrag; der Restbetrag wird mitgerechnet. Ein
Klick auf eine bereits gesplittete Buchung öffnet direkt den Dialog. „Splitbuchung
löschen“ verwirft alle Zeilen samt ihren Zuordnungen – auch die Gegenseite einer
Splitzeile mit Gegenkonto –, die Buchung steht danach unzugeordnet im Kontoblatt.
Beide Wege gehen über `Buchung::zuordnen()` und `Buchung::splitsErsetzen()`,
achten den Monatsabschluss und lassen den Rest der Buchung unberührt.

`public/static/betrag.js` bündelt das Lesen deutscher Beträge und die
Restbetragsrechnung für Maske und Kontoblatt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:28:33 +02:00

375 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
const GIRO = `E2E Giro ${MARKE}`
const SPAR = `E2E Spar ${MARKE}`
const KATEGORIE = `E2E Einkauf ${MARKE}`
async function anmelden(page) {
await page.goto('/login')
if (page.url().includes('/einrichtung')) {
throw new Error(
'Instanz ist noch nicht eingerichtet: ' +
'docker compose exec -u www-data php php scripts/e2e-benutzer.php'
)
}
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
/** Legt ein Konto an und liefert dessen Kennung aus der URL. */
async function kontoAnlegen(page, name, anfangssaldo) {
await page.goto('/konten/neu')
await page.fill('#name', name)
await page.fill('#anfangssaldo', anfangssaldo)
await page.click('.formular button[type="submit"]')
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
const treffer = page.url().match(/\/konten\/(\d+)\//)
return treffer ? treffer[1] : null
}
test.describe('Buchungen', () => {
let giroId = null
let sparId = null
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
await anmelden(page)
giroId = await kontoAnlegen(page, GIRO, '1.000,00')
sparId = await kontoAnlegen(page, SPAR, '5.000,00')
await page.goto('/verwaltung/kategorien/neu')
await page.fill('#name', KATEGORIE)
await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]')
await page.close()
})
test.beforeEach(async ({ page }) => {
await anmelden(page)
})
test('Kontoblatt zeigt den Anfangssaldo', async ({ page }) => {
await page.goto(`/konten/${giroId}`)
await expect(page.locator('h1')).toContainText(GIRO)
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
})
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-03-18')
await page.fill('#betrag', '49,90')
await page.fill('#empfaenger', 'Supermarkt')
await page.selectOption('#zuordnung', { label: KATEGORIE })
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: 'Supermarkt' })
await expect(zeile).toContainText('-49,90')
await expect(zeile).toContainText('950,10')
await expect(page.locator('.saldoanzeige').first()).toContainText('950,10')
})
test('Umbuchung erscheint auf beiden Kontoblättern', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
// Die Umbuchung entsteht im Kategoriefeld: Unter den Kategorien stehen
// die eigenen Konten in eckigen Klammern.
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-01')
await page.fill('#betrag', '500')
await page.fill('#empfaenger', `Übertrag ${MARKE}`)
await page.selectOption('#zuordnung', { label: `[${SPAR}]` })
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Ausgangskonto: Abgang, mit dem Gegenkonto in eckigen Klammern.
const hin = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
await expect(hin).toContainText('-500,00')
await expect(hin).toContainText(`[${SPAR}]`)
// Gegenkonto: derselbe Vorgang mit umgekehrtem Vorzeichen – und mit dem
// Konto, von dem er kommt. Ohne diese Angabe stünde dort eine Umbuchung
// ohne erkennbares Gegenüber.
await page.goto(`/konten/${sparId}`)
const her = page.locator('tr', { hasText: `Übertrag ${MARKE}` })
await expect(her).toContainText('500,00')
await expect(her).toContainText('Umbuchung')
await expect(her).toContainText(`[${GIRO}]`)
await expect(page.locator('.saldoanzeige').first()).toContainText('5.500,00')
// Auch die Gegenseite ist anklickbar: Sie führt zu ihrer Buchung, die
// auf dem anderen Konto liegt und nur dort geändert wird.
await her.locator('a.zuordnungsfeld.gegenseite').click()
await expect(page).toHaveURL(/\/buchungen\/\d+/)
await expect(page.locator('h1')).toContainText('Buchung bearbeiten')
await expect(page.locator('#zuordnung')).toHaveValue(/^konto:\d+$/)
})
test('Kategorie im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-03')
await page.fill('#betrag', '12,30')
await page.fill('#empfaenger', `Ohne Kategorie ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
// Die Auswahlliste gibt es einmal und wandert in die angeklickte Zelle.
const liste = zeile.locator('#zuordnungsauswahl select')
await expect(liste).toBeVisible()
await liste.selectOption({ label: KATEGORIE })
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Ohne Kategorie ${MARKE}` }))
.toContainText(KATEGORIE)
})
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-04')
await page.fill('#betrag', '75')
await page.fill('#empfaenger', `Nachträglich ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Nachträglich ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption({ label: `[${SPAR}]` })
await expect(page.locator('.meldung-erfolg')).toContainText('Zuordnung')
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${SPAR}]`)
// Und die Gegenseite steht sofort auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Nachträglich ${MARKE}` }))
.toContainText(`[${GIRO}]`)
})
test('Splitbuchung aus dem Kontoblatt heraus aufteilen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-05')
await page.fill('#betrag', '90')
await page.fill('#empfaenger', `Aufzuteilen ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
await zeile.locator('#zuordnungsauswahl select').selectOption('split')
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('-90,00')
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][betrag]"]').fill('-50')
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${SPAR}]` })
await dialog.locator('input[name="split[1][betrag]"]').fill('-40')
// Der Dialog rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitdialogsumme')).not.toContainText('Rest')
// Der Dialog hat zwei Submit-Knöpfe: Speichern und Löschen.
await dialog.locator('button[type="submit"].primaer').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung')
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` }).first())
.toContainText('Splitbuchung')
// Die Zeile mit Gegenkonto erscheint anteilig auf dem anderen Konto.
await page.goto(`/konten/${sparId}`)
await expect(page.locator('tr', { hasText: `Aufzuteilen ${MARKE}` })).toContainText('40,00')
})
test('Splitbuchung öffnet den Dialog und lässt sich dort löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')
await page.fill('#betrag', '70')
await page.fill('#empfaenger', `Wieder einfach ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-30')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][betrag]"]', '-40')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Der Klick führt nicht in die Auswahlliste, sondern in den Dialog –
// mit den vorhandenen Zeilen darin.
const zeile = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await zeile.locator('a.zuordnungsfeld').click()
const dialog = page.locator('#splitdialog')
await expect(dialog).toBeVisible()
await expect(page.locator('#zuordnungsauswahl select')).toBeHidden()
await expect(dialog.locator('input[name="split[0][betrag]"]')).toHaveValue('-30,00')
await dialog.locator('#splitloeschen').click()
await expect(page.locator('.meldung-erfolg')).toContainText('Splitbuchung gelöscht')
const danach = page.locator('tr', { hasText: `Wieder einfach ${MARKE}` })
await expect(danach).not.toContainText('Splitbuchung')
await expect(danach).not.toContainText(KATEGORIE)
// Und die Zuordnung geht jetzt wieder über die Auswahlliste.
await danach.locator('a.zuordnungsfeld').click()
await expect(danach.locator('#zuordnungsauswahl select')).toBeVisible()
})
test('Splitbuchung mit zwei Zeilen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-02')
await page.fill('#betrag', '260,49')
await page.fill('#empfaenger', `Versicherung ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][notiz]"]', 'Kasko')
await page.fill('input[name="split[0][betrag]"]', '-162,51')
await page.selectOption('select[name="split[1][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[1][notiz]"]', 'Haftpflicht')
await page.fill('input[name="split[1][betrag]"]', '-97,98')
// Die Maske rechnet den Restbetrag mit; er muss aufgehen.
await expect(page.locator('#splitsumme')).not.toContainText('Rest')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
await expect(zeile).toContainText('Splitbuchung')
await expect(zeile).toContainText('-260,49')
})
test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-03')
await page.fill('#betrag', '100')
await page.fill('#empfaenger', `Krumm ${MARKE}`)
await page.selectOption('select[name="split[0][zuordnung]"]', { label: KATEGORIE })
await page.fill('input[name="split[0][betrag]"]', '-60')
await expect(page.locator('#splitsumme')).toContainText('Rest')
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-fehler')).toBeVisible()
await expect(page.locator('.meldung-fehler')).toContainText('Splitzeilen')
})
test('Kontoblatt lässt sich durchsuchen', async ({ page }) => {
await page.goto(`/konten/${giroId}`)
await page.fill('input[name="suche"]', 'Supermarkt')
await page.click('.filterleiste button[type="submit"]')
// Auf der Seite steht noch eine zweite Tabelle: die leeren Zeilen des
// Splitdialogs. Gemeint ist hier das Kontoblatt.
await expect(page.locator('table.kontoblatt tbody tr')).toHaveCount(1)
await expect(page.locator('table.kontoblatt tbody')).toContainText('Supermarkt')
// Mit Filter ist die Saldospalte leer – ein laufender Saldo wäre gelogen.
// Spalte 0 ist das Ankreuzfeld der Mehrfachauswahl.
await expect(page.locator('table.kontoblatt tbody tr td').nth(8)).toContainText('–')
})
test('Eine Buchung ohne Kategorie setzt ihr Konto in der Übersicht fett', async ({ page }) => {
// Heutiges Datum: Fett stehen nur Konten mit offenen Buchungen aus dem
// laufenden Jahr – ein festes Datum wäre nächstes Silvester falsch.
const heute = new Date().toISOString().slice(0, 10)
await page.goto(`/konten/${sparId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', heute)
await page.fill('#betrag', '17,50')
await page.fill('#empfaenger', 'Noch zuzuordnen')
// Kategorie bleibt „— keine —“: genau der Zustand, den ein automatisch
// gebuchter Bankumsatz ohne passende Regel hinterlässt.
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
await page.goto('/konten')
await expect(page.locator('tr.unkategorisiert', { hasText: SPAR })).toHaveCount(1)
await expect(page.locator('tr.unkategorisiert a', { hasText: SPAR }))
.toHaveAttribute('title', /ohne Kategorie/)
})
test('Löschen fragt nach – trotz strenger Content-Security-Policy', async ({ page }) => {
await page.goto(`/konten/${giroId}`)
await page.locator('tr', { hasText: 'Supermarkt' }).getByRole('link').first().click()
await expect(page).toHaveURL(/\/buchungen\/\d+/)
// Der Dialog MUSS kommen: die Bestätigung hängt an data-bestaetigen und
// wird von /static/app.js gesetzt, weil Inline-Handler an der CSP
// scheitern. Der Handler muss vor dem Klick stehen und den Dialog
// sofort schließen – ein offener Dialog blockiert die Klickaktion.
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await page.locator('form[action$="/loeschen"] button').click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page.locator('table.kontoblatt tbody')).not.toContainText('Supermarkt')
})
test('Mehrere Buchungen über die Auswahl im Kontoblatt löschen', async ({ page }) => {
for (const empfaenger of ['Sammellöschung A', 'Sammellöschung B']) {
await page.goto(`/konten/${sparId}/buchung`)
await page.selectOption('#art', 'ausgabe')
await page.fill('#datum', '2026-04-02')
await page.fill('#betrag', '9,90')
await page.fill('#empfaenger', empfaenger)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
}
await page.goto(`/konten/${sparId}`)
// Ohne Auswahl bleibt der Knopf gesperrt.
const knopf = page.locator('#buchungsauswahl button')
await expect(knopf).toBeDisabled()
// Das Ankreuzfeld in der Titelzeile wählt alle angezeigten Buchungen.
const anzahl = await page.locator('table.kontoblatt tbody input[name="buchungen[]"]').count()
await page.check('.alle-waehlen')
await expect(knopf).toContainText(`(${anzahl})`)
// Wie beim einzelnen Löschen hängt die Rückfrage an data-bestaetigen –
// der Handler muss vor dem Klick stehen.
const gefragt = []
page.on('dialog', async (dialog) => {
gefragt.push(dialog.message())
await dialog.accept()
})
await knopf.click()
expect(gefragt.join(' ')).toContain('löschen')
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
await expect(page.getByText('Sammellöschung')).toHaveCount(0)
})
})