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, sessionAblaufen, sessionHolen } from './connect.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, telefon }, 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. * 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 } } // --------------------------------------------------------------------------------------------------------------------- // 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). Unter den Sperren des Bestands und der Artikel. * @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]) } if (nachArtikel.size === 0) return false await withLocks([`shop-bestand:${shopId}`, `shop-produkte:${shopId}`], async () => { 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 } /** * 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) const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { const 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') if (order.status !== 'offen' && !spaet) return { order, neu: false } 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 }) const neueDaten = { ...daten, kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) }, ...(lieferadresse ? { lieferadresse } : {}), ...(rechnungsadresse ? { rechnungsadresse } : {}), ...(zahlungsart ? { zahlungsart } : {}), ...(spaet ? { spaet: true } : {}), 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 }) let doc = await updateDocument(col(), orderId, felder) const ueberverkauft = await zieheBestandAb(order.shopId, daten.positionen) if (ueberverkauft) { const mitHinweis = { ...neueDaten, ueberverkauft: true } doc = await updateDocument(col(), orderId, { bestellung: JSON.stringify(mitHinweis) }).catch(() => doc) } return { order: { ...doc, daten: bestellDaten(doc) }, neu: true, ueberverkauft, siteKey: shop.siteKey } }) if (!ergebnis.neu) return { order: ergebnis.order, neu: false } // Mails: ausserhalb der Sperre, Fehler zaehlen nicht const { order } = ergebnis try { leereCache(ergebnis.siteKey) const ctx = await shopPerSiteKey(ergebnis.siteKey, { now: jetzt }) if (!ctx) throw new Error('Shop-Kontext nicht gefunden') await anKaeufer('G09', ctx, order, {}, jetzt) const k25 = haendlerK25(ctx, order, { ueberverkauft: ergebnis.ueberverkauft }) await anHaendler('K25', ctx, k25.values, k25.blocks) } catch (err) { console.warn(`[shop] Mails zur bezahlten Bestellung ${orderId.slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`) } return { order, neu: true } } /** * 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 } const doc = await updateDocument(col(), orderId, { status: 'storniert', bestellung: JSON.stringify({ ...order.daten, 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 ) 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 zaehler.wartend += 1 // SEPA unterwegs: async_payment_succeeded/failed entscheidet 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 }