diff --git a/src/lib/rechnungAnfrage.js b/src/lib/rechnungAnfrage.js new file mode 100644 index 0000000..e03e5c3 --- /dev/null +++ b/src/lib/rechnungAnfrage.js @@ -0,0 +1,221 @@ +// Anfragen des Rechnungsdialogs (rein, ohne React und Appwrite; unter Node testbar). +// Aufgaben: Körper für die Vorschau und für „Rechnung anlegen“ bauen und prüfen, Status der Vorschau, Zeilentexte der +// Aufwandsrechnung und das Absenden mit Rückfrage, Adress-Sperre und Doppelklick-Sperre. +// Die Eingabe `e` des Dialogs ist ein flaches Objekt (siehe rechnungAnfrage). Beträge sind Cent. +import { + INVOICE_LIMITS, + ADDRESS_INCOMPLETE_TEXT, + customerName, + finalizedFilter, + invoiceFailureMessage, + invoiceSendConfirmText, + invoiceSendGate, + isFinalizedNotSent, + parseQuantity, + parseUnitPrice, + validateInvoiceForm, +} from './financeView' +import { ausgewaehlteIds } from './rechnungDialogView' + +export const LEISTUNGSZEITRAUM_MAX = 140 +export const BEZEICHNUNG_MAX = 60 +export const VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss'] + +const ZEILEN_ART = /^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$/ +const TRENNER = ' – ' // Leerzeichen, Gedankenstrich (U+2013), Leerzeichen + +const text = (v) => String(v ?? '').trim() + +/** Zusatzzeilen prüfen; eine Zeile ohne Beschreibung und ohne Preis zählt nicht. */ +function zusatzPruefen(rows, label = 'Zusatzposition') { + const items = [] + const liste = Array.isArray(rows) ? rows : [] + for (let i = 0; i < liste.length; i += 1) { + const row = liste[i] || {} + const description = text(row.description) + if (!description && !text(row.price)) continue + const name = `${label} ${i + 1}` + if (!description) return { ok: false, error: `${name}: Bitte eine Beschreibung eingeben.` } + if (description.length > INVOICE_LIMITS.maxDescription) { + return { ok: false, error: `${name}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.` } + } + const quantity = parseQuantity(row.quantity) + if (quantity === null) return { ok: false, error: `${name}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.` } + const unit = parseUnitPrice(row.price) + if (unit === null) return { ok: false, error: `${name}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` } + items.push({ description, quantity, unitAmountCents: unit }) + } + return { ok: true, items } +} + +/** '33,5' -> 33.5; nur 0 < Wert <= 100 mit höchstens zwei Nachkommastellen, sonst null. */ +function parseProzent(value) { + const s = text(value).replace(',', '.') + if (!/^[0-9]{1,3}(\.[0-9]{1,2})?$/.test(s)) return null + const n = Number(s) + return n > 0 && n <= 100 ? n : null +} + +/** Körper für die Vorschau; `error: ''` heißt „keine Vorschau für diese Art“. */ +export function vorschauAnfrage(e) { + const art = e?.art + if (!VORSCHAU_ARTEN.includes(art)) return { ok: false, error: '' } + if (!e.kunde) return { ok: false, error: 'Bitte einen Kunden wählen.' } + const body = { customerId: e.kunde.$id, art, projektId: e.projektId } + + if (art === 'aufwand') { + const ids = ausgewaehlteIds(e.blaetter, e.abgewaehlt) + if (ids.length === 0) return { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' } + body.arbeitsblattIds = ids + } else { + if (!e.auftragId) return { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' } + body.auftragId = e.auftragId + } + + if (art === 'anzahlung') { + const a = e.anzahlung || {} + const betrag = {} + if (a.modus === 'betrag') { + const cents = parseUnitPrice(a.wert) + if (cents === null) return { ok: false, error: 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.' } + betrag.betragCents = cents + } else { + const prozent = parseProzent(a.wert) + if (prozent === null) return { ok: false, error: 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.' } + betrag.prozent = prozent + } + const bezeichnung = text(a.bezeichnung) + if (!bezeichnung || bezeichnung.length > BEZEICHNUNG_MAX) { + return { ok: false, error: `Bitte eine Bezeichnung eingeben (höchstens ${BEZEICHNUNG_MAX} Zeichen).` } + } + body.anzahlung = { ...betrag, bezeichnung } + } else { + const z = zusatzPruefen(e.zusatz) + if (!z.ok) return z + if (z.items.length) body.items = z.items + } + + if (art === 'aufwand') { + const zeilentexte = {} + for (const [schluessel, t] of Object.entries(e.zeilentexte || {})) { + if (text(t)) zeilentexte[schluessel] = text(t) + } + if (Object.keys(zeilentexte).length) body.zeilentexte = zeilentexte + } + + const zeitraum = leistungszeitraumFeld(e) + if (zeitraum.error) return { ok: false, error: zeitraum.error } + if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert + return { ok: true, body } +} + +function leistungszeitraumFeld(e) { + const wert = e?.zeitraumGeaendert ? text(e.zeitraum) : '' + if (wert.length > LEISTUNGSZEITRAUM_MAX) { + return { error: `Der Leistungszeitraum darf höchstens ${LEISTUNGSZEITRAUM_MAX} Zeichen lang sein.`, wert: '' } + } + return { error: '', wert } +} + +/** Körper für „Rechnung anlegen“ (ohne `senden`); `totalCents` nur bei freien Positionen und Festbetrag. */ +export function rechnungAnfrage(e) { + const art = e?.art + if (art === 'frei' || art === 'festbetrag') { + // die Sätze und Grenzen für Kunde, Positionen, Zahlungsziel und Notiz gelten unverändert aus dem bisherigen Dialog + const form = validateInvoiceForm({ customer: e.kunde, items: e.items, days: e.days, memo: e.memo }) + if (!form.ok) return { ok: false, error: form.error } + if (art === 'festbetrag' && !e.projektId) return { ok: false, error: 'Bitte ein Projekt wählen.' } + const zeitraum = leistungszeitraumFeld(e) + if (zeitraum.error) return { ok: false, error: zeitraum.error } + const body = { ...form.payload, art } + if (art === 'festbetrag') body.projektId = e.projektId + if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert + return { ok: true, body, totalCents: form.totalCents } + } + const v = vorschauAnfrage(e) + if (!v.ok) return v + const form = validateInvoiceForm({ + customer: e.kunde, + items: [{ description: 'x', quantity: '1', price: '1' }], + days: e.days, + memo: e.memo, + }) + if (!form.ok) return { ok: false, error: form.error } + const body = { ...v.body, daysUntilDue: form.payload.daysUntilDue } + if (form.payload.memo) body.memo = form.payload.memo + return { ok: true, body, totalCents: null } +} + +/** Schlüssel der Eingabe, die Zahlen der Vorschau ändert (Texte und Leistungszeitraum zählen nicht). */ +export function vorschauSchluessel(body) { + if (!body) return '' + const { zeilentexte, leistungszeitraum, ...rest } = body + void zeilentexte + void leistungszeitraum + if (Array.isArray(rest.arbeitsblattIds)) rest.arbeitsblattIds = [...rest.arbeitsblattIds].sort() + return JSON.stringify(rest) +} + +/** Zustand der Vorschau: nicht-noetig | ungueltig | laedt | fehler | bereit. */ +export function vorschauStatus({ art, anfrage, vorschau, schluessel }) { + if (!VORSCHAU_ARTEN.includes(art)) return { state: 'nicht-noetig', message: '' } + if (!anfrage?.ok) return { state: 'ungueltig', message: anfrage?.error || '' } + if (!vorschau || vorschau.key !== schluessel || vorschau.status === 'laedt') return { state: 'laedt', message: '' } + if (vorschau.status === 'fehler') return { state: 'fehler', message: vorschau.message || 'Die Vorschau konnte nicht geladen werden.' } + if (!(Number(vorschau.daten?.summeCents) > 0)) return { state: 'fehler', message: 'Nichts mehr offen.' } + return { state: 'bereit', message: '' } +} + +/** Entwurf und Senden sind nur erlaubt, wenn keine Vorschau nötig ist oder sie zur aktuellen Eingabe gehört. */ +export function darfSpeichern(status) { + return status?.state === 'bereit' || status?.state === 'nicht-noetig' +} + +/** Zeile der Aufwandsrechnung am letzten ' – ' teilen: vorn bearbeitbar, hinten fest. */ +export function zeileTeilen(beschreibung) { + const s = String(beschreibung ?? '') + const i = s.lastIndexOf(TRENNER) + if (i < 0) return { vorn: s, hinten: '' } + return { vorn: s.slice(0, i), hinten: s.slice(i) } +} + +/** Nur Zeilen mit Tag oder Kalenderwoche als Schlüssel und festem Hinten-Teil sind bearbeitbar. */ +export function zeileBearbeitbar(position) { + return ZEILEN_ART.test(String(position?.schluessel ?? '')) && zeileTeilen(position?.beschreibung).hinten !== '' +} + +/** Höchstlänge des bearbeitbaren Teils, damit die ganze Zeile bei 200 Zeichen bleibt. */ +export function maxVorn(hinten) { + return Math.max(1, INVOICE_LIMITS.maxDescription - String(hinten ?? '').length) +} + +/** + * Absenden mit Rückfrage und Sperren. `submit({ anfrage, senden, kunde, totalCents })` liefert + * ok | abgebrochen | laeuft | fehler | festgeschrieben. Ein zweiter Aufruf, solange der erste läuft, wird verworfen. + */ +export function createSubmitter({ create, confirm }) { + let running = false + return async function submit({ anfrage, senden, kunde, totalCents }) { + if (running) return { status: 'laeuft' } + if (!anfrage?.ok) return { status: 'fehler', message: anfrage?.error || '' } + if (senden) { + if (!invoiceSendGate(kunde, totalCents).sendAllowed) return { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT } + const frage = invoiceSendConfirmText({ name: customerName(kunde), email: kunde?.email, totalCents }) + if (!confirm(frage)) return { status: 'abgebrochen' } + } + running = true + try { + const data = await create({ ...anfrage.body, senden: Boolean(senden) }) + return { status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) } + } catch (err) { + if (isFinalizedNotSent(err)) { + return { status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) } + } + const out = { status: 'fehler', message: invoiceFailureMessage(err) } + if (Number.isInteger(err?.status)) out.httpStatus = err.status + return out + } finally { + running = false + } + } +} diff --git a/tests/unit/rechnungAnfrage.test.mjs b/tests/unit/rechnungAnfrage.test.mjs new file mode 100644 index 0000000..855c31f --- /dev/null +++ b/tests/unit/rechnungAnfrage.test.mjs @@ -0,0 +1,281 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { + LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX, + vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern, + zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter, +} = await loadModule('src/lib/rechnungAnfrage.js') +const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js') + +const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' } +const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price }) +const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false }) +const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)] +const basis = (over = {}) => ({ + kunde, art: 'festbetrag', projektId: 'p1', auftragId: '', + items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [], + blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, + zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over, +}) + +test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => { + const r = rechnungAnfrage(basis()) + assert.equal(r.ok, true) + assert.deepEqual(r.body, { + customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14, + items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }], + }) + assert.equal(r.totalCents, 50000) +}) + +test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => { + const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' })) + assert.equal(r.ok, true) + assert.equal(r.body.art, 'frei') + assert.equal('projektId' in r.body, false) +}) + +test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => { + assert.equal(LEISTUNGSZEITRAUM_MAX, 140) + const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true })) + assert.equal(r.body.memo, 'Danke') + assert.equal(r.body.leistungszeitraum, 'Oktober 2026') + assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false) + assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false) +}) + +test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => { + assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' }) + assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.') + assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.') + assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.') + assert.equal( + rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error, + 'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.' + ) +}) + +test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => { + const r = rechnungAnfrage(basis({ + art: 'aufwand', blaetter: B, abgewaehlt: ['b2'], + zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' }, + zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')], + })) + assert.equal(r.ok, true) + assert.deepEqual(r.body, { + customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14, + arbeitsblattIds: ['b1', 'b3'], + items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }], + zeilentexte: { '2026-10-06': '06.10.2026: Startseite' }, + }) + assert.equal(r.totalCents, null) +}) + +test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => { + assert.deepEqual( + rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })), + { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' } + ) + assert.equal( + rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error, + 'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).' + ) + assert.equal( + rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error, + 'Zusatzposition 1: Bitte eine Beschreibung eingeben.' + ) + // eine ganz leere Zeile zählt nicht + const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] })) + assert.equal(r.ok, true) + assert.equal('items' in r.body, false) +}) + +test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => { + const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } })) + assert.deepEqual(prozent.body, { + customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14, + anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' }, + }) + const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } })) + assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' }) + assert.equal(BEZEICHNUNG_MAX, 60) +}) + +test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => { + const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung })) + assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.') + for (const wert of ['0', '0,00', '101', 'abc', '']) { + assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert) + } + for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert) + assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.') + assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).') + assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).') +}) + +test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => { + const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' }) + const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }] + assert.deepEqual(rechnungAnfrage(e).body, { + customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke', + }) + assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } }) + assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' }) +}) + +test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => { + assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' }) + assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' }) + const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) }) + assert.equal(vorschauAnfrage(e).ok, true) + assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.') + assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' }) +}) + +test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => { + const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body) + const k1 = key({}) + assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1) + assert.notEqual(key({ abgewaehlt: ['b2'] }), k1) + assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1) + assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1) +}) + +test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => { + const ok = { ok: true, body: {} } + const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } } + const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args }) + assert.deepEqual( + vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }), + { state: 'nicht-noetig', message: '' } + ) + assert.deepEqual( + status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }), + { state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' } + ) + assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' }) + assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt') + assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert + assert.deepEqual( + status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }), + { state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } + ) + assert.deepEqual( + status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }), + { state: 'fehler', message: 'Nichts mehr offen.' } + ) + assert.deepEqual(status({}), { state: 'bereit', message: '' }) + assert.equal(darfSpeichern({ state: 'bereit' }), true) + assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true) + for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state) +}) + +test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => { + const e1 = basis({ art: 'aufwand', blaetter: B }) + const a1 = vorschauAnfrage(e1) + const k1 = vorschauSchluessel(a1.body) + const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } } + assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true) + const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] }) + const k2 = vorschauSchluessel(a2.body) + assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3']) + assert.notEqual(k2, k1) + assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false) +}) + +test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => { + const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €' + assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' }) + assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' }) + assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' }) + const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung }) + assert.equal(zeileBearbeitbar(pos('2026-10-06')), true) + assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true) + for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel) + assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false) + const hinten = ' – 2,25 Std. à 100,00 €' + assert.equal(maxVorn(hinten), 200 - hinten.length) + assert.equal(maxVorn('x'.repeat(250)), 1) +}) + +const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } } +const anfrage = rechnungAnfrage(basis()) + +test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => { + const gesendet = [] + const submit = createSubmitter({ + create: async (body) => (gesendet.push(body), okAntwort), + confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'), + }) + const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 }) + assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false }) + assert.equal(gesendet[0].senden, false) + assert.equal(gesendet[0].art, 'festbetrag') +}) + +test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => { + let frage = '' + let create = 0 + const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } }) + assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' }) + assert.equal(create, 0) + assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.') +}) + +test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => { + let create = 0 + const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true }) + const ohneAdresse = { ...kunde, street: '' } + assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }) + assert.equal(create, 0) + assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag + assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich +}) + +test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => { + let create = 0 + let fertig + const submit = createSubmitter({ + create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) }, + confirm: () => true, + }) + const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 }) + const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 }) + assert.deepEqual(zweiter, { status: 'laeuft' }) + fertig() + assert.equal((await erster).status, 'ok') + assert.equal(create, 1) + // danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können) + const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 }) + fertig() + assert.equal((await dritter).status, 'ok') + assert.equal(create, 2) +}) + +test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => { + const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true }) + const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 }) + assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), { + status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409, + }) + const ohneStatus = new Error('Irgendwas ist schiefgegangen.') + assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' }) + const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), { + status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009', + }) + assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), { + status: 'festgeschrieben', + notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.', + finalizedFilter: 'offen', + }) +}) + +test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => { + let create = 0 + const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true }) + const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 }) + assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' }) + assert.equal(create, 0) +})