// @ts-check // Hotspot: Rechnung anlegen → finalisieren → PDF- und XML-Export. // // Der XML-Download (EN 16931 / XRechnung 3.0) kam zuletzt zweimal in Folge als // Fix ins Repo — einmal der Export selbst, danach die USt-IdNr. des Kunden // (BT-48), die im XML fehlte. Beide Male hing der Fehler am Zusammenspiel aus // Controller, ZugferdService und den tatsächlich gespeicherten Rechnungsdaten. // // Hinweis: Der Finalisierungs-Test vergibt eine Rechnungsnummer, gibt sie beim // Löschen der Testrechnung aber wieder frei (Invoice::delete). Der Nummernkreis // steht nach dem Testlauf also wieder da, wo er vorher stand — der letzte Test // dieser Datei weist genau das nach. Siehe README.md. const { test, expect } = require('@playwright/test') const { uniqueTag, gotoOk, acceptDialogs, cleanupInvoice, parseMoney } = require('../helpers/app') /** Legt einen Rechnungsentwurf mit einer Position an und liefert die ID. */ async function entwurfAnlegen(page, { tag, einzelpreis = '100.00', ustSatz = '19' }) { await gotoOk(page, '/invoices/new') // Ersten echten Kunden wählen (Index 0 ist der Platzhalter). const kundenOptionen = page.locator('#customer_id option') const anzahl = await kundenOptionen.count() expect(anzahl, 'Kein Kunde vorhanden — die Instanz braucht mindestens einen Kunden').toBeGreaterThan(1) const kundenValue = await kundenOptionen.nth(1).getAttribute('value') await page.selectOption('#customer_id', kundenValue) await page.fill('#betreff', tag) // Position über den "+"-Button und das Popup hinzufügen. await page.click('button[data-action="show-add-line-dialog"]') await page.click('#add-line-popup >> text=Neue Position') const zeile = page.locator('#invoice-lines-body tr').last() await zeile.locator('textarea[name$="[bezeichnung]"]').fill(`Leistung ${tag}`) await zeile.locator('input[name$="[menge]"]').fill('1') await zeile.locator('select[name$="[ust_satz]"]').selectOption(ustSatz) await zeile.locator('input[name$="[einzelpreis]"]').fill(einzelpreis) await Promise.all([ page.waitForURL(/\/invoices\/\d+/), page.click('button[type="submit"]:has-text("Entwurf speichern")'), ]) const id = Number(page.url().match(/\/invoices\/(\d+)/)?.[1] || 0) expect(id, `Rechnungs-ID nicht ermittelbar: ${page.url()}`).toBeGreaterThan(0) return id } /** Finalisiert den Entwurf über den Button in der Detailansicht. */ async function finalisieren(page, invoiceId) { acceptDialogs(page) await page.click('button[type="submit"]:has-text("Finalisieren")') await page.waitForLoadState('networkidle') await expect(page.locator('body'), 'Finalisieren hat den Status nicht geändert') .not.toContainText('Finalisieren & Nummerieren') } /** * Liest die vergebene Rechnungsnummer aus dem Dateinamen des XML-Downloads. * Sie steht nirgends sonst maschinell eindeutig auf der Seite. */ async function rechnungsnummerAusXml(page, invoiceId) { const response = await page.request.get(`/invoices/${invoiceId}/xml`) expect(response.status()).toBe(200) const dateiname = response.headers()['content-disposition'] || '' const nummer = dateiname.match(/Rechnung-(.+)\.xml/)?.[1] expect(nummer, `Rechnungsnummer nicht aus Dateiname ermittelbar: ${dateiname}`).toBeTruthy() expect(nummer).not.toBe('Entwurf') return nummer } test.describe('Rechnung: Anlegen, Finalisieren, Export', () => { let invoiceId = 0 test.afterEach(async ({ page }) => { await cleanupInvoice(page, invoiceId) invoiceId = 0 }) test('Entwurf wird mit Position und Summen gespeichert', async ({ page }) => { const tag = uniqueTag() invoiceId = await entwurfAnlegen(page, { tag }) await expect(page.locator('body')).toContainText(tag) await expect(page.locator('body')).toContainText('Entwurf') // 100,00 netto + 19 % = 119,00 brutto müssen irgendwo auf der Seite stehen. await expect(page.locator('body')).toContainText('119,00') }) test('XML-Export eines Entwurfs liefert wohlgeformtes XRechnung-XML', async ({ page }) => { const tag = uniqueTag() invoiceId = await entwurfAnlegen(page, { tag }) const response = await page.request.get(`/invoices/${invoiceId}/xml`) expect(response.status()).toBe(200) expect(response.headers()['content-type']).toContain('xml') const xml = await response.text() expect(xml.startsWith(' { const tag = uniqueTag() invoiceId = await entwurfAnlegen(page, { tag }) await finalisieren(page, invoiceId) const seitentext = await page.locator('body').textContent() expect(seitentext).toContain(tag) // ── PDF ── const pdf = await page.request.get(`/invoices/${invoiceId}/pdf`) expect(pdf.status()).toBe(200) const pdfBody = await pdf.body() expect(pdfBody.subarray(0, 5).toString('latin1'), 'Kein gültiger PDF-Header').toBe('%PDF-') expect(pdfBody.length).toBeGreaterThan(1000) // ── XML ── const xmlResponse = await page.request.get(`/invoices/${invoiceId}/xml`) expect(xmlResponse.status()).toBe(200) const xml = await xmlResponse.text() expect(xml).toContain('CrossIndustryInvoice') // Die vergebene Rechnungsnummer muss im XML stehen (BT-1). const nummer = await rechnungsnummerAusXml(page, invoiceId) expect(xml).toContain(nummer) }) test('Löschen einer finalisierten Rechnung gibt die Nummer wieder frei', async ({ page }) => { // Der Testlauf darf keine Nummer aus dem laufenden Kreis verbrauchen: // Invoice::delete() zählt den Nummernkreis zurück, wenn die gelöschte // Rechnung die zuletzt nummerierte war. Nachweis über die Nummer der // nächsten Rechnung — sie muss dieselbe sein. const ersteId = await entwurfAnlegen(page, { tag: uniqueTag() }) await finalisieren(page, ersteId) const ersteNummer = await rechnungsnummerAusXml(page, ersteId) await cleanupInvoice(page, ersteId) await expect( page.locator(`form[action="/invoices/${ersteId}/delete"]`), 'Die finalisierte Testrechnung wurde nicht gelöscht' ).toHaveCount(0) invoiceId = await entwurfAnlegen(page, { tag: uniqueTag() }) await finalisieren(page, invoiceId) expect( await rechnungsnummerAusXml(page, invoiceId), 'Der Nummernkreis wurde beim Löschen nicht zurückgesetzt' ).toBe(ersteNummer) }) test('PDF eines Entwurfs wird nicht ausgeliefert', async ({ page }) => { const tag = uniqueTag() invoiceId = await entwurfAnlegen(page, { tag }) // Im Entwurf gibt es bewusst keinen PDF-Link (GoBD: erst ab Finalisierung). await expect(page.locator(`a[href="/invoices/${invoiceId}/pdf"]`)).toHaveCount(0) }) })