diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index 43d9d47..638f958 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -466,7 +466,7 @@ const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/ const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' }) // Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code) -const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich']) +const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'mindestbetrag', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich']) const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung']) /** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */ diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index dda288f..2992cbd 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -257,6 +257,7 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date(), ipHash const geschlossen = () => fehler(409, 'geschlossen', GESCHLOSSEN_TEXT) if (!shop.stripeAccountId) throw geschlossen() const jetzt = new Date(now) + const start = performance.now() const { positionen, lieferart, land } = istObjekt(eingabe) ? eingabe : {} // 1. Pruefen, rechnen und reservieren: alles unter einer Sperre je Shop, damit der letzte Artikel nur einmal weggeht @@ -329,7 +330,18 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date(), ipHash }) // 2. Kasse bei Stripe starten (ausserhalb der Sperre: die offene Bestellung haelt den Bestand schon) - const { orderId, token, bestellnummer, daten, ergebnis, produkte, sessionEnde } = angelegt + const { orderId, token, bestellnummer, daten, ergebnis, produkte } = angelegt + // Ende der Sitzung direkt vor dem Aufruf nachfuehren: die Zeit seit Beginn (Warten auf Sperren, Lesen) ganzer Sekunden schiebt es + // weiter, damit Stripes Minimum von 30 Minuten sicher bleibt (30 s Reserve decken den Rest); ablaufAt folgt demselben Wert + const sessionEnde = angelegt.sessionEnde + Math.floor((performance.now() - start) / 1000) + if (sessionEnde !== angelegt.sessionEnde) { + try { + await updateDocument(col(), orderId, { ablaufAt: new Date(sessionEnde * 1000).toISOString() }) + } catch { + await storniere(orderId, daten, 'stripe_fehler', jetzt).catch(() => {}) + throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT) + } + } const rueck = pruefeRueckUrl(eingabe?.rueckUrl, ctx) const metadata = { wkShop: '1', shopId: shop.id, orderId, bestellnummer } const parameter = (zahlungsarten) => ({ diff --git a/server/services/shop/preise.js b/server/services/shop/preise.js index 67c126c..b1a9faa 100644 --- a/server/services/shop/preise.js +++ b/server/services/shop/preise.js @@ -11,6 +11,8 @@ 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. */ @@ -146,6 +148,7 @@ export function berechne({ produkte, positionen, lieferart, land, einstellungen, 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 = [] diff --git a/test/shopBestellungen.test.js b/test/shopBestellungen.test.js index 381785e..be674c9 100644 --- a/test/shopBestellungen.test.js +++ b/test/shopBestellungen.test.js @@ -25,6 +25,7 @@ after(async () => { useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') +const { withLocks } = await import('../server/services/keyedLock.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const K = await import('../server/services/shop/kontext.js') const B = await import('../server/services/shop/bestellungen.js') @@ -138,6 +139,24 @@ test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nei assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY) }) +test('M9: Wartezeit auf die Bestandssperre verkuerzt die Sitzungsdauer nicht - expires_at und ablaufAt werden vor dem Stripe-Aufruf nachgefuehrt', async () => { + const ctx = await aufbau() + let los + const gehalten = new Promise((r) => (los = r)) + const sperre = withLocks([`shop-bestand:${FEATURE}`], () => gehalten) + const lauf = B.legeBestellungAn(ctx, eingabe(), { now: T0 }) + await new Promise((r) => setTimeout(r, 2100)) + los() + await sperre + await lauf + const [call] = sessionCalls() + const ende = Number(call.body.expires_at) + const grundwert = Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30 + assert.ok(ende >= grundwert + 2, `expires_at ${ende} soll mindestens 2 s nach ${grundwert} liegen`) + assert.ok(ende <= grundwert + 6) + assert.equal(new Date(orders()[0].ablaufAt).getTime(), ende * 1000, 'ablaufAt = Ende der Stripe-Sitzung') +}) + test('Normalfall: Bestellung offen, Stripe-Kasse auf dem Haendlerkonto mit allen Parametern', async () => { const ctx = await aufbau() const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }) diff --git a/test/shopPreise.test.js b/test/shopPreise.test.js index fd6e4ff..c4a5ad8 100644 --- a/test/shopPreise.test.js +++ b/test/shopPreise.test.js @@ -67,14 +67,16 @@ test('gemischte Steuersaetze: Text mit absteigenden Saetzen, 0 % liefert keinen 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 fueller = produkt('fueller', { preisCent: 100, steuersatz: 0 }) // hebt den Gesamtbetrag ueber 0,50 EUR, ohne Steuer const e = einstellungen({ abholung: { aktiv: true, adresse: 'x', hinweis: '' } }) - const r = berechne({ produkte: karte(a), positionen: eine(a, 3), lieferart: 'abholung', einstellungen: e }) + const r = berechne({ produkte: karte(a, fueller), positionen: [...eine(a, 3), ...eine(fueller)], 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() }) + const fueller = produkt('fueller', { preisCent: 100, steuersatz: 0 }) // Gesamtbetrag mindestens 0,50 EUR, selbst ohne Steuer + const abh = (p, n = 1) => berechne({ produkte: karte(p, fueller), positionen: [...eine(p, n), ...eine(fueller)], 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, []) @@ -348,6 +350,21 @@ test('Fehler mindestbestellwert: Grenze gleich ist erlaubt, Versandkosten zaehle assert.equal(rechne({ produkte: karte(a), positionen: eine(a), lieferart: 'abholung', einstellungen: e(1001) }).fehler.code, 'mindestbestellwert') }) +test('Fehler mindestbetrag: Gesamtbetrag unter 0,50 EUR (Stripe-Minimum) wird abgelehnt, 0,50 EUR genau ist erlaubt; Versandkosten zaehlen mit', () => { + const klein = produkt('klein', { preisCent: 30 }) + const r = rechne({ produkte: karte(klein), positionen: eine(klein), lieferart: 'abholung' }) + assert.equal(r.ok, false) + assert.equal(r.fehler.code, 'mindestbetrag') + assert.equal(r.fehler.text, 'Der Bestellwert muss mindestens 0,50 € betragen.') + const genau = produkt('genau', { preisCent: 50 }) + assert.equal(rechne({ produkte: karte(genau), positionen: eine(genau), lieferart: 'abholung' }).ok, true) + const knapp = produkt('knapp', { preisCent: 49 }) + assert.equal(rechne({ produkte: karte(knapp), positionen: eine(knapp), lieferart: 'abholung' }).fehler.code, 'mindestbetrag') + // mit Versandkosten ueber der Grenze + const e = einstellungen({ versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null } }) + assert.equal(rechne({ produkte: karte(klein), positionen: eine(klein), lieferart: 'versand', land: 'DE', einstellungen: e }).ok, true) +}) + 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')