/** * Rechnungszeilen je Art (rein, ohne Stripe und Appwrite): feste Positionen, Aufwand je Tag oder Kalenderwoche, * Anzahlung und Schlussrechnung mit Minuszeilen. Betraege sind ganze Cent. * Importzyklus (siehe Plan L2): zur Modulauswertung nichts aus fehler.js benutzen, nur innerhalb von Funktionen. */ import { AuftragError } from './fehler.js' import { LIMITS } from './konstanten.js' import { berlinDate, formatDe } from '../finance/dates.js' import { euroDe } from '../finance/format.js' const TAG_MS = 86_400_000 const istGanzzahlZwischen = (wert, min, max) => Number.isSafeInteger(wert) && wert >= min && wert <= max /** Text auf hoechstens max Zeichen kuerzen (Auslassungszeichen am Ende, kein halbes Emoji). */ export function kuerzen(text, max) { if (text.length <= max) return text let teil = text.slice(0, max - 1) const letzter = teil.charCodeAt(teil.length - 1) if (letzter >= 0xd800 && letzter <= 0xdbff) teil = teil.slice(0, -1) return `${teil.trimEnd()}…` } /** Minuten als Stunden mit Komma, ohne Endnullen: 135 -> "2,25", 390 -> "6,5", 60 -> "1". */ export function stundenText(minuten) { return String(Math.round((minuten / 60) * 100) / 100).replace('.', ',') } /** Nur ein String YYYY-MM-DD, den es als Tag gibt. */ export function gueltigerTag(ymd) { const treffer = typeof ymd === 'string' ? ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null if (!treffer) return false const [j, m, t] = treffer.slice(1).map(Number) const pruef = new Date(Date.UTC(j, m - 1, t)) return pruef.getUTCFullYear() === j && pruef.getUTCMonth() === m - 1 && pruef.getUTCDate() === t } /** ISO-Woche samt ISO-Jahr; wirft RangeError bei ungueltigem Tag. */ export function kalenderwoche(ymd) { if (!gueltigerTag(ymd)) throw new RangeError(`Ungültiges Datum: ${typeof ymd === 'string' ? ymd : typeof ymd}`) const tag = new Date(`${ymd}T00:00:00Z`) const wochentag = (tag.getUTCDay() + 6) % 7 // Montag = 0 const donnerstag = new Date(tag.getTime() + (3 - wochentag) * TAG_MS) const jahr = donnerstag.getUTCFullYear() const woche = Math.floor((donnerstag.getTime() - Date.UTC(jahr, 0, 1)) / TAG_MS / 7) + 1 return { jahr, woche } } /** "06.10.2026" | "01.10.–20.10.2026" | "28.12.2026–05.01.2027" (Halbgeviertstrich ohne Leerzeichen). */ export function leistungszeitraumText(von, bis) { if (bis < von) throw new RangeError('Das Ende liegt vor dem Beginn.') if (von === bis) return formatDe(von) if (von.slice(0, 4) === bis.slice(0, 4)) return `${formatDe(von).slice(0, 6)}–${formatDe(bis)}` return `${formatDe(von)}–${formatDe(bis)}` } /** Gemeinsamer Pruefer fuer feste und Zusatzpositionen (Eingabe { description, quantity, unitAmountCents }). */ function pruefeEingabePositionen(items, { leerErlaubt, praefix }) { const leer = items === undefined || items === null if (leer && leerErlaubt) return [] if (!Array.isArray(items) || items.length > LIMITS.maxPositionen || (items.length < 1 && !leerErlaubt)) { throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${LIMITS.maxPositionen} Positionen.`) } const positionen = items.map((item, index) => { const label = `Position ${index + 1}` const beschreibung = typeof item?.description === 'string' ? item.description.trim() : '' if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) { throw new AuftragError(400, `${label}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`) } if (!istGanzzahlZwischen(item.quantity, 1, LIMITS.maxMenge)) { throw new AuftragError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`) } if (!istGanzzahlZwischen(item.unitAmountCents, 1, LIMITS.maxEinzelpreisCents)) { throw new AuftragError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) } return { schluessel: `${praefix}-${index + 1}`, beschreibung, menge: item.quantity, einzelpreisCents: item.unitAmountCents, betragCents: item.quantity * item.unitAmountCents, } }) if (positionen.reduce((summe, p) => summe + p.betragCents, 0) > LIMITS.maxSummeCents) { throw new AuftragError(400, 'Rechnungssumme zu hoch.') } return positionen } /** Feste Positionen einer freien Rechnung oder eines Festbetrags: 1 bis 20, Schluessel pos-. */ export function festePositionen(items) { return pruefeEingabePositionen(items, { leerErlaubt: false, praefix: 'pos' }) } /** Zusatzpositionen (Nachtraege einer Schlussrechnung): wie festePositionen, aber leer erlaubt, Schluessel zusatz-. */ export function zusatzPositionen(items) { return pruefeEingabePositionen(items, { leerErlaubt: true, praefix: 'zusatz' }) } /** Prueft fertige Zeilen (auch Minuszeilen): Zahl 1 bis max, Beschreibungen, Summe; liefert die Summe in Cent. */ export function pruefePositionen(positionen, { max = LIMITS.maxPositionen } = {}) { if (!Array.isArray(positionen) || positionen.length < 1 || positionen.length > max) { throw new AuftragError(400, `Eine Rechnung braucht 1 bis ${max} Positionen.`) } positionen.forEach((p, index) => { const laenge = typeof p.beschreibung === 'string' ? p.beschreibung.length : 0 if (laenge < 1 || laenge > LIMITS.maxBeschreibung) { throw new AuftragError(400, `Position ${index + 1}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`) } }) const summe = positionen.reduce((s, p) => s + p.betragCents, 0) if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') return summe } const vereinheitlicht = (text) => (typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '') /** Eine Zeile (Tag oder Woche) mit Standardtext oder ersetztem Text, Stunden und Satz; nie ueber 200 Zeichen. */ function aufwandZeile(einheit, satzCents, zeilentexte) { const eigen = zeilentexte && Object.hasOwn(zeilentexte, einheit.schluessel) ? vereinheitlicht(zeilentexte[einheit.schluessel]) : '' const standard = `${einheit.kopf}: ${einheit.texte.length ? einheit.texte.join('; ') : 'Arbeitszeit'}` const schluss = ` – ${stundenText(einheit.minuten)} Std. à ${euroDe(satzCents)}` const betragCents = Math.round((einheit.minuten * satzCents) / 60) return { schluessel: einheit.schluessel, beschreibung: `${kuerzen(eigen || standard, LIMITS.maxBeschreibung - schluss.length)}${schluss}`, menge: 1, einzelpreisCents: betragCents, betragCents, } } /** * Aufwandszeilen: eine Zeile je Tag; gibt es mehr Tage als maxZeilen, werden die aeltesten Kalenderwochen zu * KW-Zeilen zusammengefasst. zeilentexte[schluessel] ersetzt den Text vor dem Gedankenstrich. */ export function aufwandPositionen(blaetter, { satzCents, zeilentexte = {}, maxZeilen = LIMITS.maxPositionen } = {}) { if (!blaetter.length) return { positionen: [], minuten: 0, von: null, bis: null } if (!istGanzzahlZwischen(satzCents, 1, LIMITS.maxEinzelpreisCents)) throw new AuftragError(400, 'Der Stundensatz ist ungültig.') for (const blatt of blaetter) { if (!gueltigerTag(blatt.datum) || !Number.isSafeInteger(blatt.minuten) || blatt.minuten <= 0) { throw new AuftragError(400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.') } } const sortiert = [...blaetter].sort((a, b) => a.datum.localeCompare(b.datum) || String(a.id).localeCompare(String(b.id))) const tage = [] for (const blatt of sortiert) { let tag = tage.at(-1) if (!tag || tag.datum !== blatt.datum) { tag = { datum: blatt.datum, minuten: 0, texte: [] } tage.push(tag) } tag.minuten += blatt.minuten const text = vereinheitlicht(blatt.text) if (text && !tag.texte.includes(text)) tag.texte.push(text) } const tagesEinheit = (tag) => ({ schluessel: tag.datum, kopf: formatDe(tag.datum), minuten: tag.minuten, texte: tag.texte }) const wochenEinheit = (woche) => { const texte = [] for (const tag of woche.tage) for (const text of tag.texte) if (!texte.includes(text)) texte.push(text) const ww = String(woche.woche).padStart(2, '0') return { schluessel: `KW-${woche.jahr}-${ww}`, kopf: `KW ${ww}/${woche.jahr}`, minuten: woche.tage.reduce((s, t) => s + t.minuten, 0), texte } } const einheiten = [] if (tage.length <= maxZeilen) { einheiten.push(...tage.map(tagesEinheit)) } else { const wochen = [] for (const tag of tage) { const kw = kalenderwoche(tag.datum) let woche = wochen.find((w) => w.jahr === kw.jahr && w.woche === kw.woche) if (!woche) { woche = { ...kw, tage: [] } wochen.push(woche) } woche.tage.push(tag) } let anzahl = tage.length for (const woche of wochen) { if (anzahl > maxZeilen && woche.tage.length > 1) { anzahl -= woche.tage.length - 1 einheiten.push(wochenEinheit(woche)) } else einheiten.push(...woche.tage.map(tagesEinheit)) } if (anzahl > maxZeilen) { throw new AuftragError(400, `Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als ${maxZeilen} Zeilen). Bitte weniger Arbeitsblätter auswählen.`) } } return { positionen: einheiten.map((einheit) => aufwandZeile(einheit, satzCents, zeilentexte)), minuten: tage.reduce((s, t) => s + t.minuten, 0), von: tage[0].datum, bis: tage.at(-1).datum, } } /** Berliner Tag des Sendens der AB, sonst der Tag der Absprache (YYYY-MM-DD). */ export function auftragDatum(auftrag) { if (auftrag.gesendetAm) { const zeitpunkt = new Date(auftrag.gesendetAm) if (!Number.isNaN(zeitpunkt.getTime())) return berlinDate(zeitpunkt) } return auftrag.auftragVom || '' } const angegeben = (wert) => wert !== undefined && wert !== null /** Anzahlungszeile zu einer AB: Prozent oder Betrag, hoechstens Summe minus bereits Berechnetes. */ export function anzahlungPosition(auftrag, angaben = {}, bereitsBerechnetCents = 0) { const { prozent: prozentEingabe, betragCents: betragEingabe, bezeichnung: bezeichnungEingabe = 'Anzahlung' } = angaben || {} const bezeichnung = typeof bezeichnungEingabe === 'string' ? bezeichnungEingabe.trim() : '' if (bezeichnung.length < 1 || bezeichnung.length > 60) throw new AuftragError(400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.') if (angegeben(prozentEingabe) && angegeben(betragEingabe)) { throw new AuftragError(400, 'Anzahlung entweder in Prozent oder als Betrag angeben.') } let prozent = null let betrag if (angegeben(betragEingabe)) { if (!Number.isSafeInteger(betragEingabe) || betragEingabe < 1) { throw new AuftragError(400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.') } betrag = betragEingabe } else { prozent = angegeben(prozentEingabe) ? prozentEingabe : auftrag.zahlungsplan?.art === 'anzahlung' && angegeben(auftrag.zahlungsplan.prozent) ? auftrag.zahlungsplan.prozent : LIMITS.standardAnzahlungProzent if (typeof prozent !== 'number' || !Number.isFinite(prozent) || prozent <= 0 || prozent > 100) { throw new AuftragError(400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.') } betrag = Math.round((auftrag.summeCents * prozent) / 100) if (betrag < 1) throw new AuftragError(400, 'Der Betrag muss mindestens 0,01 € betragen.') } const rest = auftrag.summeCents - bereitsBerechnetCents if (rest <= 0) throw new AuftragError(409, 'Nichts mehr offen.') if (betrag > rest) { throw new AuftragError(400, `Die Anzahlung darf höchstens ${euroDe(rest)} betragen (Summe der Auftragsbestätigung minus bereits berechnet).`) } const prozentTeil = prozent === null ? '' : ` ${String(prozent).replace('.', ',')} %` const davor = `${bezeichnung}${prozentTeil} gemäß Auftragsbestätigung ${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` const titel = typeof auftrag.titel === 'string' ? auftrag.titel.trim() : '' const beschreibung = titel ? `${davor} (${kuerzen(titel, LIMITS.maxBeschreibung - davor.length - 3)})` : davor return { schluessel: 'anzahlung', beschreibung, menge: 1, einzelpreisCents: betrag, betragCents: betrag } } // Obergrenzen einer Schlussrechnung (Uebersicht, Nachtraege nach der Planrunde); alle anderen Rechnungen: LIMITS.maxPositionen export const SCHLUSS_GRENZEN = { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 } /** Schlussrechnung: alle AB-Positionen, Nachtraege, je fruehere Rechnung eine Minuszeile. */ export function schlussPositionen(auftrag, fruehere, zusatz = []) { const positionenAb = auftrag.positionen || [] if (positionenAb.length > SCHLUSS_GRENZEN.abPositionen) { throw new AuftragError(400, `Die Auftragsbestätigung hat ${positionenAb.length} Positionen; eine Schlussrechnung übernimmt höchstens ${SCHLUSS_GRENZEN.abPositionen}.`) } if (Array.isArray(zusatz) && zusatz.length > SCHLUSS_GRENZEN.zusatz) { throw new AuftragError(400, `Eine Schlussrechnung erlaubt höchstens ${SCHLUSS_GRENZEN.zusatz} Zusatzpositionen.`) } const abzuege = (fruehere || []).filter((f) => f.betragCents > 0) if (abzuege.length > SCHLUSS_GRENZEN.abzuege) { throw new AuftragError(400, `Die Schlussrechnung hätte ${abzuege.length} Abzüge für frühere Rechnungen; erlaubt sind höchstens ${SCHLUSS_GRENZEN.abzuege}.`) } const zeilen = positionenAb.map((p, i) => ({ schluessel: `ab-${i + 1}`, beschreibung: p.beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents, betragCents: p.menge * p.einzelpreisCents, })) zeilen.push(...zusatzPositionen(zusatz)) const sortiert = [...abzuege].sort((a, b) => a.datum.localeCompare(b.datum) || a.invoiceId.localeCompare(b.invoiceId)) for (const f of sortiert) { zeilen.push({ schluessel: `abzug-${f.invoiceId}`, beschreibung: `abzüglich ${f.art === 'anzahlung' ? 'Anzahlung' : 'Teilrechnung'}, Rechnung ${f.rechnungsnummer} vom ${formatDe(f.datum)}`, menge: 1, einzelpreisCents: -f.betragCents, betragCents: -f.betragCents, }) } const summe = zeilen.reduce((s, p) => s + p.betragCents, 0) if (summe > LIMITS.maxSummeCents) throw new AuftragError(400, 'Rechnungssumme zu hoch.') if (summe <= 0) throw new AuftragError(409, 'Nichts mehr offen.') return zeilen }