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: [] }) })