Files
Webklar-Kundenbereich/server/services/shop/bestellungen.js
T
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist
(Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon
übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00

1260 lines
64 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<string, number>>} 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 `<JJ>-<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
* `<Portal>/shop/<siteKey>`.
*/
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:<shopId>` und danach `shop-produkte:<shopId>` (Reihenfolge immer so; withLocks ist nicht reentrant).
* @returns {Promise<boolean>} 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:<id>; 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:<id>: zwei gleichzeitige Erstattungen erstatten nie doppelt (die zweite sieht den neuen Rest). Idempotenz-Schluessel
* `shop-erstattung-<orderId>-<Anzahl bisheriger Erstattungen>`. 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 }
}