diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index f7f0ffc..e2d08eb 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -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') { diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index 454b6f0..26ee603 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -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>} 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:` und danach `shop-produkte:` (Reihenfolge immer so; withLocks ist nicht reentrant). * @returns {Promise} 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 { diff --git a/test/publicShopKasse.test.js b/test/publicShopKasse.test.js index 769569b..89dbaca 100644 --- a/test/publicShopKasse.test.js +++ b/test/publicShopKasse.test.js @@ -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 }) diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js index 00eaa56..a45c281 100644 --- a/test/shopAbschluss.test.js +++ b/test/shopAbschluss.test.js @@ -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) +})