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:
1 parent
42a6bb5a4a
commit
9c2897a0b0
4 files changed
+305
-83
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user