diff --git a/server/services/shop/preise.js b/server/services/shop/preise.js new file mode 100644 index 0000000..67c126c --- /dev/null +++ b/server/services/shop/preise.js @@ -0,0 +1,202 @@ +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) +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 + + // 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, + } +} diff --git a/test/shopPreise.test.js b/test/shopPreise.test.js new file mode 100644 index 0000000..fd6e4ff --- /dev/null +++ b/test/shopPreise.test.js @@ -0,0 +1,379 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { berechne } from '../server/services/shop/preise.js' +import { euro } from '../server/services/shop/geld.js' + +const basis = { + aktiv: true, kurz: '', beschreibung: '', steuersatz: 19, grundpreis: null, bestand: null, varianten: null, + bilder: [], lieferart: 'beides', widerruf: 'gesetzlich', +} +const produkt = (id, extra = {}) => ({ ...basis, id, name: `Artikel ${id}`, preisCent: 1000, ...extra }) +const karte = (...liste) => new Map(liste.map((p) => [p.id, p])) +const shirt = produkt('shirt', { + name: 'T-Shirt', + preisCent: null, + varianten: { + name: 'Größe', + werte: [ + { id: 'sizeaaaa', name: 'M', preisCent: 2000, bestand: 5 }, + { id: 'sizebbbb', name: 'L', preisCent: 2200, bestand: null }, + ], + }, +}) +const einstellungen = (extra = {}) => ({ + kleinunternehmer: false, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: null }, + abholung: { aktiv: true, adresse: 'Weg 1', hinweis: '' }, + mindestbestellCent: null, + ...extra, +}) +const rechne = (args) => berechne({ lieferart: 'versand', land: 'DE', einstellungen: einstellungen(), ...args }) +const eine = (p, menge = 1, varianteId) => [{ produktId: p.id, menge, ...(varianteId ? { varianteId } : {}) }] + +test('einfacher Warenkorb: Summen, Texte, Versand und Steuer', () => { + const a = produkt('a', { preisCent: 1190 }) + const r = rechne({ produkte: karte(a), positionen: eine(a, 2) }) + assert.equal(r.ok, true) + assert.equal(r.zwischensummeCent, 2380) + assert.equal(r.versandCent, 490) + assert.equal(r.gesamtCent, 2870) + assert.deepEqual(r.positionen[0], { + produktId: 'a', varianteId: null, name: 'Artikel a', varianteText: '', menge: 2, einzelCent: 1190, summeCent: 2380, + steuersatz: 19, widerruf: 'gesetzlich', einzelText: euro(1190), summeText: euro(2380), + }) + assert.equal(r.texte.zwischensumme, euro(2380)) + assert.equal(r.texte.versand, euro(490)) + assert.equal(r.texte.gesamt, euro(2870)) + // 2870 * 19 / 119 = 458,23 => 458 + assert.deepEqual(r.steuer, [{ satz: 19, betragCent: 458, text: `inkl. 19 % USt: ${euro(458)}` }]) + assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(458)}`) + assert.equal(r.lieferart, 'versand') + assert.equal(r.land, 'DE') +}) + +test('gemischte Steuersaetze: Text mit absteigenden Saetzen, 0 % liefert keinen Eintrag', () => { + const a = produkt('a', { preisCent: 2000, steuersatz: 19 }) + const b = produkt('b', { preisCent: 700, steuersatz: 7 }) + const c = produkt('c', { preisCent: 500, steuersatz: 0 }) + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 1 } }) + const r = berechne({ produkte: karte(a, b, c), positionen: [...eine(b), ...eine(c), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e }) + assert.equal(r.versandCent, 0) + assert.equal(r.texte.versand, 'kostenlos') + assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [[19, 319], [7, 46]]) + assert.equal(r.texte.steuerHinweis, `inkl. 19 % USt: ${euro(319)}; inkl. 7 % USt: ${euro(46)}`) + assert.deepEqual(r.positionen.map((p) => p.produktId), ['b', 'c', 'a'], 'Reihenfolge der Eingabe bleibt') +}) + +test('Steuer wird je Satz ueber die Summe gerundet, nicht je Position', () => { + // Einzeln: 3 * round(0,119 * ... ) waere 3 * 0 ; ueber die Summe: 3 * 10 = 30 Cent brutto => 30*19/119 = 4,79 => 5 + const a = produkt('a', { preisCent: 10 }) + const e = einstellungen({ abholung: { aktiv: true, adresse: 'x', hinweis: '' } }) + const r = berechne({ produkte: karte(a), positionen: eine(a, 3), lieferart: 'abholung', einstellungen: e }) + assert.equal(r.steuer[0].betragCent, 5) +}) + +test('Rundung halb auf an der Grenze', () => { + // brutto 107 bei 7 % => 107*7/107 = 7 exakt; brutto 50 bei 7 % => 3,271 => 3; brutto 1 bei 19 % => 0,159 => 0 (kein Eintrag) + const abh = (p, n = 1) => berechne({ produkte: karte(p), positionen: eine(p, n), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(abh(produkt('a', { preisCent: 107, steuersatz: 7 })).steuer[0].betragCent, 7) + assert.equal(abh(produkt('a', { preisCent: 50, steuersatz: 7 })).steuer[0].betragCent, 3) + assert.deepEqual(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).steuer, []) + assert.equal(abh(produkt('a', { preisCent: 1, steuersatz: 19 })).texte.steuerHinweis, '') + // brutto 5 bei 19 % => 0,798 => 1 + assert.equal(abh(produkt('a', { preisCent: 5, steuersatz: 19 })).steuer[0].betragCent, 1) + // brutto 30 bei 7 % => 1,962 => 2 + assert.equal(abh(produkt('a', { preisCent: 30, steuersatz: 7 })).steuer[0].betragCent, 2) +}) + +test('Versand wird anteilig nach Bruttowert auf die Saetze verteilt, Rest-Cent auf den hoechsten Satz', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 19 }) + const b = produkt('b', { preisCent: 1000, steuersatz: 7 }) + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 501 }, freiAbCent: null } }) + const r = berechne({ produkte: karte(b, a), positionen: [...eine(b), ...eine(a)], lieferart: 'versand', land: 'DE', einstellungen: e }) + // Anteile: floor(501*1000/2000) = 250 je Satz, Rest 1 => 19 %: 1251, 7 %: 1250 + assert.equal(r.gesamtCent, 2501) + assert.deepEqual(r.steuer.map((s) => [s.satz, s.betragCent]), [ + [19, Math.floor((2 * 1251 * 19 + 119) / (2 * 119))], + [7, Math.floor((2 * 1250 * 7 + 107) / (2 * 107))], + ]) + assert.equal(r.steuer[0].betragCent, 200) + assert.equal(r.steuer[1].betragCent, 82) +}) + +test('Versand bei nur 0-%-Artikeln erzeugt keine Steuer', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 0 }) + const r = rechne({ produkte: karte(a), positionen: eine(a) }) + assert.deepEqual(r.steuer, []) + assert.equal(r.gesamtCent, 1490) +}) + +test('Kleinunternehmer: keine Steuer, Hinweis nach Paragraph 19', () => { + const a = produkt('a', { preisCent: 1000, steuersatz: 0 }) + const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: einstellungen({ kleinunternehmer: true }) }) + assert.deepEqual(r.steuer, []) + assert.equal(r.texte.steuerHinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') + // auch wenn ein Artikel (fehlerhaft) einen Satz traegt + const b = produkt('b', { preisCent: 1000, steuersatz: 19 }) + const r2 = rechne({ produkte: karte(b), positionen: eine(b), einstellungen: einstellungen({ kleinunternehmer: true }) }) + assert.deepEqual(r2.steuer, []) +}) + +test('Versandkosten: Oesterreich, Schwelle gleich ist kostenlos, knapp darunter nicht', () => { + const e = einstellungen({ versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 } }) + const p = (cent) => produkt('a', { preisCent: cent }) + const lauf = (cent, land) => { + const a = p(cent) + return berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land, einstellungen: e }) + } + assert.equal(lauf(2000, 'AT').versandCent, 990) + assert.equal(lauf(2000, 'AT').texte.versand, euro(990)) + assert.equal(lauf(4999, 'DE').versandCent, 490) + assert.equal(lauf(5000, 'DE').versandCent, 0) + assert.equal(lauf(5000, 'AT').versandCent, 0) + assert.equal(lauf(5001, 'DE').texte.versand, 'kostenlos') + assert.equal(lauf(5000, 'DE').gesamtCent, 5000) +}) + +test('freiAbCent 0 heisst immer kostenlos', () => { + const a = produkt('a') + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: 0 } }) + assert.equal(berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'versand', land: 'DE', einstellungen: e }).versandCent, 0) +}) + +test('Abholung: keine Versandkosten, eigener Text, Land ignoriert', () => { + const a = produkt('a', { preisCent: 1190 }) + const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(r.ok, true) + assert.equal(r.versandCent, 0) + assert.equal(r.gesamtCent, 1190) + assert.equal(r.texte.versand, 'Abholung – kostenlos') + assert.equal(r.lieferart, 'abholung') +}) + +test('Abholung nur-Abholung-Artikel sind bei Abholung erlaubt', () => { + const a = produkt('a', { lieferart: 'abholung' }) + const r = berechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen() }) + assert.equal(r.ok, true) +}) + +test('Varianten: Preis und Text der Variante', () => { + const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizebbbb') }) + assert.equal(r.ok, true) + assert.equal(r.positionen[0].varianteId, 'sizebbbb') + assert.equal(r.positionen[0].varianteText, 'Größe: L') + assert.equal(r.positionen[0].einzelCent, 2200) + assert.equal(r.positionen[0].summeCent, 4400) +}) + +test('Fehler leer: leere, fehlende oder keine Liste', () => { + const a = produkt('a') + for (const positionen of [[], undefined, null, 'x', {}]) { + const r = rechne({ produkte: karte(a), positionen }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'leer') + assert.match(r.fehler.text, /Warenkorb/) + } +}) + +test('Fehler zu_viele: 51 verschiedene Positionen, aber 50 sind erlaubt und Duplikate zaehlen einmal', () => { + const liste = Array.from({ length: 51 }, (_, i) => produkt(`p${i}`)) + const positionen = (n) => liste.slice(0, n).map((p) => ({ produktId: p.id, menge: 1 })) + const r51 = rechne({ produkte: karte(...liste), positionen: positionen(51) }) + assert.equal(r51.ok, false) + assert.equal(r51.fehler.code, 'zu_viele') + assert.equal(rechne({ produkte: karte(...liste), positionen: positionen(50) }).ok, true) + // 51 Eintraege, aber nur 50 verschiedene + const dup = [...positionen(50), { produktId: 'p0', menge: 1 }] + const r = rechne({ produkte: karte(...liste), positionen: dup }) + assert.equal(r.ok, true) + assert.equal(r.positionen.length, 50) + assert.equal(r.positionen[0].menge, 2) +}) + +test('Fehler menge: 0, 100, negativ, Dezimal, Text, NaN, fehlend', () => { + const a = produkt('a') + for (const menge of [0, 100, -1, 1.5, '2', NaN, Infinity, undefined, null]) { + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge }] }) + assert.equal(r.ok, false, String(menge)) + assert.equal(r.fehler.code, 'menge', String(menge)) + assert.equal(r.fehler.produktId, 'a') + } + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 99) }).ok, true) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1) }).ok, true) +}) + +test('Zusammengefasste Menge ueber 99 ist ein Mengenfehler', () => { + const a = produkt('a') + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 60 }, { produktId: 'a', menge: 40 }] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'menge') + assert.equal(r.fehler.produktId, 'a') +}) + +test('Duplikate gleicher Artikel werden zusammengefasst, Varianten bleiben getrennt', () => { + const a = produkt('a', { preisCent: 300 }) + const r = rechne({ + produkte: karte(a, shirt), + positionen: [ + { produktId: 'a', menge: 1 }, + { produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 }, + { produktId: 'a', menge: 2 }, + { produktId: 'shirt', varianteId: 'sizebbbb', menge: 1 }, + { produktId: 'shirt', varianteId: 'sizeaaaa', menge: 1 }, + ], + }) + assert.equal(r.ok, true) + assert.deepEqual(r.positionen.map((p) => [p.produktId, p.varianteId, p.menge]), [ + ['a', null, 3], ['shirt', 'sizeaaaa', 2], ['shirt', 'sizebbbb', 1], + ]) + assert.equal(r.zwischensummeCent, 900 + 4000 + 2200) +}) + +test('Fehler nicht_verfuegbar: unbekannt, inaktiv, ungueltige Position', () => { + const a = produkt('a') + const aus = produkt('aus', { aktiv: false }) + for (const pos of [{ produktId: 'x', menge: 1 }, { produktId: 'aus', menge: 1 }, { produktId: '__proto__', menge: 1 }, { produktId: 'constructor', menge: 1 }]) { + const r = rechne({ produkte: karte(a, aus), positionen: [pos] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'nicht_verfuegbar') + } + assert.equal(rechne({ produkte: karte(a), positionen: [null] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(a), positionen: [{ menge: 1 }] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(a), positionen: [{ produktId: 5, menge: 1 }] }).fehler.code, 'nicht_verfuegbar') + assert.equal(rechne({ produkte: karte(aus), positionen: eine(aus) }).fehler.produktId, 'aus') +}) + +test('Fehler variante: fehlt, unbekannt, bei Artikel ohne Varianten', () => { + const a = produkt('a') + const ohne = rechne({ produkte: karte(shirt), positionen: eine(shirt) }) + assert.equal(ohne.fehler.code, 'variante') + assert.match(ohne.fehler.text, /Größe/) + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'gibtsnich') }).fehler.code, 'variante') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1, 'sizeaaaa') }).fehler.code, 'variante') + assert.equal(ohne.fehler.produktId, 'shirt') + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa') }).ok, true) +}) + +test('Bestand: unbegrenzt, genau ausreichend, zu wenig mit Restmenge, ausverkauft', () => { + const frei = produkt('frei', { bestand: null }) + assert.equal(rechne({ produkte: karte(frei), positionen: eine(frei, 99) }).ok, true) + const a = produkt('a', { bestand: 3 }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 3) }).ok, true) + const zuwenig = rechne({ produkte: karte(a), positionen: eine(a, 4) }) + assert.equal(zuwenig.ok, false) + assert.equal(zuwenig.fehler.code, 'ausverkauft') + assert.equal(zuwenig.fehler.produktId, 'a') + assert.match(zuwenig.fehler.text, /Artikel a/) + assert.match(zuwenig.fehler.text, /nur noch 3/) + const null0 = produkt('n', { bestand: 0 }) + const aus = rechne({ produkte: karte(null0), positionen: eine(null0) }) + assert.equal(aus.fehler.code, 'ausverkauft') + assert.match(aus.fehler.text, /Artikel n/) + assert.match(aus.fehler.text, /ausverkauft/) +}) + +test('Bestand: gilt fuer die zusammengefasste Menge', () => { + const a = produkt('a', { bestand: 3 }) + const r = rechne({ produkte: karte(a), positionen: [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 2 }] }) + assert.equal(r.fehler.code, 'ausverkauft') +}) + +test('Reservierungen verringern den Bestand (Artikel und Variante)', () => { + const a = produkt('a', { bestand: 5 }) + const res = new Map([['a', 3]]) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 2), reserviert: res }).ok, true) + const r = rechne({ produkte: karte(a), positionen: eine(a, 3), reserviert: res }) + assert.equal(r.fehler.code, 'ausverkauft') + assert.match(r.fehler.text, /nur noch 2/) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a, 1), reserviert: new Map([['a', 7]]) }).fehler.code, 'ausverkauft') + + const vres = new Map([['shirt:sizeaaaa', 4]]) + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 1, 'sizeaaaa'), reserviert: vres }).ok, true) + const rv = rechne({ produkte: karte(shirt), positionen: eine(shirt, 2, 'sizeaaaa'), reserviert: vres }) + assert.equal(rv.fehler.code, 'ausverkauft') + assert.match(rv.fehler.text, /T-Shirt/) + assert.match(rv.fehler.text, /Größe: M/) + // unbegrenzte Variante bleibt unberuehrt, andere Schluessel stoeren nicht + assert.equal(rechne({ produkte: karte(shirt), positionen: eine(shirt, 50, 'sizebbbb'), reserviert: new Map([['shirt:sizebbbb', 99], ['shirt', 99]]) }).ok, true) +}) + +test('Variantenbestand wird je Variante geprueft', () => { + const r = rechne({ produkte: karte(shirt), positionen: eine(shirt, 6, 'sizeaaaa') }) + assert.equal(r.fehler.code, 'ausverkauft') + assert.match(r.fehler.text, /nur noch 5/) +}) + +test('Fehler lieferart: unbekannt, Versand aus, Abholung aus', () => { + const a = produkt('a') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'lieferart') + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: undefined }).fehler.code, 'lieferart') + const keinVersand = einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: keinVersand }).fehler.code, 'lieferart') + const keineAbholung = einstellungen({ abholung: { aktiv: false, adresse: '', hinweis: '' } }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: keineAbholung }).fehler.code, 'lieferart') + // Abholung geht, obwohl Versand aus ist + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: einstellungen({ versand: { aktiv: false, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) }).ok, true) +}) + +test('Fehler land: nicht geliefert, fehlend, ungueltig', () => { + const a = produkt('a') + const nurDe = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) + for (const land of ['AT', 'CH', '', undefined, null, 'de']) { + const r = rechne({ produkte: karte(a), positionen: eine(a), land, einstellungen: nurDe }) + assert.equal(r.fehler.code, 'land', String(land)) + } + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), land: 'AT' }).ok, true) +}) + +test('Fehler nur_abholung: Versand mit Nur-Abholung-Artikel', () => { + const a = produkt('a') + const abholer = produkt('abholer', { lieferart: 'abholung' }) + const r = rechne({ produkte: karte(a, abholer), positionen: [...eine(a), ...eine(abholer)] }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'nur_abholung') + assert.equal(r.fehler.produktId, 'abholer') + assert.match(r.fehler.text, /Artikel abholer/) +}) + +test('Fehler mindestbestellwert: Grenze gleich ist erlaubt, Versandkosten zaehlen nicht mit', () => { + const a = produkt('a', { preisCent: 1000 }) + const e = (cent) => einstellungen({ mindestbestellCent: cent }) + const r = rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1001) }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'mindestbestellwert') + assert.ok(r.fehler.text.includes(euro(1001))) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(1000) }).ok, true) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), einstellungen: e(null) }).ok, true) + // gilt auch bei Abholung + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: e(1001) }).fehler.code, 'mindestbestellwert') +}) + +test('Reihenfolge der Pruefungen: Artikelfehler vor Lieferart, Lieferart vor Mindestbestellwert', () => { + const a = produkt('a', { bestand: 0 }) + assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'post' }).fehler.code, 'ausverkauft') + const b = produkt('b') + assert.equal(rechne({ produkte: karte(b), positionen: eine(b), lieferart: 'post', einstellungen: einstellungen({ mindestbestellCent: 99999 }) }).fehler.code, 'lieferart') +}) + +test('Eingaben werden nicht veraendert und das Ergebnis teilt keine Referenzen', () => { + const a = produkt('a', { bestand: 10 }) + const positionen = [{ produktId: 'a', menge: 2 }, { produktId: 'a', menge: 1 }] + const e = einstellungen() + const vorher = JSON.stringify({ a, positionen, e }) + const r1 = rechne({ produkte: karte(a), positionen, einstellungen: e }) + assert.equal(JSON.stringify({ a, positionen, e }), vorher) + const r2 = rechne({ produkte: karte(a), positionen, einstellungen: e }) + assert.deepEqual(r1, r2) + assert.notEqual(r1.positionen, r2.positionen) +}) + +test('Gesamtsumme ist immer ganzzahlig und gleich Zwischensumme plus Versand', () => { + const a = produkt('a', { preisCent: 333, steuersatz: 7 }) + const b = produkt('b', { preisCent: 777, steuersatz: 19 }) + const r = rechne({ produkte: karte(a, b), positionen: [{ produktId: 'a', menge: 7 }, { produktId: 'b', menge: 3 }], land: 'AT' }) + assert.ok(Number.isInteger(r.gesamtCent)) + assert.equal(r.gesamtCent, r.zwischensummeCent + r.versandCent) + assert.equal(r.zwischensummeCent, 333 * 7 + 777 * 3) + const summe = r.steuer.reduce((s, x) => s + x.betragCent, 0) + assert.ok(summe < r.gesamtCent) +})