Files
Webklar-Kundenbereich/server/services/shop/bestellungen.js
T
ClaudeandClaude Opus 5.5 bf99f659eb Shop E2: Abschluss (schliesseAb), verwirf, Abgleich offener Bestellungen, Minutenjob
schliesseAb ist die einzige Stelle fuer den Status bezahlt: Sperre je Bestellung, Konto muss zum Shop passen,
idempotent, spaete Zahlung nach Ablauf wird angenommen, SEPA erst bei bezahlt, Bestandsabzug mit Ueberverkauft-Hinweis,
G09 an den Kaeufer und K25 an den Haendler (Mailfehler machen nichts rueckgaengig). pruefeOffene und jobs.js minutenJob
gleichen offene Bestellungen mit Stripe ab. fakeStripe: completeSession.

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

612 lines
28 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, sessionAblaufen, sessionHolen } from './connect.js'
import { istGeoeffnet, leereCache, shopPerSiteKey } from './kontext.js'
import { anHaendler, anKaeufer, haendlerK25 } from './mails.js'
import { berechne } from './preise.js'
import { holeProdukt, listeProdukte, parseProdukt } from './produkte.js'
/**
* Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung).
*
* Datenlage einer Bestellung (shopOrders): Spalten shopId, customerId, bestellnummer, status, tokenHash, stripeSessionId,
* paymentIntentId, summeCent, erstelltAt, ablaufAt, bezahltAt, kaeuferEmail, bestellung (JSON-Text, <= 60000), anonymisiertAt.
* bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen):
* { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname,
* lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] }
* E2 ergaenzt im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code,
* city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt.
* Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message.
*/
const NUMMER_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const NUMMER_VERSUCHE = 5
/** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */
const SESSION_RESERVE_S = 30
const MAX_JSON = 60000
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.'
const KLARNA_HINWEIS =
'Klarna/SEPA ist in Deinem Stripe-Konto nicht aktiviert. Bestellungen wurden deshalb nur mit Karte angeboten. ' +
'Bitte aktiviere die Zahlungsarten in Deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.'
const col = () => config.collections.shopOrders
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
function fehler(status, code, text, extra = {}) {
return Object.assign(new Error(text), { status, code, ...extra })
}
// ---------------------------------------------------------------------------------------------------------------------
// Hilfen
/** Gespeicherten JSON-Text einer Bestellung lesen; bei kaputten Daten ein leeres Objekt. */
export function bestellDaten(doc) {
try {
const d = JSON.parse(doc?.bestellung)
return istObjekt(d) ? d : {}
} catch {
return {}
}
}
/** Bestellung per ID: Dokument mit zusaetzlichem Feld daten (geparstes bestellung) oder null, wenn es sie nicht gibt. */
export async function bestellungLesen(orderId) {
if (typeof orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(orderId)) return null
let doc
try {
doc = await getDocument(col(), orderId)
} catch (err) {
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null
throw err
}
return { ...doc, daten: bestellDaten(doc) }
}
export const tokenHash = (token) => createHash('sha256').update(String(token ?? '')).digest('hex')
/** Passt der Token aus der Rueckkehr-Adresse zur Bestellung? Vergleich in konstanter Zeit ueber den SHA-256-Hash. */
export function tokenPasst(order, token) {
if (typeof token !== 'string' || token === '' || typeof order?.tokenHash !== 'string' || order.tokenHash === '') return false
const a = Buffer.from(tokenHash(token), 'hex')
const b = Buffer.from(order.tokenHash, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId)
/**
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft).
* @returns {Promise<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 }
}
// ---------------------------------------------------------------------------------------------------------------------
// Abschluss (E2): bezahlt, verwerfen, Ablauf
const ZAHLUNGSARTEN = new Set(['card', 'klarna', 'sepa_debit'])
const ABGLEICH_PUFFER_MS = 5 * 60 * 1000
const kurz = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max) : '')
const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '')
const verlaufMit = (daten, eintrag) => [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), eintrag]
/** Stripe-Anschrift {line1, line2, postal_code, city, country} (+ name) als schlankes Objekt oder null. */
function anschrift(adresse, name) {
if (!istObjekt(adresse)) return null
const a = {
...(kurz(name, 200) ? { name: kurz(name, 200) } : {}),
line1: kurz(adresse.line1, 200),
line2: kurz(adresse.line2, 200),
postal_code: kurz(adresse.postal_code, 20),
city: kurz(adresse.city, 100),
country: kurz(adresse.country, 2).toUpperCase(),
}
return a.line1 || a.postal_code || a.city ? a : null
}
/** Zahlungsart nur, wenn sie eindeutig ist (eine erlaubte Art oder die bezahlte Art des erweiterten PaymentIntents). */
function zahlungsartVon(session) {
const arten = Array.isArray(session.payment_method_types) ? session.payment_method_types.filter((a) => ZAHLUNGSARTEN.has(a)) : []
if (arten.length === 1 && session.payment_method_types.length === 1) return arten[0]
const bezahlt = session.payment_intent?.payment_method?.type
return ZAHLUNGSARTEN.has(bezahlt) ? bezahlt : ''
}
async function shopDokument(shopId) {
try {
return await getDocument(config.collections.shopConfig, shopId)
} catch (err) {
if (err?.status === 404) return null
throw err
}
}
/**
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Unter den Sperren des Bestands und der Artikel.
* @returns {Promise<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])
}
if (nachArtikel.size === 0) return false
await withLocks([`shop-bestand:${shopId}`, `shop-produkte:${shopId}`], async () => {
for (const [produktId, liste] of nachArtikel) {
const doc = await holeProdukt(shopId, produktId)
const produkt = doc ? parseProdukt(doc) : null
if (!produkt) continue
let geaendert = false
const abziehen = (objekt, menge) => {
if (!Number.isInteger(objekt.bestand)) return
if (objekt.bestand < menge) ueberverkauft = true
objekt.bestand = Math.max(0, objekt.bestand - menge)
geaendert = true
}
for (const pos of liste) {
const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null
if (pos.varianteId && !wert) continue
abziehen(wert ?? produkt, pos.menge)
}
if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) })
}
})
return ueberverkauft
}
/**
* Schliesst eine bezahlte Checkout-Sitzung ab: einzige Stelle fuer den Status bezahlt. Idempotent (zweiter Aufruf aendert nichts
* und schickt keine Mail), nimmt auch spaete Zahlungen einer abgelaufenen Bestellung an. Mailfehler machen die Zahlung nie rueckgaengig.
* @param {{ accountId: string, session: object, now?: Date }} p session = Stripe-Checkout-Session (Webhook oder retrieve)
* @returns {Promise<{ order: object|null, neu: boolean }>} order mit daten; null, wenn es keine passende Bestellung gibt
*/
export async function schliesseAb({ accountId, session, now = new Date() }) {
const orderId = session?.metadata?.orderId
if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { order: null, neu: false }
const jetzt = new Date(now)
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
const order = await bestellungLesen(orderId)
if (!order) return { order: null, neu: false }
const shop = await shopDokument(order.shopId)
if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) {
console.warn(`[shop] Bestellung ${orderId.slice(0, 36)} gehört nicht zum Stripe-Konto der Sitzung, ignoriert`)
return { order: null, neu: false }
}
const daten = order.daten
const spaet = order.status === 'storniert' && Array.isArray(daten.verlauf) && daten.verlauf.some((v) => v?.was === 'abgelaufen')
if (order.status !== 'offen' && !spaet) return { order, neu: false }
if (session.payment_status !== 'paid') return { order, neu: false }
const kunde = istObjekt(session.customer_details) ? session.customer_details : {}
const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details
const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null
const rechnungsadresse = anschrift(kunde.address, kunde.name)
const zahlungsart = zahlungsartVon(session)
const email = kurz(kunde.email, 320)
const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent
if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`)
const at = jetzt.toISOString()
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
const neueDaten = {
...daten,
kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
...(lieferadresse ? { lieferadresse } : {}),
...(rechnungsadresse ? { rechnungsadresse } : {}),
...(zahlungsart ? { zahlungsart } : {}),
...(spaet ? { spaet: true } : {}),
verlauf,
}
const felder = {
status: 'bezahlt',
bezahltAt: at,
bestellung: JSON.stringify(neueDaten),
...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}),
...(email ? { kaeuferEmail: email } : {}),
}
if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined })
let doc = await updateDocument(col(), orderId, felder)
const ueberverkauft = await zieheBestandAb(order.shopId, daten.positionen)
if (ueberverkauft) {
const mitHinweis = { ...neueDaten, ueberverkauft: true }
doc = await updateDocument(col(), orderId, { bestellung: JSON.stringify(mitHinweis) }).catch(() => doc)
}
return { order: { ...doc, daten: bestellDaten(doc) }, neu: true, ueberverkauft, siteKey: shop.siteKey }
})
if (!ergebnis.neu) return { order: ergebnis.order, neu: false }
// Mails: ausserhalb der Sperre, Fehler zaehlen nicht
const { order } = ergebnis
try {
leereCache(ergebnis.siteKey)
const ctx = await shopPerSiteKey(ergebnis.siteKey, { now: jetzt })
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
await anKaeufer('G09', ctx, order, {}, jetzt)
const k25 = haendlerK25(ctx, order, { ueberverkauft: ergebnis.ueberverkauft })
await anHaendler('K25', ctx, k25.values, k25.blocks)
} catch (err) {
console.warn(`[shop] Mails zur bezahlten Bestellung ${orderId.slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
return { order, neu: true }
}
/**
* Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung.
* @param {string} orderId
* @param {'abgelaufen'|'zahlung_fehlgeschlagen'|string} grund Eintrag im Verlauf
* @param {{ now?: Date, mail?: boolean, email?: string, accountId?: string }} [opt] mail: G14 an den Kaeufer (email oder kaeuferEmail);
* accountId: Konto der Sitzung, muss zum Shop der Bestellung passen (Webhook)
* @returns {Promise<{ order: object|null, verworfen: boolean }>}
*/
export async function verwirf(orderId, grund, { now = new Date(), mail = false, email, accountId } = {}) {
const jetzt = new Date(now)
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
const order = await bestellungLesen(orderId)
if (!order) return { order: null, verworfen: false }
if (accountId !== undefined) {
const shop = await shopDokument(order.shopId)
if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false }
}
if (order.status !== 'offen') return { order, verworfen: false }
const doc = await updateDocument(col(), orderId, {
status: 'storniert',
bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }),
})
return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true }
})
if (ergebnis.verworfen && mail) {
const an = kurz(email, 320) || kurz(ergebnis.order.kaeuferEmail, 320)
if (an) {
try {
const shop = await shopDokument(ergebnis.order.shopId)
if (shop) {
leereCache(shop.siteKey)
const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt })
if (ctx) await anKaeufer('G14', ctx, ergebnis.order, { an }, jetzt)
}
} catch (err) {
console.warn(`[shop] G14 zur Bestellung ${String(orderId).slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
}
return ergebnis
}
/**
* Abgleich der offenen Bestellungen, deren Frist (ablaufAt) seit mehr als 5 Minuten verstrichen ist (Minutenjob): Stripe fragen
* und bezahlt abschliessen, verwerfen oder (noch offene Sitzung) beenden und verwerfen. Lesefehler: naechster Lauf.
* @returns {Promise<{ geprueft: number, bezahlt: number, verworfen: number, wartend: number, fehler: number }>}
*/
export async function pruefeOffene(now = new Date()) {
const grenze = new Date(now).getTime() - ABGLEICH_PUFFER_MS
const zaehler = { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }
const shops = await listAllDocuments(config.collections.shopConfig, [])
for (const shop of shops) {
const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter(
(d) => new Date(d.ablaufAt).getTime() < grenze
)
for (const order of offene) {
zaehler.geprueft += 1
try {
if (!order.stripeSessionId || !shop.stripeAccountId) {
// Es wurde nie eine Sitzung gespeichert: es kann nichts bezahlt werden
if (!order.stripeSessionId) {
const erg = await verwirf(order.$id, 'abgelaufen', { now })
if (erg.verworfen) zaehler.verworfen += 1
} else zaehler.wartend += 1
continue
}
let session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId)
if (session?.status === 'complete') {
if (session.payment_status === 'paid') {
const erg = await schliesseAb({ accountId: shop.stripeAccountId, session, now })
if (erg.neu) zaehler.bezahlt += 1
} else zaehler.wartend += 1 // SEPA unterwegs: async_payment_succeeded/failed entscheidet
continue
}
if (session?.status === 'open') {
session = await sessionAblaufen(shop.stripeAccountId, order.stripeSessionId)
if (session?.status !== 'expired') {
zaehler.wartend += 1
continue
}
} else if (session?.status !== 'expired') {
zaehler.wartend += 1
continue
}
const erg = await verwirf(order.$id, 'abgelaufen', { now })
if (erg.verworfen) zaehler.verworfen += 1
} catch (err) {
zaehler.fehler += 1
console.warn(`[shop] Abgleich der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
}
return zaehler
}