berechne lehnt Gesamtbetraege unter 0,50 EUR mit dem Code mindestbetrag und einem Kaeufertext ab. legeBestellungAn schiebt expires_at um die seit Beginn vergangenen ganzen Sekunden (Warten auf Sperren) weiter und fuehrt ablaufAt auf denselben Wert nach, damit Stripes 30-Minuten-Minimum sicher bleibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
206 lines
9.5 KiB
JavaScript
206 lines
9.5 KiB
JavaScript
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<string, object>} 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<string, number>} [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,
|
||
}
|
||
}
|