import { euro } from './geld.js' /** * Warenkorb-Berechnung fuer den Shop (Plan 2026-10-05-webshop.md, Task C4). Rein und deterministisch: kein I/O, kein Datum. * Geld ist immer ganze Cent; Steuer und Versandanteile werden nur mit ganzen Zahlen gerechnet. * Fehlertexte sind fuer Kaeufer geschrieben (Sie-Form). */ const MAX_POSITIONEN = 50 const MAX_MENGE = 99 const KLEINUNTERNEHMER_HINWEIS = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) /** Stripe nimmt Zahlungen in Euro erst ab 0,50 EUR an. */ const MINDESTBETRAG_CENT = 50 const fehler = (code, text, produktId) => ({ ok: false, fehler: produktId === undefined ? { code, text } : { code, text, produktId } }) /** Enthaltene USt aus Brutto, kaufmaennisch (halb auf) gerundet: brutto * satz / (100 + satz), nur mit ganzen Zahlen. */ function enthalteneSteuer(bruttoCent, satz) { if (satz === 0 || bruttoCent === 0) return 0 const teiler = 100 + satz return Math.floor((2 * bruttoCent * satz + teiler) / (2 * teiler)) } /** Gesamtbetrag der Positionen je Steuersatz (Brutto), als Map satz => Cent in Einfuegereihenfolge. */ function nachSteuersatz(zeilen, wert) { const summen = new Map() for (const z of zeilen) summen.set(z.steuersatz, (summen.get(z.steuersatz) ?? 0) + wert(z)) return summen } /** * @param {object} p * @param {Map} p.produkte id => { id, aktiv, ...normalisiertes Produkt } * @param {Array<{produktId: string, varianteId?: string, menge: number}>} p.positionen * @param {'versand'|'abholung'} p.lieferart * @param {string} [p.land] 'DE' | 'AT' (nur bei Versand) * @param {object} p.einstellungen normalisierte Shop-Einstellungen * @param {Map} [p.reserviert] Schluessel produktId bzw. `${produktId}:${varianteId}` => reservierte Menge */ export function berechne({ produkte, positionen, lieferart, land, einstellungen, reserviert = new Map() } = {}) { const e = einstellungen ?? {} // 1. Eingabe pruefen und gleiche Artikel (Produkt + Variante) zusammenfassen if (!Array.isArray(positionen) || positionen.length === 0) return fehler('leer', 'Ihr Warenkorb ist leer.') const zusammen = new Map() for (const pos of positionen) { const produktId = istObjekt(pos) && typeof pos.produktId === 'string' ? pos.produktId : '' if (produktId === '') return fehler('nicht_verfuegbar', 'Ein Artikel in Ihrem Warenkorb ist leider nicht mehr verfügbar.') const menge = pos.menge if (!Number.isInteger(menge) || menge < 1 || menge > MAX_MENGE) { return fehler('menge', `Bitte wählen Sie eine Menge von 1 bis ${MAX_MENGE}.`, produktId) } const varianteId = typeof pos.varianteId === 'string' && pos.varianteId !== '' ? pos.varianteId : null const schluessel = `${produktId}\u0000${varianteId ?? ''}` const vorhanden = zusammen.get(schluessel) if (vorhanden) vorhanden.menge += menge else zusammen.set(schluessel, { produktId, varianteId, menge }) } if (zusammen.size > MAX_POSITIONEN) { return fehler('zu_viele', `Ihr Warenkorb enthält zu viele verschiedene Artikel. Bitte bestellen Sie höchstens ${MAX_POSITIONEN} Positionen auf einmal.`) } // 2. Jede Position gegen Produkt, Variante und Bestand pruefen const zeilen = [] for (const { produktId, varianteId, menge } of zusammen.values()) { const produkt = produkte instanceof Map ? produkte.get(produktId) : undefined if (!istObjekt(produkt) || produkt.aktiv !== true) { const name = istObjekt(produkt) && typeof produkt.name === 'string' ? `„${produkt.name}“` : 'Ein Artikel in Ihrem Warenkorb' return fehler('nicht_verfuegbar', `${name} ist leider nicht mehr verfügbar.`, produktId) } const hatVarianten = istObjekt(produkt.varianten) && Array.isArray(produkt.varianten.werte) && produkt.varianten.werte.length > 0 let wert = null if (hatVarianten) { wert = varianteId === null ? null : (produkt.varianten.werte.find((w) => w.id === varianteId) ?? null) if (wert === null) { const text = varianteId === null ? `Bitte wählen Sie für „${produkt.name}“ eine Variante (${produkt.varianten.name}) aus.` : `Die gewählte Variante von „${produkt.name}“ gibt es leider nicht (mehr). Bitte wählen Sie eine andere (${produkt.varianten.name}).` return fehler('variante', text, produktId) } } else if (varianteId !== null) { return fehler('variante', `Für „${produkt.name}“ gibt es keine Varianten. Bitte entfernen Sie die Auswahl.`, produktId) } if (menge > MAX_MENGE) return fehler('menge', `Bitte wählen Sie von „${produkt.name}“ höchstens ${MAX_MENGE} Stück.`, produktId) const einzelCent = wert ? wert.preisCent : produkt.preisCent if (!Number.isSafeInteger(einzelCent) || einzelCent < 0) { return fehler('nicht_verfuegbar', `„${produkt.name}“ ist leider nicht mehr verfügbar.`, produktId) } const varianteText = wert ? `${produkt.varianten.name}: ${wert.name}` : '' const anzeige = varianteText ? `${produkt.name} (${varianteText})` : produkt.name const bestand = wert ? wert.bestand : produkt.bestand if (bestand !== null && bestand !== undefined) { const reservierungKey = wert ? `${produktId}:${varianteId}` : produktId const reservierteMenge = reserviert instanceof Map ? (reserviert.get(reservierungKey) ?? 0) : 0 const frei = Math.max(0, bestand - reservierteMenge) if (frei < menge) { const text = frei === 0 ? `„${anzeige}“ ist leider ausverkauft.` : `Von „${anzeige}“ sind leider nur noch ${frei} Stück verfügbar. Bitte verringern Sie die Menge.` return fehler('ausverkauft', text, produktId) } } zeilen.push({ produktId, varianteId: wert ? varianteId : null, name: produkt.name, varianteText, menge, einzelCent, summeCent: einzelCent * menge, steuersatz: produkt.steuersatz ?? 0, widerruf: produkt.widerruf ?? 'gesetzlich', nurAbholung: produkt.lieferart === 'abholung', anzeige, }) } // 3. Lieferart const versand = e.versand ?? {} const abholung = e.abholung ?? {} if (lieferart === 'versand') { if (versand.aktiv !== true) return fehler('lieferart', 'Versand ist in diesem Shop leider nicht möglich.') if (typeof land !== 'string' || !Array.isArray(versand.laender) || !versand.laender.includes(land)) { return fehler('land', 'Wir liefern leider nicht in das gewählte Land.') } const nur = zeilen.find((z) => z.nurAbholung) if (nur) return fehler('nur_abholung', `„${nur.anzeige}“ kann nicht versendet werden, sondern nur abgeholt werden.`, nur.produktId) } else if (lieferart === 'abholung') { if (abholung.aktiv !== true) return fehler('lieferart', 'Abholung ist in diesem Shop leider nicht möglich.') } else { return fehler('lieferart', 'Bitte wählen Sie Versand oder Abholung.') } // 4. Summen und Mindestbestellwert const zwischensummeCent = zeilen.reduce((s, z) => s + z.summeCent, 0) if (Number.isInteger(e.mindestbestellCent) && zwischensummeCent < e.mindestbestellCent) { return fehler('mindestbestellwert', `Der Mindestbestellwert beträgt ${euro(e.mindestbestellCent)}. Bitte legen Sie weitere Artikel in den Warenkorb.`) } let versandCent = 0 if (lieferart === 'versand') { const kosten = versand.kostenCent && Object.hasOwn(versand.kostenCent, land) ? versand.kostenCent[land] : 0 const kostenlos = Number.isInteger(versand.freiAbCent) && zwischensummeCent >= versand.freiAbCent versandCent = kostenlos || !Number.isInteger(kosten) || kosten < 0 ? 0 : kosten } const gesamtCent = zwischensummeCent + versandCent if (gesamtCent < MINDESTBETRAG_CENT) return fehler('mindestbetrag', 'Der Bestellwert muss mindestens 0,50 € betragen.') // 5. Steuer: je Satz ueber die Summe der Positionen, Versand anteilig nach Bruttowert (Rest auf den hoechsten Satz) let steuer = [] let steuerHinweis if (e.kleinunternehmer === true) { steuerHinweis = KLEINUNTERNEHMER_HINWEIS } else { const brutto = nachSteuersatz(zeilen, (z) => z.summeCent) const saetze = [...brutto.keys()].sort((a, b) => b - a) if (versandCent > 0 && zwischensummeCent > 0) { let verteilt = 0 for (const satz of saetze) { const anteil = Math.floor((versandCent * brutto.get(satz)) / zwischensummeCent) verteilt += anteil brutto.set(satz, brutto.get(satz) + anteil) } brutto.set(saetze[0], brutto.get(saetze[0]) + (versandCent - verteilt)) } steuer = saetze .map((satz) => ({ satz, betragCent: enthalteneSteuer(brutto.get(satz), satz) })) .filter((s) => s.betragCent > 0) .map((s) => ({ ...s, text: `inkl. ${s.satz} % USt: ${euro(s.betragCent)}` })) steuerHinweis = steuer.map((s) => s.text).join('; ') } return { ok: true, positionen: zeilen.map((z) => ({ produktId: z.produktId, varianteId: z.varianteId, name: z.name, varianteText: z.varianteText, menge: z.menge, einzelCent: z.einzelCent, summeCent: z.summeCent, steuersatz: z.steuersatz, widerruf: z.widerruf, einzelText: euro(z.einzelCent), summeText: euro(z.summeCent), })), zwischensummeCent, versandCent, gesamtCent, steuer, texte: { zwischensumme: euro(zwischensummeCent), versand: lieferart === 'abholung' ? 'Abholung – kostenlos' : versandCent === 0 ? 'kostenlos' : euro(versandCent), gesamt: euro(gesamtCent), steuerHinweis, }, lieferart, land: lieferart === 'versand' ? land : null, } }