From 9d19a2cbadecf6f1a5f986da849c0b3da2c148a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:48:56 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20E1:=20Bestellung=20anlegen=20(Reservieru?= =?UTF-8?q?ng=20unter=20Sperre,=20Stripe-Kasse=20auf=20dem=20H=C3=A4ndlerk?= =?UTF-8?q?onto,=20R=C3=BCck-URL-Pr=C3=BCfung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/bestellungen.js | 359 +++++++++++++++++++++++ test/shopBestellungen.test.js | 416 +++++++++++++++++++++++++++ 2 files changed, 775 insertions(+) create mode 100644 server/services/shop/bestellungen.js create mode 100644 test/shopBestellungen.test.js diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js new file mode 100644 index 0000000..ec17f98 --- /dev/null +++ b/server/services/shop/bestellungen.js @@ -0,0 +1,359 @@ +import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { analyseDomains } from '../analyse.js' +import { withLocks } from '../keyedLock.js' +import { checkoutAnlegen } from './connect.js' +import { istGeoeffnet, leereCache } from './kontext.js' +import { berechne } from './preise.js' +import { listeProdukte } from './produkte.js' + +/** + * Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung). + * + * Datenlage einer Bestellung (shopOrders): Spalten shopId, customerId, bestellnummer, status, tokenHash, stripeSessionId, + * paymentIntentId, summeCent, erstelltAt, ablaufAt, bezahltAt, kaeuferEmail, bestellung (JSON-Text, <= 60000), anonymisiertAt. + * bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen): + * { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname, + * lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] } + * E2 ergaenzt kaeufer / lieferadresse / erstattungen[] / hinweise im selben JSON. Status offen reserviert Bestand bis ablaufAt. + * Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message. + */ + +const NUMMER_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const NUMMER_VERSUCHE = 5 +/** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */ +const SESSION_RESERVE_S = 30 +const MAX_JSON = 60000 + +const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' +const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.' +const KLARNA_HINWEIS = + 'Klarna/SEPA ist in Deinem Stripe-Konto nicht aktiviert. Bestellungen wurden deshalb nur mit Karte angeboten. ' + + 'Bitte aktiviere die Zahlungsarten in Deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.' + +const col = () => config.collections.shopOrders +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +function fehler(status, code, text, extra = {}) { + return Object.assign(new Error(text), { status, code, ...extra }) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Hilfen + +/** Gespeicherten JSON-Text einer Bestellung lesen; bei kaputten Daten ein leeres Objekt. */ +export function bestellDaten(doc) { + try { + const d = JSON.parse(doc?.bestellung) + return istObjekt(d) ? d : {} + } catch { + return {} + } +} + +/** Bestellung per ID: Dokument mit zusaetzlichem Feld daten (geparstes bestellung) oder null, wenn es sie nicht gibt. */ +export async function bestellungLesen(orderId) { + if (typeof orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(orderId)) return null + let doc + try { + doc = await getDocument(col(), orderId) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null + throw err + } + return { ...doc, daten: bestellDaten(doc) } +} + +export const tokenHash = (token) => createHash('sha256').update(String(token ?? '')).digest('hex') + +/** Passt der Token aus der Rueckkehr-Adresse zur Bestellung? Vergleich in konstanter Zeit ueber den SHA-256-Hash. */ +export function tokenPasst(order, token) { + if (typeof token !== 'string' || token === '' || typeof order?.tokenHash !== 'string' || order.tokenHash === '') return false + const a = Buffer.from(tokenHash(token), 'hex') + const b = Buffer.from(order.tokenHash, 'hex') + return a.length === b.length && timingSafeEqual(a, b) +} + +const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId) + +/** + * Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft). + * @returns {Promise>} Schluessel produktId bzw. `${produktId}:${varianteId}` + */ +export async function reserviert(shopId, now = new Date()) { + const nowMs = new Date(now).getTime() + const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', 'offen')]) + const karte = new Map() + for (const doc of docs) { + const ablauf = new Date(doc.ablaufAt).getTime() + if (!(ablauf > nowMs)) continue + const positionen = bestellDaten(doc).positionen + if (!Array.isArray(positionen)) continue + for (const pos of positionen) { + if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue + const key = schluesselFuer(pos) + karte.set(key, (karte.get(key) ?? 0) + pos.menge) + } + } + return karte +} + +const berlinJahr = (now) => + new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(now).slice(-2) + +/** Bestellnummer `-<6 Zeichen>` (JJ = Jahr in Berlin; Zeichen ohne 0, 1, O, I). Eindeutigkeit prueft der Aufrufer. */ +export function neueBestellnummer(now = new Date()) { + let zeichen = '' + for (let i = 0; i < 6; i += 1) zeichen += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)] + return `${berlinJahr(now)}-${zeichen}` +} + +async function freieBestellnummer(shopId, now) { + for (let i = 0; i < NUMMER_VERSUCHE; i += 1) { + const nummer = neueBestellnummer(now) + const treffer = await listDocuments(col(), [Query.equal('shopId', shopId), Query.equal('bestellnummer', nummer), Query.limit(1)]) + if (treffer.length === 0) return nummer + } + throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT) +} + +/** + * Rueck-URL fuer success_url/cancel_url: https (http nur, wenn die Portal-Adresse selbst http ist, also in Tests), Host aus + * analyseDomains(project) oder Host der Portal-Adresse, ohne Zugangsdaten. Entfernt wk_*-Parameter und Fragment. Sonst + * `/shop/`. + */ +export function pruefeRueckUrl(rueckUrl, ctx) { + const basis = String(config.publicBaseUrl).replace(/\/$/, '') + const ausweich = `${basis}/shop/${ctx?.shop?.siteKey ?? ''}` + if (typeof rueckUrl !== 'string' || rueckUrl === '' || rueckUrl.length > 2000) return ausweich + let url + let portal = null + try { + url = new URL(rueckUrl) + } catch { + return ausweich + } + try { + portal = new URL(basis) + } catch { + portal = null + } + if (url.username || url.password) return ausweich + const portalHost = portal ? portal.host.toLowerCase() : '' + const istPortal = portal !== null && url.host.toLowerCase() === portalHost && url.protocol === portal.protocol + const betrieb = ctx?.project ? analyseDomains(ctx.project) : [] + const istBetrieb = url.protocol === 'https:' && url.port === '' && betrieb.includes(url.hostname.toLowerCase()) + if (!istPortal && !istBetrieb) return ausweich + if (url.protocol !== 'https:' && !(portal?.protocol === 'http:' && istPortal)) return ausweich + for (const key of [...url.searchParams.keys()]) if (key.startsWith('wk_')) url.searchParams.delete(key) + url.hash = '' + return url.toString() +} + +const mitParametern = (basis, params) => { + const url = new URL(basis) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + return url.toString() +} + +/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON. */ +function produktKarte(docs) { + const karte = new Map() + for (const doc of docs) { + let p + try { + p = JSON.parse(doc.produkt) + } catch { + continue + } + if (!istObjekt(p)) continue + karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false }) + } + return karte +} + +function stripeProduktName(pos, produkte) { + if (!pos.varianteId) return pos.name + const wert = produkte.get(pos.produktId)?.varianten?.werte?.find((w) => w.id === pos.varianteId) + return `${pos.name} – ${wert?.name ?? pos.varianteText}` +} + +function versandName(lieferart, land) { + if (lieferart === 'abholung') return 'Abholung' + return land === 'AT' ? 'Versand nach Österreich' : 'Versand nach Deutschland' +} + +/** Absolute https-Bild-URL des ersten Bildes, sonst undefined (Stripe holt Bilder selbst; http oder lokal geht nicht). */ +function bildUrl(produkt, siteKey) { + const id = Array.isArray(produkt?.bilder) ? produkt.bilder[0] : undefined + if (typeof id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(id)) return undefined + const basis = String(config.publicBaseUrl).replace(/\/$/, '') + if (!basis.startsWith('https://')) return undefined + return `${basis}/api/public/shop/v1/${encodeURIComponent(siteKey)}/bild/${id}-l.webp` +} + +// --------------------------------------------------------------------------------------------------------------------- +// Bestellung anlegen + +/** Stripe lehnt Klarna/SEPA ab, weil das Konto sie nicht aktiviert hat. */ +function zahlartAbgelehnt(err) { + if (err?.type !== 'StripeInvalidRequestError') return false + return /payment_method_types|klarna|sepa/i.test(`${err.param ?? ''} ${err.message ?? ''}`) +} + +async function merkeHinweis(ctx) { + const shopId = ctx.shop.id + await withLocks([`shop-cfg:${shopId}`], async () => { + const doc = await getDocument(config.collections.shopConfig, shopId) + let status = {} + try { + const s = JSON.parse(doc.stripeStatus) + if (istObjekt(s)) status = s + } catch { + status = {} + } + await updateDocument(config.collections.shopConfig, shopId, { stripeStatus: JSON.stringify({ ...status, hinweis: KLARNA_HINWEIS }) }) + }) + leereCache(ctx.shop.siteKey) +} + +async function storniere(orderId, daten, was, now) { + const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: new Date(now).toISOString(), was }] + await updateDocument(col(), orderId, { status: 'storniert', bestellung: JSON.stringify({ ...daten, verlauf }) }) +} + +/** + * Legt eine offene Bestellung an und startet die Stripe-Kasse auf dem Konto des Haendlers. + * Preise, Bestand und Versand kommen immer aus der Datenbank; der Browser liefert nur Artikel, Mengen, Lieferart und Land. + * @param {object} ctx Kontext aus shopPerSiteKey + * @param {{ positionen: Array, lieferart: string, land?: string, rueckUrl?: string }} eingabe + * @param {{ now?: Date }} [opt] + * @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>} + */ +export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) { + const { shop } = ctx + const e = shop.einstellungen + const geschlossen = () => fehler(409, 'geschlossen', GESCHLOSSEN_TEXT) + if (!shop.stripeAccountId) throw geschlossen() + const jetzt = new Date(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 + const angelegt = await withLocks([`shop-bestand:${shop.id}`], async () => { + const docs = await listeProdukte(shop.id) + if (!(await istGeoeffnet(ctx, { produkte: docs }))) throw geschlossen() + const produkte = produktKarte(docs) + const ergebnis = berechne({ + produkte, + positionen, + lieferart, + land, + einstellungen: e, + reserviert: await reserviert(shop.id, jetzt), + }) + if (!ergebnis.ok) { + const { code, text, produktId } = ergebnis.fehler + const konflikt = code === 'ausverkauft' || code === 'nicht_verfuegbar' || code === 'nur_abholung' + throw fehler(konflikt ? 409 : 400, code, text, produktId ? { produktId } : {}) + } + + const orderId = randomUUID() + const token = randomBytes(32).toString('base64url') + const bestellnummer = await freieBestellnummer(shop.id, jetzt) + const sessionEnde = Math.ceil(jetzt.getTime() / 1000) + config.shop.bestellAblaufMinuten * 60 + SESSION_RESERVE_S + const daten = { + positionen: ergebnis.positionen, + summen: { + zwischensummeCent: ergebnis.zwischensummeCent, + versandCent: ergebnis.versandCent, + gesamtCent: ergebnis.gesamtCent, + steuer: ergebnis.steuer, + texte: ergebnis.texte, + }, + lieferart: ergebnis.lieferart, + land: ergebnis.land, + anbieter: e.anbieter, + shopname: e.shopname, + lieferzeit: e.lieferzeit, + zahlungsarten: e.zahlungsarten, + ...(ergebnis.lieferart === 'abholung' ? { abholung: { adresse: e.abholung.adresse, hinweis: e.abholung.hinweis } } : {}), + rechtstexteHash: shop.rechtstexte.bestaetigt.hash, + verlauf: [{ at: jetzt.toISOString(), was: 'angelegt' }], + } + const json = JSON.stringify(daten) + if (json.length > MAX_JSON) throw fehler(400, 'zu_viele', 'Ihr Warenkorb enthält zu viele verschiedene Artikel.') + await createDocument( + col(), + { + shopId: shop.id, + customerId: shop.customerId, + bestellnummer, + status: 'offen', + tokenHash: tokenHash(token), + summeCent: ergebnis.gesamtCent, + erstelltAt: jetzt.toISOString(), + ablaufAt: new Date(sessionEnde * 1000).toISOString(), + bestellung: json, + }, + orderId + ) + return { orderId, token, bestellnummer, daten, ergebnis, produkte, sessionEnde } + }) + + // 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 rueck = pruefeRueckUrl(eingabe?.rueckUrl, ctx) + const metadata = { wkShop: '1', shopId: shop.id, orderId, bestellnummer } + const parameter = (zahlungsarten) => ({ + mode: 'payment', + line_items: ergebnis.positionen.map((pos) => { + const bild = bildUrl(produkte.get(pos.produktId), shop.siteKey) + return { + quantity: pos.menge, + price_data: { + currency: 'eur', + unit_amount: pos.einzelCent, + product_data: { name: stripeProduktName(pos, produkte), ...(bild ? { images: [bild] } : {}) }, + }, + } + }), + shipping_options: [ + { + shipping_rate_data: { + type: 'fixed_amount', + fixed_amount: { amount: ergebnis.versandCent, currency: 'eur' }, + display_name: versandName(ergebnis.lieferart, ergebnis.land), + }, + }, + ], + ...(ergebnis.lieferart === 'versand' ? { shipping_address_collection: { allowed_countries: [ergebnis.land] } } : {}), + billing_address_collection: 'required', + phone_number_collection: { enabled: true }, + payment_method_types: zahlungsarten, + locale: 'de', + expires_at: sessionEnde, + client_reference_id: orderId, + metadata, + payment_intent_data: { metadata, description: `Bestellung ${bestellnummer} – ${e.shopname}` }, + success_url: mitParametern(rueck, { wk_bestellung: orderId, wk_t: token }), + cancel_url: mitParametern(rueck, { wk_abbruch: '1' }), + }) + + let session + try { + try { + session = await checkoutAnlegen(shop.stripeAccountId, parameter(e.zahlungsarten), { idempotencyKey: `shop-order-${orderId}` }) + } catch (err) { + if (!zahlartAbgelehnt(err) || e.zahlungsarten.every((z) => z === 'card')) throw err + session = await checkoutAnlegen(shop.stripeAccountId, parameter(['card']), { idempotencyKey: `shop-order-${orderId}-card` }) + await merkeHinweis(ctx).catch(() => {}) + } + if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || session.url === '') throw new Error('Stripe lieferte keine Kasse') + } catch { + await storniere(orderId, daten, 'stripe_fehler', jetzt).catch(() => {}) + throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT) + } + await updateDocument(col(), orderId, { stripeSessionId: session.id }) + return { url: session.url, orderId, bestellnummer } +} diff --git a/test/shopBestellungen.test.js b/test/shopBestellungen.test.js new file mode 100644 index 0000000..d987d31 --- /dev/null +++ b/test/shopBestellungen.test.js @@ -0,0 +1,416 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Bestellung anlegen (E1): Berechnung nur aus der Datenbank, Reservierung unter Sperre, Stripe-Parameter auf dem Haendlerkonto, +// Rueck-URL-Pruefung, Fehlerwege (geschlossen, ausverkauft, Stripe-Fehler, Klarna nicht freigeschaltet). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + }, + datetimes: SHOP_DATETIMES, +}) +const st = await startFakeStripe() +after(async () => { + await aw.close() + await st.close() +}) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.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') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') + +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const ACCOUNT = 'acct_haendler1' +const T0 = new Date('2026-10-05T12:00:00.000Z') +const nach = (s) => new Date(T0.getTime() + s * 1000) +const PORTAL = 'https://project.webklar.com' + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, + zahlungsarten: ['card'], +} +const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } +const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) + +const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true } +const shopDoc = (over = {}, einstellungen = EINSTELLUNGEN) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: ACCOUNT, + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(einstellungen), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), + geoeffnet: true, + version: 1, + ...over, +}) +const artikel = (id, produktEingabe = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...produktEingabe }, { einstellungen: { kleinunternehmer: true } }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) } +} + +async function aufbau({ shop = {}, einstellungen = EINSTELLUNGEN, produkte = [artikel('a1')], projekt = {} } = {}) { + for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects']) { + for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + } + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example', ...projekt }) + aw.seed('portalFeatures', featureDoc) + aw.seed('shopConfig', shopDoc(shop, einstellungen)) + for (const p of produkte) aw.seed('shopProducts', p) + st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + K.leereCache() + return K.shopPerSiteKey(SITE_KEY, { now: T0 }) +} +beforeEach(() => { + st.calls.length = 0 + config.publicBaseUrl = PORTAL + K.leereCache() +}) + +const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST') +const orders = () => [...aw.docs('shopOrders').values()] +const eingabe = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', rueckUrl: 'https://www.brot.example/laden?x=1#warenkorb', ...over }) +const rejectsMit = (promise, status, code) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, err.message) + assert.equal(err.code, code) + return true + }) + +test('neueBestellnummer: JJ-XXXXXX ohne 0, 1, O, I; Jahr nach Berliner Zeit', () => { + for (let i = 0; i < 200; i += 1) assert.match(B.neueBestellnummer(T0), /^26-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/) + // Silvester 23:30 UTC ist in Berlin schon Neujahr + assert.match(B.neueBestellnummer(new Date('2026-12-31T23:30:00.000Z')), /^27-/) + assert.match(B.neueBestellnummer(new Date('2026-12-31T22:30:00.000Z')), /^26-/) +}) + +test('tokenPasst: nur der passende Token, kein Hash als Token', () => { + const token = 'abc123' + const order = { tokenHash: B.tokenHash(token) } + assert.equal(B.tokenPasst(order, token), true) + assert.equal(B.tokenPasst(order, 'abc124'), false) + assert.equal(B.tokenPasst(order, order.tokenHash), false) + assert.equal(B.tokenPasst(order, ''), false) + assert.equal(B.tokenPasst(order, undefined), false) + assert.equal(B.tokenPasst({}, token), false) + assert.equal(B.tokenPasst({ tokenHash: '' }, ''), false) +}) + +test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nein; wk_* und Fragment fallen weg', async () => { + const ctx = await aufbau() + const fallback = `${PORTAL}/shop/${SITE_KEY}` + const p = (u) => B.pruefeRueckUrl(u, ctx) + assert.equal(p('https://www.brot.example/laden?x=1&wk_t=alt&wk_abbruch=1#top'), 'https://www.brot.example/laden?x=1') + assert.equal(p('https://brot.example/'), fallback) // Apex nur, wenn das Projekt weitere Hosts hat (analyseDomains) + assert.equal(p(`${PORTAL}/shop/${SITE_KEY}/seite`), `${PORTAL}/shop/${SITE_KEY}/seite`) + for (const schlecht of ['https://boese.example/laden', 'http://www.brot.example/laden', 'https://www.brot.example.boese.example/', 'https://user:pw@www.brot.example/', 'https://www.brot.example:8443/', 'javascript:alert(1)', '//boese.example', '/relativ', '', undefined, null, 42, 'x'.repeat(3000)]) { + assert.equal(p(schlecht), fallback, String(schlecht).slice(0, 40)) + } + // Ohne Projekt gilt nur das Portal + assert.equal(B.pruefeRueckUrl('https://www.brot.example/', { ...ctx, project: null }), fallback) + // Portal-Adresse http (Tests): http auf dem Portal-Host erlaubt, auf fremden Hosts nicht + config.publicBaseUrl = 'http://127.0.0.1:4000' + assert.equal(B.pruefeRueckUrl('http://127.0.0.1:4000/shop/x?wk_t=1', ctx), 'http://127.0.0.1:4000/shop/x') + assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY) +}) + +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 }) + assert.match(r.bestellnummer, /^26-[A-Z2-9]{6}$/) + assert.match(r.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/) + + const [order] = orders() + assert.equal(order.$id, r.orderId) + assert.equal(order.status, 'offen') + assert.equal(order.bestellnummer, r.bestellnummer) + assert.equal(order.shopId, FEATURE) + assert.equal(order.customerId, KUNDE) + assert.equal(order.summeCent, 2 * 1250 + 490) + assert.equal(new Date(order.erstelltAt).toISOString(), T0.toISOString()) + assert.equal(new Date(order.ablaufAt).getTime(), T0.getTime() + (30 * 60 + 30) * 1000) + assert.match(order.stripeSessionId, /^cs_/) + assert.match(order.tokenHash, /^[0-9a-f]{64}$/) + const daten = JSON.parse(order.bestellung) + assert.equal(daten.lieferart, 'versand') + assert.equal(daten.land, 'DE') + assert.equal(daten.positionen[0].menge, 2) + assert.deepEqual({ z: daten.summen.zwischensummeCent, v: daten.summen.versandCent, g: daten.summen.gesamtCent }, { z: 2500, v: 490, g: 2990 }) + assert.equal(daten.anbieter.name, 'Brot & Mehr GmbH') + assert.equal(daten.shopname, 'Brot & Mehr') + assert.equal(daten.lieferzeit, EINSTELLUNGEN.lieferzeit) + assert.deepEqual(daten.zahlungsarten, ['card']) + assert.equal(daten.rechtstexteHash, HASH) + assert.equal(daten.abholung, undefined) + assert.equal(daten.verlauf.length, 1) + assert.equal(daten.verlauf[0].was, 'angelegt') + + const [call] = sessionCalls() + assert.equal(sessionCalls().length, 1) + assert.equal(call.account, ACCOUNT) + assert.equal(call.idempotencyKey, `shop-order-${r.orderId}`) + const b = call.body + assert.equal(b.mode, 'payment') + assert.equal(b['line_items[0][quantity]'], '2') + assert.equal(b['line_items[0][price_data][currency]'], 'eur') + assert.equal(b['line_items[0][price_data][unit_amount]'], '1250') + assert.equal(b['line_items[0][price_data][product_data][name]'], 'Artikel a1') + assert.equal(b['line_items[0][price_data][product_data][images][0]'], undefined) + assert.equal(b['shipping_options[0][shipping_rate_data][type]'], 'fixed_amount') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][currency]'], 'eur') + assert.equal(b['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Deutschland') + assert.equal(b['shipping_address_collection[allowed_countries][0]'], 'DE') + assert.equal(b.billing_address_collection, 'required') + assert.equal(b['phone_number_collection[enabled]'], 'true') + assert.equal(b['payment_method_types[0]'], 'card') + assert.equal(b['payment_method_types[1]'], undefined) + assert.equal(b.locale, 'de') + assert.equal(Number(b.expires_at), Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30) + assert.equal(b.client_reference_id, r.orderId) + for (const prefix of ['metadata', 'payment_intent_data[metadata]']) { + assert.equal(b[`${prefix}[wkShop]`], '1') + assert.equal(b[`${prefix}[shopId]`], FEATURE) + assert.equal(b[`${prefix}[orderId]`], r.orderId) + assert.equal(b[`${prefix}[bestellnummer]`], r.bestellnummer) + } + assert.equal(b['payment_intent_data[description]'], `Bestellung ${r.bestellnummer} – Brot & Mehr`) + const success = new URL(b.success_url) + assert.equal(success.origin + success.pathname, 'https://www.brot.example/laden') + assert.equal(success.searchParams.get('x'), '1') + assert.equal(success.searchParams.get('wk_bestellung'), r.orderId) + assert.equal(success.hash, '') + const token = success.searchParams.get('wk_t') + assert.match(token, /^[A-Za-z0-9_-]{43}$/) + assert.equal(B.tokenPasst(order, token), true) + assert.equal(order.tokenHash.includes(token), false) + const cancel = new URL(b.cancel_url) + assert.equal(cancel.searchParams.get('wk_abbruch'), '1') + assert.equal(cancel.searchParams.get('x'), '1') + assert.equal(cancel.searchParams.get('wk_t'), null) + + // bestellungLesen + const gelesen = await B.bestellungLesen(r.orderId) + assert.equal(gelesen.daten.shopname, 'Brot & Mehr') + assert.equal(gelesen.status, 'offen') + assert.equal(await B.bestellungLesen('gibt-es-nicht'), null) + assert.equal(await B.bestellungLesen('../x'), null) + assert.deepEqual(B.bestellDaten({ bestellung: '{kaputt' }), {}) +}) + +test('Abholung, Variante, Bild und Landeswahl: Namen, Versandzeile und Bild-URL', async () => { + const ctx = await aufbau({ + produkte: [ + artikel('a1', { bilder: ['abc-123'], varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000 }, { name: 'L', preisCent: 2200 }] } }), + artikel('a2'), + ], + }) + const m = ctx && JSON.parse(aw.docs('shopProducts').get('a1').produkt).varianten.werte.find((w) => w.name === 'L') + const abholung = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], lieferart: 'abholung', land: undefined }), { now: T0 }) + const a = sessionCalls()[0].body + assert.equal(a['line_items[0][price_data][product_data][name]'], 'Artikel a1 – L') + assert.equal(a['line_items[0][price_data][unit_amount]'], '2200') + assert.equal(a['line_items[0][price_data][product_data][images][0]'], `${PORTAL}/api/public/shop/v1/${SITE_KEY}/bild/abc-123-l.webp`) + assert.equal(a['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '0') + assert.equal(a['shipping_options[0][shipping_rate_data][display_name]'], 'Abholung') + assert.equal(a['shipping_address_collection[allowed_countries][0]'], undefined) + const daten = JSON.parse(aw.docs('shopOrders').get(abholung.orderId).bestellung) + assert.deepEqual(daten.abholung, { adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }) + assert.equal(daten.land, null) + + st.calls.length = 0 + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a2', menge: 1 }], land: 'AT' }), { now: T0 }) + const v = sessionCalls()[0].body + assert.equal(v['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Österreich') + assert.equal(v['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '790') + assert.equal(v['shipping_address_collection[allowed_countries][0]'], 'AT') + + // Bild-URL nur mit https-Portal + st.calls.length = 0 + config.publicBaseUrl = 'http://127.0.0.1:4000' + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], rueckUrl: undefined }), { now: T0 }) + assert.equal(sessionCalls()[0].body['line_items[0][price_data][product_data][images][0]'], undefined) + assert.ok(sessionCalls()[0].body.success_url.startsWith(`http://127.0.0.1:4000/shop/${SITE_KEY}?wk_bestellung=`)) +}) + +test('Preise nur aus der Datenbank: Preis, Summe und Versand vom Browser zaehlen nicht', async () => { + const ctx = await aufbau() + await B.legeBestellungAn( + ctx, + eingabe({ positionen: [{ produktId: 'a1', menge: 1, preisCent: 1, einzelCent: 1, summeCent: 1 }], versandCent: 0, gesamtCent: 1, summeCent: 1 }), + { now: T0 } + ) + const b = sessionCalls()[0].body + assert.equal(b['line_items[0][price_data][unit_amount]'], '1250') + assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490') + assert.equal(orders()[0].summeCent, 1740) +}) + +test('Berechnungsfehler: unbekannter Artikel 409, falsche Menge 400, Lieferart 400; nichts angelegt, kein Stripe-Aufruf', async () => { + const ctx = await aufbau() + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }), 409, 'nicht_verfuegbar') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 0 }] }), { now: T0 }), 400, 'menge') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ lieferart: 'post' }), { now: T0 }), 400, 'lieferart') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [] }), { now: T0 }), 400, 'leer') + await rejectsMit(B.legeBestellungAn(ctx, eingabe({ land: 'FR' }), { now: T0 }), 400, 'land') + await rejectsMit(B.legeBestellungAn(ctx, undefined, { now: T0 }), 400, 'leer') + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }).catch((e) => e) + assert.match(err.message, /nicht mehr verfügbar/) + assert.equal(orders().length, 0) + assert.equal(st.calls.length, 0) +}) + +test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservierungen geben ihn wieder frei', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const erste = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) + assert.deepEqual([...(await B.reserviert(FEATURE, nach(10))).entries()], [['a1', 1]]) + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(60) }).catch((e) => e) + assert.equal(err.status, 409) + assert.equal(err.code, 'ausverkauft') + assert.match(err.message, /ausverkauft/) + assert.equal(orders().length, 1) + assert.equal(sessionCalls().length, 1) + + // Nach Ablauf (30 min + 30 s) zaehlt die Reservierung nicht mehr + assert.equal((await B.reserviert(FEATURE, nach(31 * 60))).size, 0) + const zweite = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(31 * 60) }) + assert.notEqual(zweite.orderId, erste.orderId) +}) + +test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => { + await aufbau() + const ablauf = nach(1800).toISOString() + const pos = (produktId, menge, varianteId = null) => ({ produktId, varianteId, menge }) + const bestellung = (...positionen) => JSON.stringify({ positionen }) + aw.seed('shopOrders', { $id: 'o1', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 2), pos('p2', 1, 'v1')) }) + aw.seed('shopOrders', { $id: 'o2', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 3), pos('p2', 4, 'v1'), pos('p2', 1, 'v2')) }) + aw.seed('shopOrders', { $id: 'o3', shopId: FEATURE, status: 'bezahlt', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o4', shopId: 'anderer', status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o5', shopId: FEATURE, status: 'offen', ablaufAt: nach(-5).toISOString(), bestellung: bestellung(pos('p1', 9)) }) + aw.seed('shopOrders', { $id: 'o6', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: '{kaputt' }) + const karte = await B.reserviert(FEATURE, T0) + assert.deepEqual(Object.fromEntries(karte), { p1: 5, 'p2:v1': 5, 'p2:v2': 1 }) +}) + +test('Gleichzeitig: zwei parallele Bestellungen fuer den letzten Artikel, genau eine gewinnt', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + const lauf = () => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) + const ergebnisse = await Promise.allSettled([lauf(), lauf(), lauf()]) + assert.equal(ergebnisse.filter((x) => x.status === 'fulfilled').length, 1) + const abgelehnt = ergebnisse.filter((x) => x.status === 'rejected') + assert.equal(abgelehnt.length, 2) + for (const x of abgelehnt) assert.equal(x.reason.code, 'ausverkauft') + assert.equal(orders().length, 1) + assert.equal(sessionCalls().length, 1) +}) + +test('Geschlossener Shop: 409 geschlossen, nichts angelegt, kein Stripe-Aufruf', async () => { + for (const shop of [{ geoeffnet: false }, { stripeStatus: JSON.stringify({ charges_enabled: false }) }, { stripeAccountId: '' }]) { + const ctx = await aufbau({ shop }) + const err = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }).catch((e) => e) + assert.equal(err.status, 409, JSON.stringify(shop)) + assert.equal(err.code, 'geschlossen') + assert.equal(err.message, 'Der Shop ist gerade geschlossen.') + assert.equal(orders().length, 0) + assert.equal(st.calls.length, 0) + } + // Funktion nicht aktiv + const ctx = await aufbau() + await rejectsMit(B.legeBestellungAn({ ...ctx, doc: { ...ctx.doc, state: 'disabled' } }, eingabe(), { now: T0 }), 409, 'geschlossen') + // Kein lieferbarer Artikel mehr + const leer = await aufbau({ produkte: [artikel('a1', { aktiv: false })] }) + await rejectsMit(B.legeBestellungAn(leer, eingabe(), { now: T0 }), 409, 'geschlossen') +}) + +test('Fremde Rueck-URL: Fallback auf die Shop-Seite im Portal', async () => { + const ctx = await aufbau() + await B.legeBestellungAn(ctx, eingabe({ rueckUrl: 'https://boese.example/phishing?x=1' }), { now: T0 }) + const b = sessionCalls()[0].body + const s = new URL(b.success_url) + assert.equal(s.origin + s.pathname, `${PORTAL}/shop/${SITE_KEY}`) + assert.equal(new URL(b.cancel_url).origin + new URL(b.cancel_url).pathname, `${PORTAL}/shop/${SITE_KEY}`) + assert.ok(!b.success_url.includes('boese')) +}) + +test('Stripe lehnt ab: Bestellung storniert (Reservierung frei), 503 mit Kaufertext', async () => { + const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] }) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'Invalid something' }) + const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }).catch((e) => e) + assert.equal(err.status, 503) + assert.equal(err.code, 'zahlung_nicht_moeglich') + assert.equal(err.message, 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.') + assert.equal(sessionCalls().length, 1) + const [order] = orders() + assert.equal(order.status, 'storniert') + const daten = JSON.parse(order.bestellung) + assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'stripe_fehler']) + assert.equal(order.stripeSessionId ?? '', '') + assert.equal((await B.reserviert(FEATURE, T0)).size, 0) + // Der Artikel ist wieder bestellbar + await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }) +}) + +test('Klarna nicht freigeschaltet: einmal mit Karte wiederholen, Hinweis fuer den Haendler speichern', async () => { + const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] } }) + assert.deepEqual(ctx.shop.einstellungen.zahlungsarten, ['card', 'klarna']) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type provided: klarna is invalid. Please ensure the provided type is activated in your dashboard (payment_method_types).' }) + const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }) + const calls = sessionCalls() + assert.equal(calls.length, 2) + assert.equal(calls[0].body['payment_method_types[1]'], 'klarna') + assert.equal(calls[1].body['payment_method_types[0]'], 'card') + assert.equal(calls[1].body['payment_method_types[1]'], undefined) + assert.equal(calls[1].account, ACCOUNT) + assert.notEqual(calls[1].idempotencyKey, calls[0].idempotencyKey) + assert.equal(calls[1].body.client_reference_id, r.orderId) + assert.equal(calls[1].body.success_url, calls[0].body.success_url) + const [order] = orders() + assert.equal(order.status, 'offen') + assert.match(order.stripeSessionId, /^cs_/) + const status = JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus) + assert.equal(status.charges_enabled, true) + assert.match(status.hinweis, /^Klarna\/SEPA ist in Deinem Stripe-Konto nicht aktiviert/) + // Zwischenspeicher geleert: der naechste Kontext enthaelt den Hinweis + assert.match((await K.shopPerSiteKey(SITE_KEY, { now: T0 })).shop.stripeStatus.hinweis, /Klarna/) +}) + +test('Fehler bei reiner Kartenzahlung wird nicht wiederholt', async () => { + const ctx = await aufbau() + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'bad payment_method_types' }) + await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich') + assert.equal(sessionCalls().length, 1) + assert.equal(orders()[0].status, 'storniert') +}) + +test('Anderer Stripe-Fehler trotz Klarna-Auswahl: keine Wiederholung mit Karte', async () => { + const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'sepa_debit'] } }) + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'No such account: acct_x' }) + await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich') + assert.equal(sessionCalls().length, 1) + assert.equal(orders()[0].status, 'storniert') + assert.equal(JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus).hinweis, undefined) +})