Files
Webklar-Kundenbereich/server/services/shop/bestellungen.js
T

360 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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 }
}