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

+4 -3
View File
@@ -3,7 +3,7 @@ import { config } from '../config.js'
import { analyseDomains } from '../services/analyse.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { bildMeta, bildPfad } from '../services/shop/bilder.js'
import { bestellungLesen, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js'
import { abschlussAusstehend, bestellungLesen, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js'
import { sessionHolen } from '../services/shop/connect.js'
import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js'
import { euro } from '../services/shop/geld.js'
@@ -593,9 +593,10 @@ router.get(
let order = await bestellungLesen(id)
if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine()
// Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers)
// Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers); bezahlt, aber mit
// fehlendem Bestandsabzug oder fehlenden Mails (Teilfehler): schliesseAb holt die Schritte nach
const accountId = ctx.shop.stripeAccountId
if (order.status === 'offen' && order.stripeSessionId && accountId) {
if ((order.status === 'offen' || abschlussAusstehend(order)) && order.stripeSessionId && accountId) {
try {
const session = await sessionHolen(accountId, order.stripeSessionId)
if (session?.status === 'complete' && session.payment_status === 'paid') {
+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 {
+25
View File
@@ -450,6 +450,31 @@ test('Status: offen ⇒ bezahlt, sobald Stripe die Sitzung als bezahlt meldet; E
assert.equal(sink.sent.length, mailsVorher)
})
test('Status I2: bezahlte Bestellung mit fehlendem Bestandsabzug wird beim nächsten Abruf nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 10 })] })
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, {
payment_status: 'paid',
amount_total: 2990,
customer_details: { name: 'Max Mustermann', email: 'max@example.com' },
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
payment_intent: 'pi_test1',
})
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
const erster = await status(orderId, t)
assert.equal(erster.status, 200)
assert.equal(orders()[0].status, 'bezahlt')
assert.equal(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt, null)
const zweiter = await status(orderId, t)
assert.equal(zweiter.body.status, 'bezahlt')
assert.ok(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt)
await warteAufVersand()
const mails = sink.sent.length
await status(orderId, t)
await warteAufVersand()
assert.equal(sink.sent.length, mails, 'Mails nur einmal')
})
test('Status: nicht bezahlte Sitzung (z. B. SEPA unterwegs) bleibt offen; Stripe-Fehler beim Abgleich ⇒ trotzdem Antwort', async () => {
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, { payment_status: 'unpaid', amount_total: 2990 })
+122
View File
@@ -429,3 +429,125 @@ test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt
assert.equal(order(b.orderId).status, 'bezahlt')
assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
})
// ---------------------------------------------------------------------------------------------------------------------
// Review A: I1 (Statuswechsel und Bestandsabzug unter einer Sperre) und I2 (fehlende Schritte nachholen)
test('schliesseAb I1: Statuswechsel und Bestandsabzug laufen unter der Bestandssperre - eine parallele Bestellung kann den letzten Artikel nicht noch einmal bekommen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 2 }), artikel('a2', {})] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
const echtesFetch = globalThis.fetch
let konkurrenz = null
globalThis.fetch = async (url, opt = {}) => {
const antwort = await echtesFetch(url, opt)
const pfad = String(url)
if (!konkurrenz && opt.method === 'PATCH' && pfad.includes(`/${config.collections.shopOrders}/documents/${b.orderId}`) && String(opt.body).includes('"bezahlt"')) {
// Die Bestellung ist jetzt bezahlt, der Bestand noch nicht abgezogen: genau jetzt kommt ein zweiter Kaeufer
konkurrenz = B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE' }, { now: nach(61) }).then(
(r) => ({ ok: true, r }),
(e) => ({ ok: false, e })
)
await new Promise((r) => setTimeout(r, 80))
}
return antwort
}
try {
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
const zweiter = await konkurrenz
assert.equal(zweiter.ok, false, 'der letzte Artikel darf nicht noch einmal reserviert werden')
assert.equal(zweiter.e.code, 'ausverkauft')
} finally {
globalThis.fetch = echtesFetch
}
assert.equal(produkt('a1').bestand, 0)
assert.equal(orders().filter((o) => o.status === 'offen').length, 0)
})
test('schliesseAb I2: scheitert der Bestandsabzug, bleibt der Artikel reserviert; der zweite Lauf holt den Abzug einmal nach und verschickt die Mails einmal', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
assert.equal(order(b.orderId).status, 'bezahlt')
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.bestandAbgezogenAt, null)
assert.equal(produkt('a1').bestand, 5)
assert.equal((await B.reserviert(FEATURE, nach(70))).get('a1'), 2, 'noch nicht abgezogen => weiter reserviert')
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const wieder = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(wieder.neu, false)
assert.equal(produkt('a1').bestand, 3)
const nachher = JSON.parse(order(b.orderId).bestellung)
assert.ok(nachher.bestandAbgezogenAt && nachher.mailsKaeuferAt && nachher.mailsHaendlerAt)
assert.equal((await B.reserviert(FEATURE, nach(130))).get('a1'), undefined)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(180) })
await warteAufVersand()
assert.equal(produkt('a1').bestand, 3, 'Bestand nur einmal')
assert.equal(sink.sent.length, 2, 'Mails nur einmal')
assert.equal(eintraege().length, 1)
})
let kaputt = false
const kaputtSchalter = () => (kaputt = false) || setMailTransportForTests({ sent: sink.sent, sendMail: (mail) => (kaputt ? Promise.reject(new Error('smtp kaputt')) : sink.sendMail(mail)) })
test('schliesseAb I2: G09 und K25 werden einzeln nachgeholt, sobald sie scheitern, und nie doppelt verschickt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
aw.failNext({ collection: config.collections.termineOutbox, method: 'POST', status: 500 })
kaputt = true
const erst = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
assert.equal(erst.neu, true)
await warteAufVersand()
let daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsKaeuferAt, null, 'G09 nicht eingereiht => offen')
assert.equal(daten.mailsHaendlerAt, null, 'K25 nicht gesendet => offen')
assert.equal(sink.sent.length, 0)
kaputt = false
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
await warteAufVersand()
daten = JSON.parse(order(b.orderId).bestellung)
assert.ok(daten.mailsKaeuferAt && daten.mailsHaendlerAt)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3, 'Bestand blieb bei einmal abgezogen')
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(240) })
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.equal(eintraege().length, 1)
})
test('schliesseAb I2: ein dauerhaft scheiternder Mailschritt wird nicht endlos wiederholt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
kaputt = true
for (let i = 0; i < 8; i += 1) await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60 + i) })
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsHaendlerAt, null)
assert.ok(daten.mailsVersuche.haendler <= 5)
assert.equal(B.abschlussAusstehend(order(b.orderId)), false, 'nach dem Versuchslimit nichts mehr ausstehend')
})
test('pruefeOffene I2: bezahlte Bestellungen mit fehlenden Schritten werden vom Minutenjob nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
const z = await B.pruefeOffene(nach(60 + 6 * 60))
assert.equal(z.fehler, 0)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
})