Files
tickte-system/tests/unit/rechnungAnfrage.test.mjs

282 lines
17 KiB
JavaScript
Raw Permalink 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.
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)
})