Shop Review A I1/I2: Statuswechsel und Bestandsabzug unter einer Sperre, fehlende Schritte (Bestand, G09, K25) per Marker nachholen

Der Bestandsabzug laeuft jetzt im selben Block unter shop-bestand und shop-produkte wie der Statuswechsel auf bezahlt, zieheBestandAb haelt keine eigene Sperre mehr. Im Bestell-JSON stehen bestandAbgezogenAt, mailsKaeuferAt und mailsHaendlerAt als Marker; schliesseAb (Webhook-Wiederholung, Statusabruf, Minutenjob) holt fehlende Schritte genau einmal nach, Mails hoechstens fuenfmal versucht. Bezahlte Bestellungen ohne Bestandsabzug zaehlen weiter als reserviert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:30:36 +00:00
1 parent 42a6bb5a4a
commit 9c2897a0b0
4 files changed
+305 -83

No files matched your search

+154 -80
View File
@@ -82,17 +82,22 @@ export function tokenPasst(order, token) {
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).
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft) oder in bezahlten
* Bestellungen, deren Bestandsabzug noch aussteht (bestandAbgezogenAt === null, Teilfehler beim Abschluss). So gibt es nie einen
* Moment, in dem eine Bestellung weder reserviert noch abgezogen ist.
* @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 docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', ['offen', 'bezahlt'])])
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
const daten = bestellDaten(doc)
if (doc.status === 'offen') {
const ablauf = new Date(doc.ablaufAt).getTime()
if (!(ablauf > nowMs)) continue
} else if (daten.bestandAbgezogenAt !== null) continue
const positionen = daten.positionen
if (!Array.isArray(positionen)) continue
for (const pos of positionen) {
if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue
@@ -404,7 +409,8 @@ async function shopDokument(shopId) {
}
/**
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Unter den Sperren des Bestands und der Artikel.
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Der AUFRUFER haelt die Sperren
* `shop-bestand:<shopId>` und danach `shop-produkte:<shopId>` (Reihenfolge immer so; withLocks ist nicht reentrant).
* @returns {Promise<boolean>} true, wenn der Bestand eines Artikels nicht reichte (auf 0 gesetzt)
*/
async function zieheBestandAb(shopId, positionen) {
@@ -414,30 +420,83 @@ async function zieheBestandAb(shopId, 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) })
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
}
const MAIL_VERSUCHE_MAX = 5
const bestandSperren = (shopId) => [`shop-bestand:${shopId}`, `shop-produkte:${shopId}`]
/** Welche Schritte nach dem Statuswechsel noch fehlen (Marker im Bestell-JSON stehen dann explizit auf null). */
function offeneSchritte(daten) {
const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {}
return {
bestand: daten.bestandAbgezogenAt === null,
kaeufer: daten.mailsKaeuferAt === null && (versuche.kaeufer ?? 0) < MAIL_VERSUCHE_MAX,
haendler: daten.mailsHaendlerAt === null && (versuche.haendler ?? 0) < MAIL_VERSUCHE_MAX,
}
}
/** Fehlt bei einer bezahlten Bestellung noch der Bestandsabzug oder eine Mail? (Webhook-Wiederholung, Status, Minutenjob) */
export function abschlussAusstehend(order) {
if (!STATUS_BEZAHLT.includes(order?.status)) return false
const daten = order.daten ?? bestellDaten(order)
const o = offeneSchritte(daten)
return o.bestand || o.kaeufer || o.haendler
}
/** Bestandsabzug mit Marker; Sperren muessen gehalten werden. Gibt die neue Bestellung (mit daten) zurueck. */
async function bestandSchritt(order, jetzt) {
const ueberverkauft = await zieheBestandAb(order.shopId, order.daten.positionen)
const daten = { ...order.daten, bestandAbgezogenAt: jetzt.toISOString(), ...(ueberverkauft ? { ueberverkauft: true } : {}) }
const doc = await updateDocument(col(), order.$id, { bestellung: JSON.stringify(daten) })
return { ...doc, daten: bestellDaten(doc) }
}
/** Fehlende Mails (G09, K25) einzeln verschicken; Marker erst nach Erfolg, Versuche gezaehlt. Unter der Bestellsperre. */
async function mailSchritte(order, siteKey, jetzt) {
let aktuell = order
const merke = async (aenderung) => {
const doc = await updateDocument(col(), aktuell.$id, { bestellung: JSON.stringify({ ...aktuell.daten, ...aenderung }) })
aktuell = { ...doc, daten: bestellDaten(doc) }
}
const zaehle = (art) => ({ mailsVersuche: { ...(istObjekt(aktuell.daten.mailsVersuche) ? aktuell.daten.mailsVersuche : {}), [art]: (aktuell.daten.mailsVersuche?.[art] ?? 0) + 1 } })
try {
leereCache(siteKey)
const ctx = await shopPerSiteKey(siteKey, { now: jetzt })
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
if (offeneSchritte(aktuell.daten).kaeufer) {
const r = await anKaeufer('G09', ctx, aktuell, {}, jetzt)
await merke(r.ok ? { mailsKaeuferAt: jetzt.toISOString() } : zaehle('kaeufer'))
}
if (offeneSchritte(aktuell.daten).haendler) {
const k25 = haendlerK25(ctx, aktuell, { ueberverkauft: aktuell.daten.ueberverkauft === true })
const r = await anHaendler('K25', ctx, k25.values, k25.blocks)
await merke(r.ok ? { mailsHaendlerAt: jetzt.toISOString() } : zaehle('haendler'))
}
} catch (err) {
console.warn(`[shop] Mails zur bezahlten Bestellung ${String(order.$id).slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
return aktuell
}
/**
* 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.
@@ -449,8 +508,8 @@ export async function schliesseAb({ accountId, session, now = new Date() }) {
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)
return withLocks([`shop-order:${orderId}`], async () => {
let 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) {
@@ -459,62 +518,60 @@ export async function schliesseAb({ accountId, session, now = new Date() }) {
}
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 }
let 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})`)
if (order.status === 'offen' || spaet) {
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 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 } : {}),
// Marker: null = Schritt steht noch aus, wird erst nach Erfolg mit der Zeit gesetzt (siehe offeneSchritte)
bestandAbgezogenAt: null,
mailsKaeuferAt: null,
mailsHaendlerAt: null,
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 })
// Statuswechsel und Bestandsabzug unter derselben Sperre: legeBestellungAn sieht nie "weder reserviert noch abgezogen"
order = await withLocks(bestandSperren(order.shopId), async () => {
const doc = await updateDocument(col(), orderId, felder)
return bestandSchritt({ ...doc, daten: bestellDaten(doc) }, jetzt)
})
neu = true
} else if (STATUS_BEZAHLT.includes(order.status) && offeneSchritte(daten).bestand) {
// Wiederholung nach Teilfehler: Bestandsabzug nachholen (genau einmal, Marker)
order = await withLocks(bestandSperren(order.shopId), async () => {
const frisch = await bestellungLesen(orderId)
return frisch && offeneSchritte(frisch.daten).bestand ? bestandSchritt(frisch, jetzt) : (frisch ?? order)
})
} else if (!STATUS_BEZAHLT.includes(order.status)) return { order, neu: false }
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 (offeneSchritte(order.daten).kaeufer || offeneSchritte(order.daten).haendler) order = await mailSchritte(order, shop.siteKey, jetzt)
return { order, neu }
})
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 }
}
/**
@@ -572,6 +629,23 @@ export async function pruefeOffene(now = new Date()) {
const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter(
(d) => new Date(d.ablaufAt).getTime() < grenze
)
// Bezahlte Bestellungen, bei denen nach einem Teilfehler noch Bestandsabzug oder Mails fehlen: ueber schliesseAb nachholen
const unfertig = shop.stripeAccountId
? (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'bezahlt')])).filter(
(d) => new Date(d.bezahltAt).getTime() < grenze && abschlussAusstehend({ ...d, daten: bestellDaten(d) })
)
: []
for (const order of unfertig) {
zaehler.geprueft += 1
try {
if (!order.stripeSessionId) continue
const session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId)
if (session) await schliesseAb({ accountId: shop.stripeAccountId, session, now })
} catch (err) {
zaehler.fehler += 1
console.warn(`[shop] Nachholen der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
}
}
for (const order of offene) {
zaehler.geprueft += 1
try {