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:
1 parent
da43ee2b41
commit
9d19a2cbad
2 files changed
+775
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user