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>
612 lines
28 KiB
JavaScript
612 lines
28 KiB
JavaScript
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
|
||
}
|