Files
Webklar-Kundenbereich/server/services/shop/preise.js
T
ClaudeandClaude Opus 5.5 d4b9e6a5b9 Shop Review A M9: Mindestbetrag 0,50 EUR (Stripe) und Sitzungsende direkt vor dem Stripe-Aufruf nachfuehren
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>
2026-10-05 09:35:17 +00:00

206 lines
9.5 KiB
JavaScript
Raw 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 { 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,
}
}