Files
tickte-system/tests/unit/rechnungDialogView.test.mjs
ClaudeandClaude Sonnet 5.5 4b184f744f Kontrolle: AB ohne Projekt im Rechnungsdialog für Anzahlung und Schluss wählbar
Der Server (rechnungsbau.js, baueAbRechnung) nimmt Anzahlung und Schlussrechnung ohne
Projekt an. Unter 'Ohne Projekt' lädt der Dialog die ABs des Kunden ohne Projekt und
ihre Rechnungen; frei bleibt die Vorgabe.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:35 +00:00

301 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 {
AB_HINWEIS, ENTWURF_HINWEIS,
startProjekt, projektOptionen, wirksamesProjekt,
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten,
} = await loadModule('src/lib/rechnungDialogView.js')
const ab = (over = {}) => ({
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
})
const rk = (over = {}) => ({
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
})
const bl = (id, minuten, over = {}) => ({
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
})
const KONTEN = [
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
{ projektId: 'p2', name: 'Shop', tickets: [] },
]
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
const zustand = (o) => o.map((x) => [x.key, x.enabled])
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
assert.equal(startProjekt(undefined), null)
assert.equal(startProjekt(null), '')
assert.equal(startProjekt('p1'), 'p1')
})
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
assert.deepEqual(projektOptionen(KONTEN), [
{ value: 'p1', label: 'Website Relaunch · #100131' },
{ value: 'p2', label: 'Shop' },
{ value: '', label: 'Ohne Projekt' },
])
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
})
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
assert.equal(wirksamesProjekt('', KONTEN), '')
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
assert.equal(wirksamesProjekt(null, KONTEN), null)
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
assert.equal(wirksamesProjekt(null, undefined), null)
})
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
const list = [
ab({ id: 'a1' }),
ab({ id: 'a2', status: 'entwurf' }),
ab({ id: 'a3', art: 'aufwand' }),
ab({ id: 'a4', status: 'abgerechnet' }),
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
ab({ id: 'a6', status: 'storniert' }),
]
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
})
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
const list = [
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
ab({ id: 'a1' }),
]
assert.equal(aufwandAuftrag(list).id, 'x2')
assert.equal(aufwandAuftrag([ab()]), null)
assert.equal(aufwandAuftrag(undefined), null)
})
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
})
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
const list = [
rk(),
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
]
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
})
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
const o = optionen({ auftraege: [ab()] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
})
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
const o = optionen(args)
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[0].hint, AB_HINWEIS)
assert.equal(o[1].hint, AB_HINWEIS)
}
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
})
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[1].hint, ENTWURF_HINWEIS)
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
assert.equal(zwei[1].enabled, true)
})
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
const frei = artOptionen({ projektId: '', auftraege: [ab({ projektId: 'p1' })], rechnungen: [], auftraegeAktiv: true })
assert.deepEqual(zustand(frei), [['frei', true]])
})
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
const lage = (args, extra = {}) => {
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
}
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
assert.equal(lage({}), 'festbetrag')
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
})
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
assert.equal(standardAuftrag('anzahlung', []), '')
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
})
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
assert.deepEqual(anzahlungStandard(null), vorgabe)
})
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
})
const ws = (over = {}) => ({
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
})
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
assert.deepEqual(blattAusAppwrite(ws(), {}), {
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
})
// Unterstützung zählt mit: 60 + 1 × 30
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
})
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
assert.equal(blattAusAppwrite({}, {}), null)
})
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
const T = [
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
{ $id: 't4', woid: '100150', topic: '', title: '' },
]
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
])
assert.deepEqual(ticketOptionen(undefined), [])
})
test('zahlungszielVorschlag: Anzahlung und Schluss übernehmen das Zahlungsziel der gewählten AB, sonst 14', () => {
const dreissig = ab({ zahlungszielTage: 30 })
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: dreissig }), '30')
assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: dreissig }), '30')
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab({ zahlungszielTage: 0 }) }), '0') // sofort fällig ist erlaubt
// Aufwand: die jüngste gültige Aufwands-AB
const aufwand = ab({ id: 'x2', art: 'aufwand', zahlungszielTage: 21 })
assert.equal(zahlungszielVorschlag({ art: 'aufwand', auftraege: [ab(), aufwand] }), '21')
// ohne AB, ohne Wert oder mit unbrauchbarem Wert bleibt es bei 14; frei und Festbetrag kennen keine AB
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: undefined }), '14')
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab() }), '14')
for (const kaputt of [-1, 61, 1.5, '30', null, NaN]) assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: ab({ zahlungszielTage: kaputt }) }), '14')
assert.equal(zahlungszielVorschlag({ art: 'festbetrag', ab: dreissig }), '14')
assert.equal(zahlungszielVorschlag({ art: 'frei', ab: dreissig }), '14')
assert.equal(zahlungszielVorschlag({ art: '', auftraege: [aufwand] }), '14')
})
test('steuernummerHinweis: nicht sperrender Hinweis, solange die Steuernummer in den Einstellungen leer ist', () => {
assert.equal(
STEUERNUMMER_RECHNUNG_HINWEIS,
'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
)
for (const leer of [{}, { steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }]) {
assert.equal(steuernummerHinweis(leer), STEUERNUMMER_RECHNUNG_HINWEIS)
}
assert.equal(steuernummerHinweis({ steuernummer: '12/345/67890' }), '')
// noch nicht geladen (undefined) und Ladefehler (null) behaupten nichts
assert.equal(steuernummerHinweis(undefined), '')
assert.equal(steuernummerHinweis(null), '')
assert.equal(steuernummerHinweis([]), '')
})
test('abSummenText: Auftragssumme, berechnet und noch offen; bei Stripe-Ausfall „Beträge unbekannt“ statt 0,00 €', () => {
assert.equal(
abSummenText({ ab: ab(), rechnungen: [rk()], stripeOk: true }),
'Auftragssumme 800,00 € · bereits berechnet 400,00 € · noch nicht berechnet 400,00 €'
)
assert.equal(abSummenText({ ab: ab(), rechnungen: [], stripeOk: undefined }), 'Auftragssumme 800,00 € · bereits berechnet 0,00 € · noch nicht berechnet 800,00 €')
assert.equal(STRIPE_UNBEKANNT, 'Stripe nicht erreichbar – Beträge unbekannt')
const aus = abSummenText({ ab: ab(), rechnungen: [], stripeOk: false })
assert.equal(aus, 'Auftragssumme 800,00 € · Stripe nicht erreichbar – Beträge unbekannt')
assert.ok(!aus.includes('bereits berechnet'))
})
test('standardArt: bei Stripe-Ausfall wird keine Art vorgeschlagen (Beträge unbekannt)', () => {
const lage = { auftraege: [ab()], rechnungen: [], offeneMinuten: 90 }
const o = optionen({ auftraege: [ab()] })
assert.equal(standardArt(o, lage), 'anzahlung')
assert.equal(standardArt(o, { ...lage, stripeOk: true }), 'anzahlung')
assert.equal(standardArt(o, { ...lage, stripeOk: false }), '')
// ohne Projekt gibt es nichts zu raten: die freie Rechnung bleibt die Vorgabe
const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei')
})
test('artOptionen ohne Projekt: eine gültige AB ohne Projekt macht Anzahlung und Schlussrechnung wählbar', () => {
const ohne = (args = {}) => artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true, ...args })
assert.deepEqual(zustand(ohne()), [['frei', true], ['anzahlung', true], ['schluss', true]])
assert.deepEqual(ohne().map((x) => x.label), ['Freie Positionen', 'Anzahlung', 'Schlussrechnung'])
// eine AB mit Projekt, ein Entwurf, ein falscher Status oder der ausgeschaltete Schalter: wie bisher nur frei bzw. gesperrt
assert.deepEqual(zustand(ohne({ auftraege: [ab({ projektId: 'p1' })] })), [['frei', true]])
assert.deepEqual(zustand(ohne({ auftraege: [ab({ status: 'entwurf' })] })), [['frei', true]])
assert.deepEqual(zustand(ohne({ auftraegeAktiv: false })), [['frei', true]])
const entwurf = ohne({ rechnungen: [rk({ status: 'entwurf' })] })
assert.deepEqual(zustand(entwurf), [['frei', true], ['anzahlung', true], ['schluss', false]])
assert.equal(entwurf[2].hint, ENTWURF_HINWEIS)
// Vorgabe bleibt die freie Rechnung
assert.equal(standardArt(ohne(), { auftraege: [ab()], rechnungen: [], offeneMinuten: 0 }), 'frei')
})
test('ohneProjektAbDaten: nur ABs ohne Projekt und deren Rechnungen aus Auftrags- und Rechnungsliste', () => {
const daten = ohneProjektAbDaten(
{ auftraege: [ab({ id: 'a1', projektId: '' }), ab({ id: 'a2', projektId: 'p1' }), ab({ id: 'a3' })] },
{ rechnungen: [rk({ auftragId: 'a1' }), rk({ id: 'in_2', auftragId: 'a2' }), rk({ id: 'in_3', auftragId: '' }), rk({ id: 'in_4', auftragId: 'a3', status: 'entwurf' })] }
)
assert.deepEqual(daten.auftraege.map((a) => a.id), ['a1', 'a3'])
assert.deepEqual(daten.rechnungen.map((r) => r.id), ['in_1', 'in_4'])
assert.deepEqual(ohneProjektAbDaten(undefined, null), { auftraege: [], rechnungen: [] })
assert.deepEqual(ohneProjektAbDaten({ auftraege: 'x' }, { rechnungen: {} }), { auftraege: [], rechnungen: [] })
})