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 } }