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 { ipSchluessel } from '../termine/limits.js' import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from './connect.js' import { datumKurz, heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js' import { euro } from './geld.js' import { istGeoeffnet, leereCache, shopPerSiteKey } from './kontext.js' import { anHaendler, anKaeufer, haendlerK25 } from './mails.js' import { berechne } from './preise.js' import { holeProdukt, listeProdukte, parseProdukt } 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 im selben JSON: kaeufer { name, email } (keine Telefonnummer), lieferadresse / rechnungsadresse { name?, line1, line2, postal_code, * city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt. * ipHash steht nur, solange die Bestellung offen ist (Begrenzung offener Bestellungen je IP); schliesseAb und verwirf entfernen ihn. * 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 /** Offene Bestellungen, die eine IP je Shop gleichzeitig haben darf (Fenster: Reservierungsdauer), und der Text dazu. */ const OFFEN_JE_IP = 3 const OFFEN_FENSTER_MS = 30 * 60 * 1000 const ZU_VIELE_OFFEN_TEXT = 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.' 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 /** Gesalzener Kurz-Hash der IP (16 Hex-Zeichen) fuer die Obergrenze offener Bestellungen; die Adresse selbst wird nie gespeichert. */ export function ipHashVon(ip) { return createHash('sha256').update(`shop-ip:${config.sessionSecret}:${ipSchluessel(ip)}`).digest('hex').slice(0, 16) } 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) oder in bezahlten * Bestellungen, deren Bestandsabzug noch aussteht (bestandAbgezogenAt === null, Teilfehler beim Abschluss). So gibt es nie einen * Moment, in dem eine Bestellung weder reserviert noch abgezogen ist. * @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', 'bezahlt'])]) const karte = new Map() for (const doc of docs) { const daten = bestellDaten(doc) if (doc.status === 'offen') { const ablauf = new Date(doc.ablaufAt).getTime() if (!(ablauf > nowMs)) continue } else if (daten.bestandAbgezogenAt !== null) continue const positionen = daten.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, ipHash?: string }} [opt] ipHash (ipHashVon): hoechstens 3 offene Bestellungen je IP in 30 Minuten (429 zu_viele_offen), * der Hash steht in der Bestellung (wird vom Tagesjob mit anonymisiert) * @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>} */ export async function legeBestellungAn(ctx, eingabe, { now = new Date(), ipHash } = {}) { 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 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 const angelegt = await withLocks([`shop-bestand:${shop.id}`], async () => { const docs = await listeProdukte(shop.id) if (!(await istGeoeffnet(ctx, { produkte: docs }))) throw geschlossen() if (typeof ipHash === 'string' && ipHash !== '') { const seit = jetzt.getTime() - OFFEN_FENSTER_MS const offene = await listAllDocuments(col(), [Query.equal('shopId', shop.id), Query.equal('status', 'offen')]) const eigene = offene.filter((d) => new Date(d.erstelltAt).getTime() > seit && bestellDaten(d).ipHash === ipHash).length if (eigene >= OFFEN_JE_IP) throw fehler(429, 'zu_viele_offen', ZU_VIELE_OFFEN_TEXT, { retryAfter: OFFEN_FENSTER_MS / 1000 }) } 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, ...(typeof ipHash === 'string' && ipHash !== '' ? { ipHash } : {}), 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 } = 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) => ({ 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] } } : {}), // Datenminimierung (Art. 5 Abs. 1 lit. c DSGVO): keine Telefonnummer, Rechnungsanschrift nur wenn Stripe sie fuer die Zahlungsart braucht billing_address_collection: 'auto', phone_number_collection: { enabled: false }, 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 } } // --------------------------------------------------------------------------------------------------------------------- // Abschluss (E2): bezahlt, verwerfen, Ablauf const ZAHLUNGSARTEN = new Set(['card', 'klarna', 'sepa_debit']) const ABGLEICH_PUFFER_MS = 5 * 60 * 1000 const kurz = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max) : '') const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '') const verlaufMit = (daten, eintrag) => [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), eintrag] /** Stripe-Anschrift {line1, line2, postal_code, city, country} (+ name) als schlankes Objekt oder null. */ function anschrift(adresse, name) { if (!istObjekt(adresse)) return null const a = { ...(kurz(name, 200) ? { name: kurz(name, 200) } : {}), line1: kurz(adresse.line1, 200), line2: kurz(adresse.line2, 200), postal_code: kurz(adresse.postal_code, 20), city: kurz(adresse.city, 100), country: kurz(adresse.country, 2).toUpperCase(), } return a.line1 || a.postal_code || a.city ? a : null } /** Zahlungsart nur, wenn sie eindeutig ist (eine erlaubte Art oder die bezahlte Art des erweiterten PaymentIntents). */ function zahlungsartVon(session) { const arten = Array.isArray(session.payment_method_types) ? session.payment_method_types.filter((a) => ZAHLUNGSARTEN.has(a)) : [] if (arten.length === 1 && session.payment_method_types.length === 1) return arten[0] const bezahlt = session.payment_intent?.payment_method?.type return ZAHLUNGSARTEN.has(bezahlt) ? bezahlt : '' } async function shopDokument(shopId) { try { return await getDocument(config.collections.shopConfig, shopId) } catch (err) { if (err?.status === 404) return null throw err } } /** * Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Der AUFRUFER haelt die Sperren * `shop-bestand:` und danach `shop-produkte:` (Reihenfolge immer so; withLocks ist nicht reentrant). * @returns {Promise} true, wenn der Bestand eines Artikels nicht reichte (auf 0 gesetzt) */ async function zieheBestandAb(shopId, positionen) { let ueberverkauft = false const nachArtikel = new Map() for (const pos of Array.isArray(positionen) ? positionen : []) { if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue nachArtikel.set(pos.produktId, [...(nachArtikel.get(pos.produktId) ?? []), pos]) } for (const [produktId, liste] of nachArtikel) { const doc = await holeProdukt(shopId, produktId) const produkt = doc ? parseProdukt(doc) : null if (!produkt) continue let geaendert = false const abziehen = (objekt, menge) => { if (!Number.isInteger(objekt.bestand)) return if (objekt.bestand < menge) ueberverkauft = true objekt.bestand = Math.max(0, objekt.bestand - menge) geaendert = true } for (const pos of liste) { const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null if (pos.varianteId && !wert) continue abziehen(wert ?? produkt, pos.menge) } if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) }) } return ueberverkauft } const MAIL_VERSUCHE_MAX = 5 const bestandSperren = (shopId) => [`shop-bestand:${shopId}`, `shop-produkte:${shopId}`] /** Welche Schritte nach dem Statuswechsel noch fehlen (Marker im Bestell-JSON stehen dann explizit auf null). */ function offeneSchritte(daten) { const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {} return { bestand: daten.bestandAbgezogenAt === null, kaeufer: daten.mailsKaeuferAt === null && (versuche.kaeufer ?? 0) < MAIL_VERSUCHE_MAX, haendler: daten.mailsHaendlerAt === null && (versuche.haendler ?? 0) < MAIL_VERSUCHE_MAX, } } /** Fehlt bei einer bezahlten Bestellung noch der Bestandsabzug oder eine Mail? (Webhook-Wiederholung, Status, Minutenjob) */ export function abschlussAusstehend(order) { if (!STATUS_BEZAHLT.includes(order?.status)) return false const daten = order.daten ?? bestellDaten(order) const o = offeneSchritte(daten) return o.bestand || o.kaeufer || o.haendler } /** Bestandsabzug mit Marker; Sperren muessen gehalten werden. Gibt die neue Bestellung (mit daten) zurueck. */ async function bestandSchritt(order, jetzt) { const ueberverkauft = await zieheBestandAb(order.shopId, order.daten.positionen) const daten = { ...order.daten, bestandAbgezogenAt: jetzt.toISOString(), ...(ueberverkauft ? { ueberverkauft: true } : {}) } const doc = await updateDocument(col(), order.$id, { bestellung: JSON.stringify(daten) }) return { ...doc, daten: bestellDaten(doc) } } /** Fehlende Mails (G09, K25) einzeln verschicken; Marker erst nach Erfolg, Versuche gezaehlt. Unter der Bestellsperre. */ async function mailSchritte(order, siteKey, jetzt) { let aktuell = order const merke = async (aenderung) => { const doc = await updateDocument(col(), aktuell.$id, { bestellung: JSON.stringify({ ...aktuell.daten, ...aenderung }) }) aktuell = { ...doc, daten: bestellDaten(doc) } } const zaehle = (art) => ({ mailsVersuche: { ...(istObjekt(aktuell.daten.mailsVersuche) ? aktuell.daten.mailsVersuche : {}), [art]: (aktuell.daten.mailsVersuche?.[art] ?? 0) + 1 } }) try { leereCache(siteKey) const ctx = await shopPerSiteKey(siteKey, { now: jetzt }) if (!ctx) throw new Error('Shop-Kontext nicht gefunden') if (offeneSchritte(aktuell.daten).kaeufer) { const r = await anKaeufer('G09', ctx, aktuell, {}, jetzt) await merke(r.ok ? { mailsKaeuferAt: jetzt.toISOString() } : zaehle('kaeufer')) } if (offeneSchritte(aktuell.daten).haendler) { const k25 = haendlerK25(ctx, aktuell, { ueberverkauft: aktuell.daten.ueberverkauft === true }) const r = await anHaendler('K25', ctx, k25.values, k25.blocks) await merke(r.ok ? { mailsHaendlerAt: jetzt.toISOString() } : zaehle('haendler')) } } catch (err) { console.warn(`[shop] Mails zur bezahlten Bestellung ${String(order.$id).slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } return aktuell } /** * Schliesst eine bezahlte Checkout-Sitzung ab: einzige Stelle fuer den Status bezahlt. Idempotent (zweiter Aufruf aendert nichts * und schickt keine Mail), nimmt auch spaete Zahlungen einer abgelaufenen Bestellung an. Mailfehler machen die Zahlung nie rueckgaengig. * @param {{ accountId: string, session: object, now?: Date }} p session = Stripe-Checkout-Session (Webhook oder retrieve) * @returns {Promise<{ order: object|null, neu: boolean }>} order mit daten; null, wenn es keine passende Bestellung gibt */ export async function schliesseAb({ accountId, session, now = new Date() }) { const orderId = session?.metadata?.orderId if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { order: null, neu: false } const jetzt = new Date(now) return withLocks([`shop-order:${orderId}`], async () => { let order = await bestellungLesen(orderId) if (!order) return { order: null, neu: false } const shop = await shopDokument(order.shopId) if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) { console.warn(`[shop] Bestellung ${orderId.slice(0, 36)} gehört nicht zum Stripe-Konto der Sitzung, ignoriert`) return { order: null, neu: false } } const daten = order.daten const spaet = order.status === 'storniert' && Array.isArray(daten.verlauf) && daten.verlauf.some((v) => v?.was === 'abgelaufen') let neu = false if (order.status === 'offen' || spaet) { if (session.payment_status !== 'paid') return { order, neu: false } const kunde = istObjekt(session.customer_details) ? session.customer_details : {} const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null const rechnungsadresse = anschrift(kunde.address, kunde.name) const zahlungsart = zahlungsartVon(session) const email = kurz(kunde.email, 320) const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`) const at = jetzt.toISOString() let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) }) if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null }) // Datenminimierung: Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt mit der Zahlung; eine Telefonnummer wird nie gespeichert const { ipHash: _ipHash, ...datenOhneIp } = daten const neueDaten = { ...datenOhneIp, kaeufer: { name: kurz(kunde.name, 200), email }, ...(lieferadresse ? { lieferadresse } : {}), ...(rechnungsadresse ? { rechnungsadresse } : {}), ...(zahlungsart ? { zahlungsart } : {}), ...(spaet ? { spaet: true } : {}), // Marker: null = Schritt steht noch aus, wird erst nach Erfolg mit der Zeit gesetzt (siehe offeneSchritte) bestandAbgezogenAt: null, mailsKaeuferAt: null, mailsHaendlerAt: null, verlauf, } const felder = { status: 'bezahlt', bezahltAt: at, bestellung: JSON.stringify(neueDaten), ...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}), ...(email ? { kaeuferEmail: email } : {}), } if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined }) // Statuswechsel und Bestandsabzug unter derselben Sperre: legeBestellungAn sieht nie "weder reserviert noch abgezogen" order = await withLocks(bestandSperren(order.shopId), async () => { const doc = await updateDocument(col(), orderId, felder) return bestandSchritt({ ...doc, daten: bestellDaten(doc) }, jetzt) }) neu = true } else if (STATUS_BEZAHLT.includes(order.status) && offeneSchritte(daten).bestand) { // Wiederholung nach Teilfehler: Bestandsabzug nachholen (genau einmal, Marker) order = await withLocks(bestandSperren(order.shopId), async () => { const frisch = await bestellungLesen(orderId) return frisch && offeneSchritte(frisch.daten).bestand ? bestandSchritt(frisch, jetzt) : (frisch ?? order) }) } else if (!STATUS_BEZAHLT.includes(order.status)) return { order, neu: false } if (offeneSchritte(order.daten).kaeufer || offeneSchritte(order.daten).haendler) order = await mailSchritte(order, shop.siteKey, jetzt) return { order, neu } }) } /** * Eingang der Bestellung (G15): Stripe meldet die Sitzung abgeschlossen, die Zahlung steht aber noch aus (SEPA-Lastschrift, * payment_status unpaid / no_payment_required). Die Bestellung bleibt offen, der Kaeufer bekommt einmal eine Eingangsmail ohne * Vertragsschluss (der Vertrag kommt mit G09 nach Zahlungseingang zustande). Marker `mailsEingangAt` im Bestell-JSON, Versuche in * mailsVersuche.eingang (hoechstens MAIL_VERSUCHE_MAX). Aufrufer: Webhook (checkout.session.completed) und pruefeOffene. * Nie ein Wurf wegen der Mail; falsches Konto, fremde Sitzung oder eine nicht mehr offene Bestellung bleiben wirkungslos. * @param {{ accountId: string, session: object, now?: Date }} p * @returns {Promise<{ gesendet: boolean }>} */ export async function meldeEingang({ accountId, session, now = new Date() }) { const orderId = session?.metadata?.orderId if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { gesendet: false } if (session.payment_status !== 'unpaid' && session.payment_status !== 'no_payment_required') return { gesendet: false } const an = kurz(session.customer_details?.email, 320) if (!an) return { gesendet: false } const jetzt = new Date(now) return withLocks([`shop-order:${orderId}`], async () => { const order = await bestellungLesen(orderId) if (!order || order.status !== 'offen') return { gesendet: false } const shop = await shopDokument(order.shopId) if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) return { gesendet: false } const daten = order.daten const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {} if (daten.mailsEingangAt || (versuche.eingang ?? 0) >= MAIL_VERSUCHE_MAX) return { gesendet: false } let ok = false try { leereCache(shop.siteKey) const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt }) if (!ctx) throw new Error('Shop-Kontext nicht gefunden') ok = (await anKaeufer('G15', ctx, order, { an }, jetzt)).ok } catch (err) { console.warn(`[shop] G15 zur Bestellung ${orderId.slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } const neu = ok ? { ...daten, mailsEingangAt: jetzt.toISOString() } : { ...daten, mailsVersuche: { ...versuche, eingang: (versuche.eingang ?? 0) + 1 } } await updateDocument(col(), orderId, { bestellung: JSON.stringify(neu) }) return { gesendet: ok } }) } /** * Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung. * @param {string} orderId * @param {'abgelaufen'|'zahlung_fehlgeschlagen'|string} grund Eintrag im Verlauf * @param {{ now?: Date, mail?: boolean, email?: string, accountId?: string }} [opt] mail: G14 an den Kaeufer (email oder kaeuferEmail); * accountId: Konto der Sitzung, muss zum Shop der Bestellung passen (Webhook) * @returns {Promise<{ order: object|null, verworfen: boolean }>} */ export async function verwirf(orderId, grund, { now = new Date(), mail = false, email, accountId } = {}) { const jetzt = new Date(now) const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { const order = await bestellungLesen(orderId) if (!order) return { order: null, verworfen: false } if (accountId !== undefined) { const shop = await shopDokument(order.shopId) if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false } } if (order.status !== 'offen') return { order, verworfen: false } // Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt, sobald die Bestellung nicht mehr offen ist const { ipHash: _ipHash, ...datenOhneIp } = order.daten const doc = await updateDocument(col(), orderId, { status: 'storniert', bestellung: JSON.stringify({ ...datenOhneIp, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }), }) return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true } }) if (ergebnis.verworfen && mail) { const an = kurz(email, 320) || kurz(ergebnis.order.kaeuferEmail, 320) if (an) { try { const shop = await shopDokument(ergebnis.order.shopId) if (shop) { leereCache(shop.siteKey) const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt }) if (ctx) await anKaeufer('G14', ctx, ergebnis.order, { an }, jetzt) } } catch (err) { console.warn(`[shop] G14 zur Bestellung ${String(orderId).slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } } } return ergebnis } /** * Abgleich der offenen Bestellungen, deren Frist (ablaufAt) seit mehr als 5 Minuten verstrichen ist (Minutenjob): Stripe fragen * und bezahlt abschliessen, verwerfen oder (noch offene Sitzung) beenden und verwerfen. Lesefehler: naechster Lauf. * @returns {Promise<{ geprueft: number, bezahlt: number, verworfen: number, wartend: number, fehler: number }>} */ export async function pruefeOffene(now = new Date()) { const grenze = new Date(now).getTime() - ABGLEICH_PUFFER_MS const zaehler = { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 } const shops = await listAllDocuments(config.collections.shopConfig, []) for (const shop of shops) { const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter( (d) => new Date(d.ablaufAt).getTime() < grenze ) // Bezahlte Bestellungen, bei denen nach einem Teilfehler noch Bestandsabzug oder Mails fehlen: ueber schliesseAb nachholen const unfertig = shop.stripeAccountId ? (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'bezahlt')])).filter( (d) => new Date(d.bezahltAt).getTime() < grenze && abschlussAusstehend({ ...d, daten: bestellDaten(d) }) ) : [] for (const order of unfertig) { zaehler.geprueft += 1 try { if (!order.stripeSessionId) continue const session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId) if (session) await schliesseAb({ accountId: shop.stripeAccountId, session, now }) } catch (err) { zaehler.fehler += 1 console.warn(`[shop] Nachholen der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } } for (const order of offene) { zaehler.geprueft += 1 try { if (!order.stripeSessionId || !shop.stripeAccountId) { // Es wurde nie eine Sitzung gespeichert: es kann nichts bezahlt werden if (!order.stripeSessionId) { const erg = await verwirf(order.$id, 'abgelaufen', { now }) if (erg.verworfen) zaehler.verworfen += 1 } else zaehler.wartend += 1 continue } let session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId) if (session?.status === 'complete') { if (session.payment_status === 'paid') { const erg = await schliesseAb({ accountId: shop.stripeAccountId, session, now }) if (erg.neu) zaehler.bezahlt += 1 } else { // SEPA unterwegs: async_payment_succeeded/failed entscheidet; bis dahin hat der Kaeufer die Eingangsmail (G15) zu bekommen zaehler.wartend += 1 await meldeEingang({ accountId: shop.stripeAccountId, session, now }) } continue } if (session?.status === 'open') { session = await sessionAblaufen(shop.stripeAccountId, order.stripeSessionId) if (session?.status !== 'expired') { zaehler.wartend += 1 continue } } else if (session?.status !== 'expired') { zaehler.wartend += 1 continue } const erg = await verwirf(order.$id, 'abgelaufen', { now }) if (erg.verworfen) zaehler.verworfen += 1 } catch (err) { zaehler.fehler += 1 console.warn(`[shop] Abgleich der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } } } return zaehler } // --------------------------------------------------------------------------------------------------------------------- // Haendler-Aktionen, Erstattung, Liste, CSV (E3). Texte hier gehen an den Haendler (Du-Form). const STATUS_BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen'] /** Alle Status echter (bezahlter) Bestellungen; offen und storniert sind nie bezahlt worden. */ const STATUS_ECHT = [...STATUS_BEZAHLT, 'erstattet'] const STATUS_ALLE = [...STATUS_ECHT, 'offen', 'storniert'] const STATUS_TEXT = { offen: 'offen', bezahlt: 'bezahlt', versendet: 'versendet', abholbereit: 'abholbereit', abgeschlossen: 'abgeschlossen', erstattet: 'erstattet', storniert: 'storniert', } const AKTIONEN = ['versendet', 'abholbereit', 'abgeschlossen', 'storniert'] const MAX_DIENST = 60 const MAX_SENDUNG = 80 const MAX_GRUND = 300 const LISTE_SEITE = 50 /** Obergrenze fuer das Durchsuchen/Blaettern der Liste: so viele neueste Bestellungen werden hoechstens gelesen. */ const LISTE_MAX_GELESEN = 2000 const LISTE_BATCH = 100 const CSV_MAX_ZEILEN = 20000 const CSV_BATCH = 200 const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ const NICHT_GEFUNDEN_BESTELLUNG = 'Diese Bestellung gibt es nicht.' const ERSTATTUNG_FEHLER = 'Die Erstattung hat bei Stripe nicht geklappt. Bitte versuch es noch einmal oder erstatte direkt im Stripe-Dashboard.' const ERSTATTUNG_NICHT_GESPEICHERT = 'Die Erstattung wurde bei Stripe ausgeführt, konnte hier aber nicht gespeichert werden. Bitte lade die Bestellung neu und prüfe sie im Stripe-Dashboard.' // eslint-disable-next-line no-control-regex const STEUERZEICHEN = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g const einzeiler = (wert) => (typeof wert === 'string' ? wert.replace(STEUERZEICHEN, ' ').replace(/\s+/g, ' ').trim() : '') /** Summe der Erstattungen in Cent (nur gueltige ganze Betraege). */ export function erstattetCent(daten) { const liste = Array.isArray(daten?.erstattungen) ? daten.erstattungen : [] return liste.reduce((summe, e) => summe + (Number.isInteger(e?.betragCent) && e.betragCent > 0 ? e.betragCent : 0), 0) } /** Shop-Kontext aus ctx ({ doc, shop }) oder Shop-ID (dann aus shopConfig + shopPerSiteKey geladen). */ async function kontextFuer(shopOderCtx, now) { if (istObjekt(shopOderCtx) && istObjekt(shopOderCtx.shop) && typeof shopOderCtx.shop.id === 'string' && shopOderCtx.shop.id) return shopOderCtx if (typeof shopOderCtx === 'string' && shopOderCtx) { const doc = await shopDokument(shopOderCtx) const ctx = doc ? await shopPerSiteKey(doc.siteKey, { now }) : null if (ctx) return ctx throw fehler(404, 'nicht_gefunden', 'Diesen Shop gibt es nicht.') } throw new TypeError('Shop-Kontext oder Shop-ID erwartet') } /** Bestellung im Shop oder 404 (auch bei fremden Bestellungen: nie verraten, dass es sie gibt). */ async function eigeneBestellung(shopId, orderId) { const order = await bestellungLesen(orderId) if (!order || order.shopId !== shopId) throw fehler(404, 'nicht_gefunden', NICHT_GEFUNDEN_BESTELLUNG) return order } /** JSON der Bestellung; zu lang => mittlere Verlaufseintraege fallen weg (erster und letzte bleiben), nie ein Fehler. */ function jsonVon(daten) { let json = JSON.stringify(daten) const verlauf = Array.isArray(daten.verlauf) ? [...daten.verlauf] : [] while (json.length > MAX_JSON && verlauf.length > 3) { verlauf.splice(1, 1) json = JSON.stringify({ ...daten, verlauf }) } return json } function statusFehler(status, aktion) { if (status === 'offen') return fehler(409, 'falscher_status', 'Diese Bestellung ist noch nicht bezahlt.') if (status === 'storniert') return fehler(409, 'falscher_status', 'Diese Bestellung wurde storniert.') if (status === 'erstattet') return fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') if (status === aktion) return fehler(409, 'falscher_status', `Diese Bestellung ist schon als ${STATUS_TEXT[aktion] ?? aktion} markiert.`) return fehler(409, 'falscher_status', `Das geht bei dieser Bestellung nicht mehr (Status: ${STATUS_TEXT[status] ?? status}).`) } /** * Stellt die Aktion fuer den Haendler zusammen: Statusuebergaenge einer bezahlten Bestellung. * versendet aus bezahlt, nur Lieferart versand; extra { versanddienst (<= 60), sendungsnummer (<= 80) } optional => G10 * abholbereit aus bezahlt, nur Lieferart abholung => G11 * abgeschlossen aus bezahlt | versendet | abholbereit * storniert nur aus offen (unbezahlt): beendet die Kasse bei Stripe und verwirft (Bestand wird frei) * Unter der Sperre shop-order:; Eintrag im Verlauf. Die Bestellung muss zum Shop gehoeren (sonst 404), ein falscher Zustand ist * 409 mit Du-Text. Mailfehler aendern nichts (mailOk false). * @param {object|string} shopOderCtx Shop-Kontext { doc, shop } oder Shop-ID * @param {string} orderId * @param {'versendet'|'abholbereit'|'abgeschlossen'|'storniert'} aktion * @param {{ versanddienst?: string, sendungsnummer?: string }} [extra] * @param {{ now?: Date }} [opt] * @returns {Promise<{ order: object, mailOk: boolean|null }>} order mit daten; mailOk null, wenn keine Mail vorgesehen ist */ export async function setzeStatus(shopOderCtx, orderId, aktion, extra = {}, { now = new Date() } = {}) { if (!AKTIONEN.includes(aktion)) throw fehler(400, 'aktion', 'Diese Aktion gibt es nicht.') const jetzt = new Date(now) const ctx = await kontextFuer(shopOderCtx, jetzt) const shopId = ctx.shop.id const eingabe = istObjekt(extra) ? extra : {} let dienst = '' let sendung = '' if (aktion === 'versendet') { for (const [wert, max, name] of [[eingabe.versanddienst, MAX_DIENST, 'Der Versanddienst'], [eingabe.sendungsnummer, MAX_SENDUNG, 'Die Sendungsnummer']]) { if (wert !== undefined && wert !== null && typeof wert !== 'string') throw fehler(400, 'eingabe', `${name} ist ungültig.`) if (einzeiler(wert).length > max) throw fehler(400, 'eingabe', `${name} darf höchstens ${max} Zeichen lang sein.`) } dienst = einzeiler(eingabe.versanddienst) sendung = einzeiler(eingabe.sendungsnummer) } if (aktion === 'storniert') { const order = await eigeneBestellung(shopId, orderId) if (order.status !== 'offen') throw statusFehler(order.status, aktion) // Die Kasse beenden, damit nicht nach dem Stornieren noch bezahlt werden kann if (order.stripeSessionId && ctx.shop.stripeAccountId) { try { await sessionAblaufen(ctx.shop.stripeAccountId, order.stripeSessionId) } catch { let session = null try { session = await sessionHolen(ctx.shop.stripeAccountId, order.stripeSessionId) } catch { session = null } if (session?.status === 'complete') { throw fehler(409, 'falscher_status', 'Diese Bestellung wurde gerade bezahlt. Bitte lade die Seite neu.') } if (session?.status !== 'expired') { throw fehler(409, 'stripe_fehler', 'Die Bestellung kann gerade nicht storniert werden. Bitte versuch es gleich noch einmal.') } } } const erg = await verwirf(orderId, 'vom_haendler', { now: jetzt }) if (!erg.verworfen) throw statusFehler(erg.order?.status ?? 'bezahlt', aktion) return { order: erg.order, mailOk: null } } const order = await withLocks([`shop-order:${orderId}`], async () => { const aktuell = await eigeneBestellung(shopId, orderId) const daten = aktuell.daten const erlaubt = aktion === 'abgeschlossen' ? ['bezahlt', 'versendet', 'abholbereit'] : ['bezahlt'] if (!erlaubt.includes(aktuell.status)) throw statusFehler(aktuell.status, aktion) if (aktion === 'versendet' && daten.lieferart !== 'versand') throw fehler(409, 'falsche_lieferart', 'Diese Bestellung wird abgeholt, nicht versendet.') if (aktion === 'abholbereit' && daten.lieferart !== 'abholung') throw fehler(409, 'falsche_lieferart', 'Diese Bestellung wird versendet, nicht abgeholt.') const at = jetzt.toISOString() const eintrag = { at, was: aktion, ...(aktion === 'versendet' ? { ...(dienst ? { versanddienst: dienst } : {}), ...(sendung ? { sendungsnummer: sendung } : {}) } : {}) } const neu = { ...daten, ...(aktion === 'versendet' ? { versand: { ...(dienst ? { versanddienst: dienst } : {}), ...(sendung ? { sendungsnummer: sendung } : {}), at } } : {}), verlauf: verlaufMit(daten, eintrag), } const doc = await updateDocument(col(), orderId, { status: aktion, bestellung: jsonVon(neu) }) return { ...doc, daten: bestellDaten(doc) } }) let mailOk = null if (aktion === 'versendet') mailOk = (await anKaeufer('G10', ctx, order, { versanddienst: dienst, sendungsnummer: sendung }, jetzt)).ok else if (aktion === 'abholbereit') mailOk = (await anKaeufer('G11', ctx, order, {}, jetzt)).ok return { order, mailOk } } /** * Erstattet eine bezahlte Bestellung ganz oder teilweise ueber Stripe (Direct Charge des Haendlerkontos). * Erlaubt aus bezahlt | versendet | abholbereit | abgeschlossen (auch nach Teilerstattungen); ein Status bleibt bei Teilerstattung * unveraendert, bei voller Erstattung wird er `erstattet`. betragCent: ganze Cent von 1 bis zum Rest (summeCent minus bisherige * Erstattungen), ohne Angabe der ganze Rest. Die ganze Sache einschliesslich des Stripe-Aufrufs laeuft unter der Sperre * shop-order:: zwei gleichzeitige Erstattungen erstatten nie doppelt (die zweite sieht den neuen Rest). Idempotenz-Schluessel * `shop-erstattung--`. Eintrag in erstattungen[]: { rid, betragCent, grund, at, quelle:'portal' }. * Danach G13 an den Kaeufer (Mailfehler aendern nichts). * @param {object|string} shopOderCtx Shop-Kontext { doc, shop } oder Shop-ID * @param {string} orderId * @param {{ betragCent?: number, grund?: string }} [eingabe] grund hoechstens 300 Zeichen * @param {{ now?: Date }} [opt] * @returns {Promise<{ order: object, erstattung: object, voll: boolean, mailOk: boolean }>} * @throws {Error} 400 Eingabe, 404 fremde/unbekannte Bestellung, 409 falscher Zustand, 502 Stripe-Fehler (fester Text) */ export async function erstatte(shopOderCtx, orderId, { betragCent, grund } = {}, { now = new Date() } = {}) { const jetzt = new Date(now) const ctx = await kontextFuer(shopOderCtx, jetzt) const shopId = ctx.shop.id if (grund !== undefined && grund !== null && typeof grund !== 'string') throw fehler(400, 'eingabe', 'Der Grund ist ungültig.') const grundText = typeof grund === 'string' ? grund.replace(/\r\n?/g, '\n').replace(STEUERZEICHEN_OHNE_UMBRUCH, '').trim() : '' if (grundText.length > MAX_GRUND) throw fehler(400, 'eingabe', `Der Grund darf höchstens ${MAX_GRUND} Zeichen lang sein.`) if (betragCent !== undefined && betragCent !== null && !Number.isInteger(betragCent)) throw fehler(400, 'betrag', 'Bitte gib einen gültigen Betrag an.') const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { const order = await eigeneBestellung(shopId, orderId) const daten = order.daten if (!STATUS_BEZAHLT.includes(order.status)) { if (order.status === 'erstattet') throw fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') throw fehler(409, 'falscher_status', 'Diese Bestellung ist nicht bezahlt. Es gibt nichts zu erstatten.') } const accountId = ctx.shop.stripeAccountId if (!accountId || !order.paymentIntentId) throw fehler(409, 'keine_zahlung', 'Zu dieser Bestellung gibt es keine Zahlung bei Stripe. Bitte erstatte direkt im Stripe-Dashboard.') const bisher = Array.isArray(daten.erstattungen) ? daten.erstattungen : [] const rest = (Number.isInteger(order.summeCent) ? order.summeCent : 0) - erstattetCent(daten) if (rest <= 0) throw fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') const betrag = betragCent === undefined || betragCent === null ? rest : betragCent if (betrag < 1 || betrag > rest) { throw fehler(400, 'betrag', `Bitte gib einen Betrag zwischen ${euro(1)} und ${euro(rest)} an (so viel ist noch nicht erstattet).`) } let refund try { refund = await erstatten(accountId, { paymentIntent: order.paymentIntentId, betragCent: betrag, grund: grundText || undefined, idempotencyKey: `shop-erstattung-${orderId}-${bisher.length}`, }) if (typeof refund?.id !== 'string' || refund.id === '' || refund.status === 'failed' || refund.status === 'canceled') throw new Error('Erstattung nicht ausgeführt') } catch (err) { // Nur Art und Code: Stripe-Texte gehen nie an den Browser console.error(`[shop] Erstattung fehlgeschlagen: ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) throw fehler(502, 'erstattung_fehlgeschlagen', ERSTATTUNG_FEHLER) } const at = jetzt.toISOString() const eintrag = { rid: refund.id, betragCent: betrag, grund: grundText, at, quelle: 'portal' } // Defensiv: hat der Stripe-Abgleich (erstattungen.js) dieselbe Erstattung schon gebucht, wird sie nicht doppelt eingetragen const erstattungen = bisher.some((e) => e?.rid === refund.id) ? bisher : [...bisher, eintrag] const voll = erstattetCent({ erstattungen }) >= order.summeCent const neu = { ...daten, erstattungen, verlauf: verlaufMit(daten, { at, was: voll ? 'erstattet' : 'teilerstattet', betragCent: betrag }) } try { const doc = await updateDocument(col(), orderId, { ...(voll ? { status: 'erstattet' } : {}), bestellung: jsonVon(neu) }) return { order: { ...doc, daten: bestellDaten(doc) }, erstattung: eintrag, voll } } catch (err) { console.error(`[shop] Erstattung ${refund.id} bei Stripe gebucht, aber Bestellung ${String(orderId).slice(0, 36)} nicht gespeichert: ${String(err?.message).slice(0, 120)}`) throw fehler(502, 'erstattung_nicht_gespeichert', ERSTATTUNG_NICHT_GESPEICHERT) } }) const mail = await anKaeufer('G13', ctx, ergebnis.order, { betragCent: ergebnis.erstattung.betragCent, grund: ergebnis.erstattung.grund }, jetzt) return { ...ergebnis, mailOk: mail.ok } } // eslint-disable-next-line no-control-regex const STEUERZEICHEN_OHNE_UMBRUCH = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g // --------------------------------------------------------------------------------------------------------------------- // Ansichten fuer den Haendler function naechsteAktionen(status, lieferart) { if (status === 'offen') return ['storniert'] if (status === 'bezahlt') return [lieferart === 'abholung' ? 'abholbereit' : 'versendet', 'abgeschlossen'] if (status === 'versendet' || status === 'abholbereit') return ['abgeschlossen'] return [] } /** * Ansicht einer Bestellung fuer den Haendler (Detail): alles inklusive Kaeuferdaten, Verlauf und Erstattungen, aber ohne * Token-Hash, Stripe-Sitzungs- und Zahlungs-ID. `aktionen` sind die Aktionen, die der Status gerade erlaubt (der Server prueft sie trotzdem). * @param {object} order shopOrders-Dokument (mit oder ohne `daten`) */ export function bestellungKunde(order) { const daten = istObjekt(order?.daten) ? order.daten : bestellDaten(order) const summen = istObjekt(daten.summen) ? daten.summen : {} const summeCent = Number.isInteger(order?.summeCent) ? order.summeCent : Number.isInteger(summen.gesamtCent) ? summen.gesamtCent : 0 const erstattet = erstattetCent(daten) const bezahlbar = STATUS_BEZAHLT.includes(order?.status) return { id: order?.$id ?? '', bestellnummer: order?.bestellnummer ?? '', status: order?.status ?? '', erstelltAt: order?.erstelltAt ?? null, bezahltAt: order?.bezahltAt ?? null, summeCent, summeText: euro(summeCent), erstattetCent: erstattet, erstattetText: euro(erstattet), restCent: Math.max(0, summeCent - erstattet), restText: euro(Math.max(0, summeCent - erstattet)), // Alte Datensaetze koennen noch eine Telefonnummer enthalten: nie ausgeben kaeufer: istObjekt(daten.kaeufer) ? { name: daten.kaeufer.name ?? '', email: daten.kaeufer.email ?? '' } : { name: '', email: order?.kaeuferEmail ?? '' }, lieferart: daten.lieferart ?? '', land: daten.land ?? '', lieferadresse: istObjekt(daten.lieferadresse) ? daten.lieferadresse : null, rechnungsadresse: istObjekt(daten.rechnungsadresse) ? daten.rechnungsadresse : null, abholung: istObjekt(daten.abholung) ? daten.abholung : null, zahlungsart: typeof daten.zahlungsart === 'string' ? daten.zahlungsart : '', lieferzeit: typeof daten.lieferzeit === 'string' ? daten.lieferzeit : '', positionen: Array.isArray(daten.positionen) ? daten.positionen : [], summen: { zwischensummeCent: summen.zwischensummeCent ?? null, versandCent: summen.versandCent ?? null, gesamtCent: summen.gesamtCent ?? null, steuer: summen.steuer ?? null, texte: istObjekt(summen.texte) ? summen.texte : {}, }, versand: istObjekt(daten.versand) ? daten.versand : null, ueberverkauft: daten.ueberverkauft === true, spaet: daten.spaet === true, verlauf: Array.isArray(daten.verlauf) ? daten.verlauf : [], erstattungen: Array.isArray(daten.erstattungen) ? daten.erstattungen : [], aktionen: naechsteAktionen(order?.status, daten.lieferart), kannErstatten: bezahlbar && summeCent - erstattet > 0 && Boolean(order?.paymentIntentId), } } /** Zeile der Liste: das Wichtigste auf einen Blick. */ function listenZeile(doc) { const daten = bestellDaten(doc) const kaeufer = istObjekt(daten.kaeufer) ? daten.kaeufer : {} const summeCent = Number.isInteger(doc.summeCent) ? doc.summeCent : 0 const erstattet = erstattetCent(daten) return { id: doc.$id, bestellnummer: doc.bestellnummer ?? '', status: doc.status ?? '', erstelltAt: doc.erstelltAt ?? null, bezahltAt: doc.bezahltAt ?? null, summeCent, summeText: euro(summeCent), erstattetCent: erstattet, kaeuferName: typeof kaeufer.name === 'string' ? kaeufer.name : '', kaeuferEmail: doc.kaeuferEmail || (typeof kaeufer.email === 'string' ? kaeufer.email : ''), lieferart: daten.lieferart ?? '', anzahlArtikel: Array.isArray(daten.positionen) ? daten.positionen.reduce((s, p) => s + (Number.isInteger(p?.menge) ? p.menge : 0), 0) : 0, ueberverkauft: daten.ueberverkauft === true, spaet: daten.spaet === true, } } /** * Bestellungen des Shops, neueste zuerst (nach erstelltAt), 50 je Seite. * status: eines von offen|bezahlt|versendet|abholbereit|abgeschlossen|erstattet|storniert; ohne Angabe (oder 'alle') alle bezahlten * Bestellungen (bezahlt bis erstattet), also ohne unbezahlte `offen` und `storniert`. * suche: Bestellnummer als Anfang (ohne Beruecksichtigung der Schreibweise) oder Teil des Kaeufernamens bzw. der E-Mail. Name und * E-Mail stehen im JSON der Bestellung und nicht in einer durchsuchbaren Spalte, deshalb wird im Speicher gesucht – ueber hoechstens * die neuesten 2000 Bestellungen (`abgeschnitten: true`, wenn dort Schluss war und es aelteres gibt). Das Blaettern liest ebenfalls * hoechstens 2000 Bestellungen (Seite 40). * @param {string} shopId * @param {{ status?: string, suche?: string, seite?: number }} [filter] * @returns {Promise<{ bestellungen: object[], seite: number, mehr: boolean, abgeschnitten: boolean }>} */ export async function listeBestellungen(shopId, { status, suche, seite = 1 } = {}) { if (status !== undefined && status !== null && status !== '' && status !== 'alle' && !STATUS_ALLE.includes(status)) { throw fehler(400, 'status', 'Diesen Status gibt es nicht.') } const nr = Number.isInteger(seite) && seite >= 1 ? Math.min(seite, Math.floor(LISTE_MAX_GELESEN / LISTE_SEITE)) : 1 const gesucht = typeof suche === 'string' ? einzeiler(suche).toLowerCase().slice(0, 100) : '' const statusFilter = !status || status === 'alle' ? STATUS_ECHT : [status] const passt = (doc) => { if (!gesucht) return true if (String(doc.bestellnummer ?? '').toLowerCase().startsWith(gesucht)) return true const kaeufer = bestellDaten(doc).kaeufer const name = typeof kaeufer?.name === 'string' ? kaeufer.name.toLowerCase() : '' const mails = [doc.kaeuferEmail, kaeufer?.email].map((m) => String(m ?? '').toLowerCase()) return name.includes(gesucht) || mails.some((m) => m.includes(gesucht)) } const brauche = nr * LISTE_SEITE + 1 const treffer = [] let gelesen = 0 let cursor = null let ende = false while (treffer.length < brauche && gelesen < LISTE_MAX_GELESEN) { const page = await listDocuments(col(), [ Query.equal('shopId', shopId), Query.equal('status', statusFilter), Query.orderDesc('erstelltAt'), ...(cursor ? [Query.cursorAfter(cursor)] : []), Query.limit(LISTE_BATCH), ]) gelesen += page.length for (const doc of page) if (passt(doc)) treffer.push(doc) if (page.length < LISTE_BATCH) { ende = true break } cursor = page[page.length - 1].$id } const von = (nr - 1) * LISTE_SEITE return { bestellungen: treffer.slice(von, von + LISTE_SEITE).map(listenZeile), seite: nr, mehr: treffer.length > von + LISTE_SEITE, abgeschnitten: !ende && treffer.length < brauche, } } // --------------------------------------------------------------------------------------------------------------------- // CSV const CSV_STATUS = { bezahlt: 'Bezahlt', versendet: 'Versendet', abholbereit: 'Abholbereit', abgeschlossen: 'Abgeschlossen', erstattet: 'Erstattet' } const CSV_KOPF = ['Bestellnummer', 'Datum', 'Status', 'Name', 'E-Mail', 'Lieferart', 'Adresse', 'Positionen', 'Versand', 'Gesamt', 'Erstattet'] /** Betrag fuer Tabellen: '12,50' (kein Tausenderpunkt, kein Euro-Zeichen, kein geschuetztes Leerzeichen). */ const betragCsv = (cent) => `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` /** * Eine CSV-Zelle: Zellen, die mit = + - @ (auch nach fuehrendem Leerraum), Tab oder Wagenruecklauf beginnen, bekommen ein * Apostroph vorangestellt (Formel-Injektion in Excel/Calc); enthaelt die Zelle ; " oder Zeilenumbruch, wird sie in "..." gesetzt. */ export function csvZelle(wert) { let text = wert === null || wert === undefined ? '' : String(wert) if (/^[\s]*[=+\-@]/.test(text) || /^[\t\r]/.test(text)) text = `'${text}` return /[;"\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text } function adresseCsv(daten) { const a = istObjekt(daten.lieferadresse) ? daten.lieferadresse : null if (!a) return daten.lieferart === 'abholung' ? 'Abholung' : '' return [a.line1, a.line2, [a.postal_code, a.city].filter(Boolean).join(' '), a.country].filter((t) => typeof t === 'string' && t !== '').join(', ') } function positionenCsv(daten) { const liste = Array.isArray(daten.positionen) ? daten.positionen : [] return liste.map((p) => `${p.menge} × ${p.name}${p.varianteText ? ` (${p.varianteText})` : ''}`).join(' | ') } /** Eine Bestellung als Zeile; Datum = Zahlungszeitpunkt (sonst Bestellzeitpunkt) in Berliner Zeit. */ function csvZeile(doc) { const daten = bestellDaten(doc) const kaeufer = istObjekt(daten.kaeufer) ? daten.kaeufer : {} const zeit = new Date(doc.bezahltAt || doc.erstelltAt) const summe = Number.isInteger(doc.summeCent) ? doc.summeCent : 0 const versand = Number.isInteger(daten.summen?.versandCent) ? daten.summen.versandCent : 0 return [ doc.bestellnummer, Number.isNaN(zeit.getTime()) ? '' : `${datumKurz(zeit)} ${uhrzeit(zeit)}`, CSV_STATUS[doc.status] ?? doc.status, kaeufer.name, doc.kaeuferEmail || kaeufer.email, daten.lieferart === 'abholung' ? 'Abholung' : 'Versand', adresseCsv(daten), positionenCsv(daten), betragCsv(versand), betragCsv(summe), betragCsv(erstattetCent(daten)), ].map(csvZelle).join(';') } /** 'JJJJ-MM-TT' ist ein wirklich vorhandener Kalendertag (kein 30. Februar). */ function echterTag(tag) { const [j, m, t] = tag.split('-').map(Number) const d = new Date(Date.UTC(j, m - 1, t)) return d.getUTCFullYear() === j && d.getUTCMonth() === m - 1 && d.getUTCDate() === t } function csvTag(wert, name) { if (wert === undefined || wert === null || wert === '') return '' if (typeof wert !== 'string' || !TAG_RE.test(wert) || !echterTag(wert)) { throw fehler(400, 'datum', `${name} ist kein gültiges Datum (Jahr-Monat-Tag, zum Beispiel 2026-10-31).`) } return wert } /** * Bestellungen als CSV fuer die Buchhaltung: UTF-8 mit BOM, `;`-getrennt, Zeilenende CRLF. Enthalten sind alle bezahlten * Bestellungen (auch erstattete), deren Datum (Zahlung, sonst Bestellung; Berliner Kalendertag) zwischen von und bis liegt * (beide einschliesslich, 'JJJJ-MM-TT', jede Grenze optional). Spalten: Bestellnummer, Datum, Status, Name, E-Mail, Lieferart, Adresse, * Positionen, Versand, Gesamt, Erstattet (Betraege wie 12,50 in Euro). Zellen, die mit = + - @ beginnen, bekommen ein Apostroph. * @param {string} shopId * @param {{ von?: string, bis?: string }} [zeitraum] * @param {{ now?: Date }} [opt] * @returns {Promise<{ csv: string, dateiname: string, anzahl: number }>} */ export async function csv(shopId, { von, bis } = {}, { now = new Date() } = {}) { const vonTag = csvTag(von, '„Von“') const bisTag = csvTag(bis, '„Bis“') if (vonTag && bisTag && vonTag > bisTag) throw fehler(400, 'datum', '„Von“ darf nicht nach „Bis“ liegen.') const zeilen = [] let cursor = null for (;;) { const page = await listDocuments(col(), [ Query.equal('shopId', shopId), Query.equal('status', STATUS_ECHT), // Eine Bestellung kann nicht vor ihrer Anlage bezahlt sein: spaeter Angelegtes faellt sicher aus dem Zeitraum ...(bisTag ? [Query.lessThan('erstelltAt', zuUtc(plusTage(bisTag, 1), '00:00').toISOString())] : []), Query.orderAsc('$id'), ...(cursor ? [Query.cursorAfter(cursor)] : []), Query.limit(CSV_BATCH), ]) for (const doc of page) { const tag = heute(new Date(doc.bezahltAt || doc.erstelltAt)) if ((vonTag && tag < vonTag) || (bisTag && tag > bisTag)) continue zeilen.push({ tag, zeit: new Date(doc.bezahltAt || doc.erstelltAt).getTime(), zeile: csvZeile(doc) }) if (zeilen.length > CSV_MAX_ZEILEN) throw fehler(400, 'zu_viele', 'Das sind zu viele Bestellungen auf einmal. Bitte wähle einen kürzeren Zeitraum.') } if (page.length < CSV_BATCH) break cursor = page[page.length - 1].$id } zeilen.sort((a, b) => a.zeit - b.zeit) const inhalt = `${[CSV_KOPF.join(';'), ...zeilen.map((z) => z.zeile)].join('\r\n')}\r\n` return { csv: inhalt, dateiname: `webshop-bestellungen-${vonTag || 'anfang'}-${bisTag || heute(new Date(now))}.csv`, anzahl: zeilen.length } }