diff --git a/server/services/shop/geld.js b/server/services/shop/geld.js new file mode 100644 index 0000000..d2fcb5b --- /dev/null +++ b/server/services/shop/geld.js @@ -0,0 +1,106 @@ +/** + * Geld und Grundpreis fuer den Shop: reine Funktionen ohne Abhaengigkeiten. + * Geld ist immer eine ganze Zahl in Cent, nie Gleitkomma-Euro; Eingaben werden als Ziffernfolge gelesen, nicht gerechnet. + */ + +const NBSP = ' ' + +/** Obergrenze fuer eingegebene Preise: 100.000 € in Cent. */ +const MAX_CENT = 10_000_000 + +/** + * Formatiert ganze Cent als deutschen Eurobetrag: Tausenderpunkt, Dezimalkomma, immer zwei Nachkommastellen, + * geschuetztes Leerzeichen (U+00A0) vor dem Eurozeichen. Beispiel: 123456 => '1.234,56 €', -1250 => '-12,50 €'. + * Wirft einen TypeError, wenn cent keine ganze Zahl ist. + */ +export function euro(cent) { + if (!Number.isInteger(cent)) throw new TypeError('euro: cent muss eine ganze Zahl sein') + const neg = cent < 0 + const abs = Math.abs(cent) + const euros = String(Math.floor(abs / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + const rest = String(abs % 100).padStart(2, '0') + return `${neg ? '-' : ''}${euros},${rest}${NBSP}€` +} + +/** + * Liest einen Eurobetrag und liefert ganze Cent, sonst null. + * Erlaubt: Zahl in Euro (12.5), Text wie '12', '12,5', '12,50 €', '1.234,56', '1234.56'. + * Mit Komma sind Punkte nur Tausendertrenner. Ohne Komma ist ein einzelner Punkt mit 1-2 Ziffern dahinter das Dezimaltrennzeichen, + * mit genau 3 Ziffern dahinter ein Tausenderpunkt ('1.234' = 1234 €). + * null bei leer, negativ, keine Zahl, mehr als zwei Nachkommastellen oder ueber 100.000 €. + */ +export function parseEuro(value) { + let s + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null + s = String(value) + // Zahlen nur als schlichte Dezimalzahl (kein Exponent, keine Tausendergruppen) + if (!/^\d+(\.\d{1,2})?$/.test(s)) return null + return zuCent(s, '.') + } + if (typeof value !== 'string') return null + s = value.replace(/€/g, '').trim() // trim entfernt auch geschuetzte Leerzeichen; Leerzeichen mitten in der Zahl sind ungueltig + if (s === '') return null + if (s.includes(',')) { + const m = /^(\d{1,3}(?:\.\d{3})+|\d+),(\d{1,2})$/.exec(s) + if (!m) return null + return zuCent(`${m[1].replace(/\./g, '')}.${m[2]}`, '.') + } + if (/^\d+$/.test(s)) return zuCent(s, '.') + if (/^\d+\.\d{1,2}$/.test(s)) return zuCent(s, '.') + if (/^\d{1,3}(?:\.\d{3})+$/.test(s)) return zuCent(s.replace(/\./g, ''), '.') + return null +} + +/** Wandelt eine geprueft schlichte Dezimalzahl ('12.5') per Ziffernarithmetik in Cent; null ueber der Obergrenze. */ +function zuCent(text, trenner) { + const [ganz, nach = ''] = text.split(trenner) + const cent = Number(ganz) * 100 + Number(nach.padEnd(2, '0')) + if (!Number.isSafeInteger(cent) || cent > MAX_CENT) return null + return cent +} + +/** Unterstuetzte Einheiten: Anzeige-Einheit der Bezugsmenge und Faktor von der Eingabeeinheit auf diese. */ +const EINHEITEN = Object.freeze({ + g: { anzeige: 'kg', faktor: 1000 }, + kg: { anzeige: 'kg', faktor: 1 }, + ml: { anzeige: 'l', faktor: 1000 }, + l: { anzeige: 'l', faktor: 1 }, + m: { anzeige: 'm', faktor: 1 }, + m2: { anzeige: 'm²', faktor: 1 }, +}) + +/** Nachkommastellen der Menge, die exakt beruecksichtigt werden (0,75 l, 0,333 kg ...). */ +const MENGE_STELLEN = 6n + +/** + * Grundpreis nach PAngV, z. B. '(16,67 € / 1 kg)'. Bezug ist 1 kg, 1 l, 1 m oder 1 m² (g => kg, ml => l). + * einheit: g | kg | ml | l | m | m2; menge: positive Zahl (auch Dezimal, z. B. 0.75). + * Rundung kaufmaennisch (halb auf) auf ganze Cent, mit BigInt gerechnet, damit keine Gleitkommafehler entstehen. + * Fehlen Menge, Einheit oder Preis oder sind sie ungueltig, kommt '' zurueck. + */ +export function grundpreisText(angaben) { + const { preisCent, menge, einheit } = angaben ?? {} + const e = typeof einheit === 'string' && Object.hasOwn(EINHEITEN, einheit) ? EINHEITEN[einheit] : null + if (!e) return '' + if (!Number.isInteger(preisCent) || preisCent < 0) return '' + const m = mengeSkaliert(menge) + if (m === null) return '' + // Cent je Bezugsmenge = preisCent * faktor / menge, menge = m / 10^6 + const zaehler = BigInt(preisCent) * BigInt(e.faktor) * 10n ** MENGE_STELLEN + const cent = (2n * zaehler + m) / (2n * m) + if (cent > BigInt(Number.MAX_SAFE_INTEGER)) return '' + return `(${euro(Number(cent))} / 1 ${e.anzeige})` +} + +/** Menge (Zahl oder Dezimaltext) als BigInt mit 6 Nachkommastellen; null bei <= 0 oder ungueltig. */ +function mengeSkaliert(menge) { + let n + if (typeof menge === 'number') n = menge + else if (typeof menge === 'string' && /^\d+(\.\d+)?$/.test(menge.trim())) n = Number(menge.trim()) + else return null + if (!Number.isFinite(n) || n <= 0) return null + const skaliert = Math.round(n * 10 ** Number(MENGE_STELLEN)) + if (!Number.isSafeInteger(skaliert) || skaliert <= 0) return null + return BigInt(skaliert) +} diff --git a/test/shopGeld.test.js b/test/shopGeld.test.js new file mode 100644 index 0000000..fa16489 --- /dev/null +++ b/test/shopGeld.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { euro, parseEuro, grundpreisText } from '../server/services/shop/geld.js' + +const NB = ' ' + +test('euro formatiert ganze Cent deutsch mit geschuetztem Leerzeichen', () => { + assert.equal(euro(123456), `1.234,56${NB}€`) + assert.equal(euro(0), `0,00${NB}€`) + assert.equal(euro(5), `0,05${NB}€`) + assert.equal(euro(1250), `12,50${NB}€`) + assert.equal(euro(100), `1,00${NB}€`) + assert.equal(euro(99999999), `999.999,99${NB}€`) + assert.equal(euro(100000000), `1.000.000,00${NB}€`) + assert.equal(euro(-1250), `-12,50${NB}€`) + assert.equal(euro(-5), `-0,05${NB}€`) +}) + +test('euro lehnt Nicht-Ganzzahlen ab', () => { + for (const v of [12.5, NaN, Infinity, '1250', null, undefined, {}]) { + assert.throws(() => euro(v), TypeError) + } +}) + +test('parseEuro: gueltige Eingaben', () => { + const faelle = [ + [12.5, 1250], [12, 1200], [0, 0], [19.99, 1999], [0.1, 10], [1.005, null], + ['12', 1200], ['12,5', 1250], ['12,50', 1250], ['12,50 €', 1250], [`12,50${NB}€`, 1250], + [' 12,50 ', 1250], ['€ 12,50', 1250], ['1.234,56', 123456], ['1.234.567,00', null], + ['1234,56', 123456], ['1234.56', 123456], ['12.5', 1250], ['1.23', 123], ['1.234', 123400], + ['12.500', 1250000], ['0,99', 99], ['0', 0], ['007,5', 750], ['1000', 100000], + ['100000', 10000000], ['100.000,00', 10000000], ['12.345,6', 1234560], ['99999,99', 9999999], + ] + for (const [eingabe, cent] of faelle) assert.equal(parseEuro(eingabe), cent, `parseEuro(${JSON.stringify(eingabe)})`) +}) + +test('parseEuro: ungueltige Eingaben ergeben null', () => { + const faelle = [ + '', ' ', '€', 'abc', '12,5x', '-5', '-0,50', -5, NaN, Infinity, null, undefined, {}, [], + '12,345', '1.2345', '1234.567', '12,', ',50', '1,2,3', '1..234,00', '1.23.456', + '100000,01', 100000.01, '100001', 1e21, 0.30000000000000004, '12 50', '1 234,56', '1e3', + ] + for (const eingabe of faelle) assert.equal(parseEuro(eingabe), null, `parseEuro(${JSON.stringify(eingabe)})`) +}) + +test('grundpreisText: Bezugsmengen und Format', () => { + assert.equal(grundpreisText({ preisCent: 1250, menge: 750, einheit: 'g' }), `(16,67${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1250, menge: 1, einheit: 'kg' }), `(12,50${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 299, menge: 500, einheit: 'ml' }), `(5,98${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 299, menge: 0.75, einheit: 'l' }), `(3,99${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 500, menge: 2, einheit: 'm' }), `(2,50${NB}€ / 1 m)`) + assert.equal(grundpreisText({ preisCent: 1000, menge: 4, einheit: 'm2' }), `(2,50${NB}€ / 1 m²)`) +}) + +test('grundpreisText: kaufmaennische Rundung ohne Gleitkommafehler', () => { + // 1 / 8 = 0,125 -> 0,13 (halb auf) + assert.equal(grundpreisText({ preisCent: 1, menge: 8, einheit: 'kg' }), `(0,00${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 5, menge: 8, einheit: 'kg' }), `(0,01${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1005, menge: 2, einheit: 'kg' }), `(5,03${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 1001, menge: 2, einheit: 'l' }), `(5,01${NB}€ / 1 l)`) + assert.equal(grundpreisText({ preisCent: 333, menge: 3, einheit: 'm' }), `(1,11${NB}€ / 1 m)`) + assert.equal(grundpreisText({ preisCent: 1, menge: 1000, einheit: 'g' }), `(0,01${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 10, menge: 0.1, einheit: 'kg' }), `(1,00${NB}€ / 1 kg)`) + assert.equal(grundpreisText({ preisCent: 3, menge: 0.3, einheit: 'l' }), `(0,10${NB}€ / 1 l)`) +}) + +test('grundpreisText: fehlende oder ungueltige Angaben ergeben leeren Text', () => { + const ok = { preisCent: 1250, menge: 750, einheit: 'g' } + assert.equal(grundpreisText(), '') + assert.equal(grundpreisText(null), '') + assert.equal(grundpreisText({}), '') + for (const aend of [ + { menge: 0 }, { menge: -1 }, { menge: NaN }, { menge: Infinity }, { menge: '' }, { menge: null }, { menge: undefined }, { menge: 'abc' }, + { einheit: 'stk' }, { einheit: 'KG' }, { einheit: '' }, { einheit: undefined }, + { preisCent: 12.5 }, { preisCent: -1 }, { preisCent: undefined }, { preisCent: '1250' }, + ]) { + assert.equal(grundpreisText({ ...ok, ...aend }), '', JSON.stringify(aend)) + } +})