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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:01:04 +00:00
1 parent 9de23c7fe2
commit bf99f659eb
4 files changed
+741 -4

No files matched your search

+256 -4
View File
@@ -3,10 +3,11 @@ 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 { 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 { listeProdukte } from './produkte.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).
@@ -16,7 +17,8 @@ import { listeProdukte } from './produkte.js'
* 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.
* 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.
*/
@@ -357,3 +359,253 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {})
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
}
+31
View File
@@ -0,0 +1,31 @@
import { pruefeOffene } from './bestellungen.js'
import { shopModus } from './schalter.js'
/**
* Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3 ergaenzt den Tagesjob und die Registrierung in index.js).
* Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten Takt) und wirft nie.
*/
const laufend = new Set()
async function einzeln(name, arbeit) {
if (laufend.has(name)) {
console.warn(`[shop-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
return { uebersprungen: true }
}
laufend.add(name)
try {
return await arbeit()
} catch (err) {
console.error(`[shop-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`)
return { fehler: true }
} finally {
laufend.delete(name)
}
}
/** Minutenjob: offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (bezahlt abschliessen oder verwerfen). Bei SHOP_ENABLED=off aus. */
export async function minutenJob(now = new Date()) {
if (shopModus() === 'off') return { aus: true }
return einzeln('minutenJob', () => pruefeOffene(now))
}