Rechnungsdialog: Anfragekörper, Vorschau-Status, Zeilentexte und Absenden mit Doppelklick-Sperre samt Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e37c0a664e
commit
0f1dbabd98
2 files changed
+502
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user