Shop E1: Bestellung anlegen (Reservierung unter Sperre, Stripe-Kasse auf dem Händlerkonto, Rück-URL-Prüfung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:48:56 +00:00
1 parent da43ee2b41
commit 9d19a2cbad
2 files changed
+775

No files matched your search

+359
View File
@@ -0,0 +1,359 @@
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
import { config } from '../../config.js'
import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js'
import { analyseDomains } from '../analyse.js'
import { withLocks } from '../keyedLock.js'
import { checkoutAnlegen } from './connect.js'
import { istGeoeffnet, leereCache } from './kontext.js'
import { berechne } from './preise.js'
import { listeProdukte } from './produkte.js'
/**
* Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung).
*
* Datenlage einer Bestellung (shopOrders): Spalten shopId, customerId, bestellnummer, status, tokenHash, stripeSessionId,
* paymentIntentId, summeCent, erstelltAt, ablaufAt, bezahltAt, kaeuferEmail, bestellung (JSON-Text, <= 60000), anonymisiertAt.
* bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen):
* { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname,
* lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] }
* E2 ergaenzt kaeufer / lieferadresse / erstattungen[] / hinweise im selben JSON. Status offen reserviert Bestand bis ablaufAt.
* Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message.
*/
const NUMMER_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const NUMMER_VERSUCHE = 5
/** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */
const SESSION_RESERVE_S = 30
const MAX_JSON = 60000
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.'
const KLARNA_HINWEIS =
'Klarna/SEPA ist in Deinem Stripe-Konto nicht aktiviert. Bestellungen wurden deshalb nur mit Karte angeboten. ' +
'Bitte aktiviere die Zahlungsarten in Deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.'
const col = () => config.collections.shopOrders
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function fehler(status, code, text, extra = {}) {
return Object.assign(new Error(text), { status, code, ...extra })
}
// ---------------------------------------------------------------------------------------------------------------------
// Hilfen
/** Gespeicherten JSON-Text einer Bestellung lesen; bei kaputten Daten ein leeres Objekt. */
export function bestellDaten(doc) {
try {
const d = JSON.parse(doc?.bestellung)
return istObjekt(d) ? d : {}
} catch {
return {}
}
}
/** Bestellung per ID: Dokument mit zusaetzlichem Feld daten (geparstes bestellung) oder null, wenn es sie nicht gibt. */
export async function bestellungLesen(orderId) {
if (typeof orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(orderId)) return null
let doc
try {
doc = await getDocument(col(), orderId)
} catch (err) {
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null
throw err
}
return { ...doc, daten: bestellDaten(doc) }
}
export const tokenHash = (token) => createHash('sha256').update(String(token ?? '')).digest('hex')
/** Passt der Token aus der Rueckkehr-Adresse zur Bestellung? Vergleich in konstanter Zeit ueber den SHA-256-Hash. */
export function tokenPasst(order, token) {
if (typeof token !== 'string' || token === '' || typeof order?.tokenHash !== 'string' || order.tokenHash === '') return false
const a = Buffer.from(tokenHash(token), 'hex')
const b = Buffer.from(order.tokenHash, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId)
/**
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft).
* @returns {Promise<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')])
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 `<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 }} [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 }
}