From bf99f659eb2159c1db4dbdabd46cc896570393f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:01:04 +0000 Subject: [PATCH] Shop E2: Abschluss (schliesseAb), verwirf, Abgleich offener Bestellungen, Minutenjob schliesseAb ist die einzige Stelle fuer den Status bezahlt: Sperre je Bestellung, Konto muss zum Shop passen, idempotent, spaete Zahlung nach Ablauf wird angenommen, SEPA erst bei bezahlt, Bestandsabzug mit Ueberverkauft-Hinweis, G09 an den Kaeufer und K25 an den Haendler (Mailfehler machen nichts rueckgaengig). pruefeOffene und jobs.js minutenJob gleichen offene Bestellungen mit Stripe ab. fakeStripe: completeSession. Co-Authored-By: Claude Opus 5.5 --- server/services/shop/bestellungen.js | 260 +++++++++++++++- server/services/shop/jobs.js | 31 ++ test/shopAbschluss.test.js | 431 +++++++++++++++++++++++++++ test/support/fakeStripe.js | 23 ++ 4 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 server/services/shop/jobs.js create mode 100644 test/shopAbschluss.test.js diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index ec17f98..1b59c7f 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -3,10 +3,11 @@ import { config } from '../../config.js' import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { analyseDomains } from '../analyse.js' import { withLocks } from '../keyedLock.js' -import { checkoutAnlegen } from './connect.js' -import { istGeoeffnet, leereCache } from './kontext.js' +import { checkoutAnlegen, sessionAblaufen, sessionHolen } from './connect.js' +import { istGeoeffnet, leereCache, shopPerSiteKey } from './kontext.js' +import { anHaendler, anKaeufer, haendlerK25 } from './mails.js' import { berechne } from './preise.js' -import { listeProdukte } from './produkte.js' +import { holeProdukt, listeProdukte, parseProdukt } from './produkte.js' /** * Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung). @@ -16,7 +17,8 @@ import { listeProdukte } from './produkte.js' * bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen): * { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname, * lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] } - * E2 ergaenzt kaeufer / lieferadresse / erstattungen[] / hinweise im selben JSON. Status offen reserviert Bestand bis ablaufAt. + * E2 ergaenzt im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code, + * city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt. * Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message. */ @@ -357,3 +359,253 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) await updateDocument(col(), orderId, { stripeSessionId: session.id }) return { url: session.url, orderId, bestellnummer } } + + +// --------------------------------------------------------------------------------------------------------------------- +// Abschluss (E2): bezahlt, verwerfen, Ablauf + +const ZAHLUNGSARTEN = new Set(['card', 'klarna', 'sepa_debit']) +const ABGLEICH_PUFFER_MS = 5 * 60 * 1000 +const kurz = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max) : '') +const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '') +const verlaufMit = (daten, eintrag) => [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), eintrag] + +/** Stripe-Anschrift {line1, line2, postal_code, city, country} (+ name) als schlankes Objekt oder null. */ +function anschrift(adresse, name) { + if (!istObjekt(adresse)) return null + const a = { + ...(kurz(name, 200) ? { name: kurz(name, 200) } : {}), + line1: kurz(adresse.line1, 200), + line2: kurz(adresse.line2, 200), + postal_code: kurz(adresse.postal_code, 20), + city: kurz(adresse.city, 100), + country: kurz(adresse.country, 2).toUpperCase(), + } + return a.line1 || a.postal_code || a.city ? a : null +} + +/** Zahlungsart nur, wenn sie eindeutig ist (eine erlaubte Art oder die bezahlte Art des erweiterten PaymentIntents). */ +function zahlungsartVon(session) { + const arten = Array.isArray(session.payment_method_types) ? session.payment_method_types.filter((a) => ZAHLUNGSARTEN.has(a)) : [] + if (arten.length === 1 && session.payment_method_types.length === 1) return arten[0] + const bezahlt = session.payment_intent?.payment_method?.type + return ZAHLUNGSARTEN.has(bezahlt) ? bezahlt : '' +} + +async function shopDokument(shopId) { + try { + return await getDocument(config.collections.shopConfig, shopId) + } catch (err) { + if (err?.status === 404) return null + throw err + } +} + +/** + * Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Unter den Sperren des Bestands und der Artikel. + * @returns {Promise} true, wenn der Bestand eines Artikels nicht reichte (auf 0 gesetzt) + */ +async function zieheBestandAb(shopId, positionen) { + let ueberverkauft = false + const nachArtikel = new Map() + for (const pos of Array.isArray(positionen) ? positionen : []) { + if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue + nachArtikel.set(pos.produktId, [...(nachArtikel.get(pos.produktId) ?? []), pos]) + } + if (nachArtikel.size === 0) return false + await withLocks([`shop-bestand:${shopId}`, `shop-produkte:${shopId}`], async () => { + for (const [produktId, liste] of nachArtikel) { + const doc = await holeProdukt(shopId, produktId) + const produkt = doc ? parseProdukt(doc) : null + if (!produkt) continue + let geaendert = false + const abziehen = (objekt, menge) => { + if (!Number.isInteger(objekt.bestand)) return + if (objekt.bestand < menge) ueberverkauft = true + objekt.bestand = Math.max(0, objekt.bestand - menge) + geaendert = true + } + for (const pos of liste) { + const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null + if (pos.varianteId && !wert) continue + abziehen(wert ?? produkt, pos.menge) + } + if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) }) + } + }) + return ueberverkauft +} + +/** + * Schliesst eine bezahlte Checkout-Sitzung ab: einzige Stelle fuer den Status bezahlt. Idempotent (zweiter Aufruf aendert nichts + * und schickt keine Mail), nimmt auch spaete Zahlungen einer abgelaufenen Bestellung an. Mailfehler machen die Zahlung nie rueckgaengig. + * @param {{ accountId: string, session: object, now?: Date }} p session = Stripe-Checkout-Session (Webhook oder retrieve) + * @returns {Promise<{ order: object|null, neu: boolean }>} order mit daten; null, wenn es keine passende Bestellung gibt + */ +export async function schliesseAb({ accountId, session, now = new Date() }) { + const orderId = session?.metadata?.orderId + if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { order: null, neu: false } + const jetzt = new Date(now) + + const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { + const order = await bestellungLesen(orderId) + if (!order) return { order: null, neu: false } + const shop = await shopDokument(order.shopId) + if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) { + console.warn(`[shop] Bestellung ${orderId.slice(0, 36)} gehört nicht zum Stripe-Konto der Sitzung, ignoriert`) + return { order: null, neu: false } + } + const daten = order.daten + const spaet = order.status === 'storniert' && Array.isArray(daten.verlauf) && daten.verlauf.some((v) => v?.was === 'abgelaufen') + if (order.status !== 'offen' && !spaet) return { order, neu: false } + if (session.payment_status !== 'paid') return { order, neu: false } + + const kunde = istObjekt(session.customer_details) ? session.customer_details : {} + const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details + const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null + const rechnungsadresse = anschrift(kunde.address, kunde.name) + const zahlungsart = zahlungsartVon(session) + const email = kurz(kunde.email, 320) + const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent + if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`) + + const at = jetzt.toISOString() + let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) }) + if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null }) + const neueDaten = { + ...daten, + kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) }, + ...(lieferadresse ? { lieferadresse } : {}), + ...(rechnungsadresse ? { rechnungsadresse } : {}), + ...(zahlungsart ? { zahlungsart } : {}), + ...(spaet ? { spaet: true } : {}), + verlauf, + } + const felder = { + status: 'bezahlt', + bezahltAt: at, + bestellung: JSON.stringify(neueDaten), + ...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}), + ...(email ? { kaeuferEmail: email } : {}), + } + if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined }) + let doc = await updateDocument(col(), orderId, felder) + + const ueberverkauft = await zieheBestandAb(order.shopId, daten.positionen) + if (ueberverkauft) { + const mitHinweis = { ...neueDaten, ueberverkauft: true } + doc = await updateDocument(col(), orderId, { bestellung: JSON.stringify(mitHinweis) }).catch(() => doc) + } + return { order: { ...doc, daten: bestellDaten(doc) }, neu: true, ueberverkauft, siteKey: shop.siteKey } + }) + if (!ergebnis.neu) return { order: ergebnis.order, neu: false } + + // Mails: ausserhalb der Sperre, Fehler zaehlen nicht + const { order } = ergebnis + try { + leereCache(ergebnis.siteKey) + const ctx = await shopPerSiteKey(ergebnis.siteKey, { now: jetzt }) + if (!ctx) throw new Error('Shop-Kontext nicht gefunden') + await anKaeufer('G09', ctx, order, {}, jetzt) + const k25 = haendlerK25(ctx, order, { ueberverkauft: ergebnis.ueberverkauft }) + await anHaendler('K25', ctx, k25.values, k25.blocks) + } catch (err) { + console.warn(`[shop] Mails zur bezahlten Bestellung ${orderId.slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + return { order, neu: true } +} + +/** + * Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung. + * @param {string} orderId + * @param {'abgelaufen'|'zahlung_fehlgeschlagen'|string} grund Eintrag im Verlauf + * @param {{ now?: Date, mail?: boolean, email?: string, accountId?: string }} [opt] mail: G14 an den Kaeufer (email oder kaeuferEmail); + * accountId: Konto der Sitzung, muss zum Shop der Bestellung passen (Webhook) + * @returns {Promise<{ order: object|null, verworfen: boolean }>} + */ +export async function verwirf(orderId, grund, { now = new Date(), mail = false, email, accountId } = {}) { + const jetzt = new Date(now) + const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { + const order = await bestellungLesen(orderId) + if (!order) return { order: null, verworfen: false } + if (accountId !== undefined) { + const shop = await shopDokument(order.shopId) + if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false } + } + if (order.status !== 'offen') return { order, verworfen: false } + const doc = await updateDocument(col(), orderId, { + status: 'storniert', + bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }), + }) + return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true } + }) + if (ergebnis.verworfen && mail) { + const an = kurz(email, 320) || kurz(ergebnis.order.kaeuferEmail, 320) + if (an) { + try { + const shop = await shopDokument(ergebnis.order.shopId) + if (shop) { + leereCache(shop.siteKey) + const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt }) + if (ctx) await anKaeufer('G14', ctx, ergebnis.order, { an }, jetzt) + } + } catch (err) { + console.warn(`[shop] G14 zur Bestellung ${String(orderId).slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + } + } + return ergebnis +} + +/** + * Abgleich der offenen Bestellungen, deren Frist (ablaufAt) seit mehr als 5 Minuten verstrichen ist (Minutenjob): Stripe fragen + * und bezahlt abschliessen, verwerfen oder (noch offene Sitzung) beenden und verwerfen. Lesefehler: naechster Lauf. + * @returns {Promise<{ geprueft: number, bezahlt: number, verworfen: number, wartend: number, fehler: number }>} + */ +export async function pruefeOffene(now = new Date()) { + const grenze = new Date(now).getTime() - ABGLEICH_PUFFER_MS + const zaehler = { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 } + const shops = await listAllDocuments(config.collections.shopConfig, []) + for (const shop of shops) { + const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter( + (d) => new Date(d.ablaufAt).getTime() < grenze + ) + for (const order of offene) { + zaehler.geprueft += 1 + try { + if (!order.stripeSessionId || !shop.stripeAccountId) { + // Es wurde nie eine Sitzung gespeichert: es kann nichts bezahlt werden + if (!order.stripeSessionId) { + const erg = await verwirf(order.$id, 'abgelaufen', { now }) + if (erg.verworfen) zaehler.verworfen += 1 + } else zaehler.wartend += 1 + continue + } + let session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId) + if (session?.status === 'complete') { + if (session.payment_status === 'paid') { + const erg = await schliesseAb({ accountId: shop.stripeAccountId, session, now }) + if (erg.neu) zaehler.bezahlt += 1 + } else zaehler.wartend += 1 // SEPA unterwegs: async_payment_succeeded/failed entscheidet + continue + } + if (session?.status === 'open') { + session = await sessionAblaufen(shop.stripeAccountId, order.stripeSessionId) + if (session?.status !== 'expired') { + zaehler.wartend += 1 + continue + } + } else if (session?.status !== 'expired') { + zaehler.wartend += 1 + continue + } + const erg = await verwirf(order.$id, 'abgelaufen', { now }) + if (erg.verworfen) zaehler.verworfen += 1 + } catch (err) { + zaehler.fehler += 1 + console.warn(`[shop] Abgleich der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + } + } + return zaehler +} diff --git a/server/services/shop/jobs.js b/server/services/shop/jobs.js new file mode 100644 index 0000000..d5c5025 --- /dev/null +++ b/server/services/shop/jobs.js @@ -0,0 +1,31 @@ +import { pruefeOffene } from './bestellungen.js' +import { shopModus } from './schalter.js' + +/** + * Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3 ergaenzt den Tagesjob und die Registrierung in index.js). + * Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten Takt) und wirft nie. + */ + +const laufend = new Set() + +async function einzeln(name, arbeit) { + if (laufend.has(name)) { + console.warn(`[shop-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`) + return { uebersprungen: true } + } + laufend.add(name) + try { + return await arbeit() + } catch (err) { + console.error(`[shop-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`) + return { fehler: true } + } finally { + laufend.delete(name) + } +} + +/** Minutenjob: offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (bezahlt abschliessen oder verwerfen). Bei SHOP_ENABLED=off aus. */ +export async function minutenJob(now = new Date()) { + if (shopModus() === 'off') return { aus: true } + return einzeln('minutenJob', () => pruefeOffene(now)) +} diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js new file mode 100644 index 0000000..00eaa56 --- /dev/null +++ b/test/shopAbschluss.test.js @@ -0,0 +1,431 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Webshop E2: Abschluss einer bezahlten Sitzung (schliesseAb), Verwerfen, Abgleich offener Bestellungen (pruefeOffene), Minutenjob. +// Mails landen in der Senke (Kaeufer ueber termineOutbox, Haendler direkt), nichts verlaesst den Prozess. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + termineOutbox: schemaKeys('termineOutbox'), + }, + datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const K = await import('../server/services/shop/kontext.js') +const B = await import('../server/services/shop/bestellungen.js') +const J = await import('../server/services/shop/jobs.js') +const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') +after(async () => { + setMailTransportForTests(null) + await aw.close() + await st.close() +}) + +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const FEATURE = 'f_p_projekt01_shop' +const SITE_KEY = 'abcdefghijklmnop' +const ACCOUNT = 'acct_haendler1' +const T0 = new Date('2026-10-05T12:00:00.000Z') +const nach = (s) => new Date(T0.getTime() + s * 1000) +const PORTAL = 'https://project.webklar.com' + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, + zahlungsarten: ['card'], +} +const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } +const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) + +const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true } +const shopDoc = (over = {}) => ({ + $id: FEATURE, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: SITE_KEY, + anfrageStatus: 'angenommen', + stripeAccountId: ACCOUNT, + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), + geoeffnet: true, + version: 1, + ...over, +}) +const artikel = (id, eingabe = {}) => { + const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } }) + assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) + return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) } +} + +let sink +let log +let ctx +async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) { + for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) { + for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + } + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' }) + aw.seed('portalFeatures', featureDoc) + aw.seed('shopConfig', shopDoc(shop)) + aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' }) + for (const p of produkte) aw.seed('shopProducts', p) + st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + K.leereCache() + ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) + return ctx +} +beforeEach(() => { + st.calls.length = 0 + config.publicBaseUrl = PORTAL + config.smtp.from = 'noreply@webklar.com' + config.shop.mode = 'on' + resetOutboxForTests() + sink = mailSink() + setMailTransportForTests(sink) + log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) } + K.leereCache() +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() +}) + +const orders = () => [...aw.docs('shopOrders').values()] +const order = (id) => aw.docs('shopOrders').get(id) +const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt) +const eintraege = () => [...aw.docs('termineOutbox').values()] +const warnLog = () => log.warn.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n') + +const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } } +const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } } + +/** Bestellung anlegen (offen) und die zugehoerige Stripe-Sitzung liefern. */ +async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }], over = {}) { + const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE', ...over }, { now: T0 }) + const doc = order(r.orderId) + return { ...r, doc, sessionId: doc.stripeSessionId } +} +/** Sitzung als bezahlt markieren und so zurueckgeben, wie der Webhook sie schickt. */ +const zahle = (b, felder = {}) => + st.completeSession(ACCOUNT, b.sessionId, { + payment_status: 'paid', + amount_total: b.doc.summeCent, + customer_details: kunde, + shipping: versandAn, + payment_intent: 'pi_test1', + payment_method_types: ['card'], + ...felder, + }) + +test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an den Käufer und K25 an den Händler ohne BCC', async () => { + await aufbau() + const b = await bestelle() + const session = zahle(b) + const erg = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) }) + assert.equal(erg.neu, true) + assert.equal(erg.order.status, 'bezahlt') + + const doc = order(b.orderId) + assert.equal(doc.status, 'bezahlt') + assert.equal(new Date(doc.bezahltAt).toISOString(), nach(120).toISOString()) + assert.equal(doc.paymentIntentId, 'pi_test1') + assert.equal(doc.kaeuferEmail, 'max@example.com') + const daten = JSON.parse(doc.bestellung) + assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }) + assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' }) + assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1') + assert.equal(daten.zahlungsart, 'card') + assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'bezahlt']) + assert.equal(daten.ueberverkauft, undefined) + assert.equal(produkt('a1').bestand, 3) + + await warteAufVersand() + const [g09] = eintraege() + assert.equal(eintraege().length, 1) + assert.equal(g09.art, 'G09') + assert.equal(g09.bookingId, b.orderId) + assert.equal(g09.featureId, FEATURE) + const mails = sink.sent + assert.equal(mails.length, 2) + const kaeufer = mails.find((m) => m.to[0].address === 'max@example.com') + const haendler = mails.find((m) => m.to[0].address === 'erika@brot.example') + assert.ok(kaeufer && haendler) + assert.match(kaeufer.subject, new RegExp(`Ihre Bestellung ${doc.bestellnummer} bei Brot & Mehr`)) + assert.ok(kaeufer.text.includes('Lieferweg 7') && kaeufer.text.includes('Hinterhaus')) + assert.match(haendler.text, /Max Mustermann/) + assert.ok(!haendler.bcc && !kaeufer.bcc, 'kein BCC') + assert.doesNotMatch(haendler.text, /Bestand reichte nicht/) + assert.doesNotMatch(haendler.text, /Lastschrift/) + assert.equal(order(b.orderId).status, 'bezahlt') +}) + +test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => { + await aufbau() + const b = await bestelle() + const session = zahle(b) + const [a, z] = await Promise.all([B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }), B.schliesseAb({ accountId: ACCOUNT, session, now: nach(61) })]) + assert.deepEqual([a.neu, z.neu].sort(), [false, true]) + const dritte = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(300) }) + assert.equal(dritte.neu, false) + assert.equal(dritte.order.status, 'bezahlt') + await warteAufVersand() + assert.equal(eintraege().length, 1) + assert.equal(sink.sent.length, 2) + assert.equal(produkt('a1').bestand, 3) + assert.equal(JSON.parse(order(b.orderId).bestellung).verlauf.filter((v) => v.was === 'bezahlt').length, 1) +}) + +test('schliesseAb: falsches Konto, fremde oder unbekannte Sitzung ändern nichts', async () => { + await aufbau() + const b = await bestelle() + const session = zahle(b) + assert.deepEqual(await B.schliesseAb({ accountId: 'acct_fremd', session, now: nach(60) }), { order: null, neu: false }) + assert.match(warnLog(), /gehört nicht zum Stripe-Konto/) + assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { ...session.metadata, wkShop: undefined } }, now: nach(60) }), { order: null, neu: false }) + assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1', orderId: 'gibt-es-nicht' } }, now: nach(60) }), { order: null, neu: false }) + assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1' } }, now: nach(60) }), { order: null, neu: false }) + assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: null, now: nach(60) }), { order: null, neu: false }) + assert.equal(order(b.orderId).status, 'offen') + assert.equal(produkt('a1').bestand, 5) + await warteAufVersand() + assert.equal(sink.sent.length, 0) +}) + +test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_succeeded kommt; SEPA-Hinweis in K25', async () => { + await aufbau() + const b = await bestelle() + const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }) + assert.equal(erg.neu, false) + assert.equal(erg.order.status, 'offen') + assert.equal(produkt('a1').bestand, 5) + await warteAufVersand() + assert.equal(sink.sent.length, 0) + + const bezahlt = zahle(b, { payment_status: 'paid', payment_method_types: ['sepa_debit'] }) + const spaeter = await B.schliesseAb({ accountId: ACCOUNT, session: bezahlt, now: nach(3 * 86400) }) + assert.equal(spaeter.neu, true) + assert.equal(JSON.parse(order(b.orderId).bestellung).zahlungsart, 'sepa_debit') + await warteAufVersand() + assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/) +}) + +test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => { + await aufbau() + const a = await bestelle() + assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(1900) })).verworfen, true) + assert.equal(order(a.orderId).status, 'storniert') + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(a), now: nach(2000) }) + assert.equal(erg.neu, true) + assert.equal(order(a.orderId).status, 'bezahlt') + const daten = JSON.parse(order(a.orderId).bestellung) + assert.equal(daten.spaet, true) + assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen', 'bezahlt']) + assert.equal(produkt('a1').bestand, 3) + + const c = await bestelle([{ produktId: 'a1', menge: 1 }]) + assert.equal((await B.verwirf(c.orderId, 'zahlung_fehlgeschlagen', { now: nach(10) })).verworfen, true) + const nein = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(c), now: nach(20) }) + assert.equal(nein.neu, false) + assert.equal(order(c.orderId).status, 'storniert') + assert.equal(produkt('a1').bestand, 3) +}) + +test('schliesseAb: abweichender Betrag - trotzdem bezahlt, Vermerk im Verlauf, Warnung ohne Käuferdaten', async () => { + await aufbau() + const b = await bestelle() + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { amount_total: b.doc.summeCent + 100 }), now: nach(60) }) + assert.equal(erg.neu, true) + const daten = JSON.parse(order(b.orderId).bestellung) + const vermerk = daten.verlauf.find((v) => v.was === 'betrag_abweichend') + assert.deepEqual({ e: vermerk.erwartetCent, b: vermerk.bezahltCent }, { e: b.doc.summeCent, b: b.doc.summeCent + 100 }) + assert.match(warnLog(), /weicht vom Bestellwert ab/) + assert.doesNotMatch(warnLog(), /max@example|Mustermann|Lieferweg/) +}) + +test('schliesseAb: Variantenbestand wird in der Variante abgezogen, Artikel ohne Bestand bleiben unberührt', async () => { + await aufbau({ + produkte: [ + artikel('a1', { bestand: 10, varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000, bestand: 4 }, { name: 'L', preisCent: 2200, bestand: 6 }] } }), + artikel('a2'), + ], + }) + const werte = produkt('a1').varianten.werte + const L = werte.find((w) => w.name === 'L') + const M = werte.find((w) => w.name === 'M') + const b = await bestelle([{ produktId: 'a1', varianteId: L.id, menge: 2 }, { produktId: 'a1', varianteId: M.id, menge: 1 }, { produktId: 'a2', menge: 3 }]) + await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) + const danach = produkt('a1') + assert.equal(danach.varianten.werte.find((w) => w.name === 'L').bestand, 4) + assert.equal(danach.varianten.werte.find((w) => w.name === 'M').bestand, 3) + assert.equal(danach.bestand, null, 'bei Varianten führen die Varianten den Bestand') + assert.equal(produkt('a2').bestand, null) +}) + +test('schliesseAb: Bestand reicht nicht - auf 0 gesetzt, Händler-Hinweis ueberverkauft in K25', async () => { + await aufbau() + const b = await bestelle([{ produktId: 'a1', menge: 4 }]) + // Der Haendler hat den Bestand inzwischen verringert + const p = produkt('a1') + p.bestand = 1 + await updateDocument(config.collections.shopProducts, 'a1', { produkt: JSON.stringify(p) }) + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) + assert.equal(erg.neu, true) + assert.equal(produkt('a1').bestand, 0) + assert.equal(JSON.parse(order(b.orderId).bestellung).ueberverkauft, true) + await warteAufVersand() + assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Bestand reichte nicht/) + assert.equal(order(b.orderId).status, 'bezahlt') +}) + +test('schliesseAb: Mailfehler machen die Zahlung nicht rückgängig', async () => { + await aufbau() + const b = await bestelle() + aw.docs('customers').clear() + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) + assert.equal(erg.neu, true) + assert.equal(order(b.orderId).status, 'bezahlt') + assert.equal(produkt('a1').bestand, 3) +}) + +test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async () => { + await aufbau() + const b = await bestelle([{ produktId: 'a1', menge: 1 }], { lieferart: 'abholung', land: undefined }) + const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { shipping: null, customer_details: { email: 'x@example.com' } }), now: nach(60) }) + assert.equal(erg.neu, true) + const daten = JSON.parse(order(b.orderId).bestellung) + assert.equal(daten.lieferadresse, undefined) + assert.equal(daten.rechnungsadresse, undefined) + assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com', telefon: '' }) +}) + +test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => { + await aufbau() + const a = await bestelle() + assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), 2) + const erg = await B.verwirf(a.orderId, 'abgelaufen', { now: nach(10) }) + assert.equal(erg.verworfen, true) + assert.equal(erg.order.status, 'storniert') + assert.equal(JSON.parse(order(a.orderId).bestellung).verlauf.at(-1).was, 'abgelaufen') + assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), undefined) + assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(11) })).verworfen, false, 'zweites Mal wirkungslos') + assert.equal((await B.verwirf('gibt-es-nicht', 'abgelaufen')).order, null) + await warteAufVersand() + assert.equal(sink.sent.length, 0) + + // bezahlt wird nie verworfen + const bz = await bestelle() + await B.schliesseAb({ accountId: ACCOUNT, session: zahle(bz), now: nach(20) }) + assert.equal((await B.verwirf(bz.orderId, 'abgelaufen', { now: nach(30) })).verworfen, false) + assert.equal(order(bz.orderId).status, 'bezahlt') + + // falsches Konto + const f = await bestelle([{ produktId: 'a1', menge: 1 }]) + assert.equal((await B.verwirf(f.orderId, 'abgelaufen', { accountId: 'acct_fremd' })).verworfen, false) + assert.equal(order(f.orderId).status, 'offen') + + // G14 mit der Adresse aus der Sitzung + sink.sent.length = 0 + const g = await bestelle([{ produktId: 'a1', menge: 1 }]) + const v = await B.verwirf(g.orderId, 'zahlung_fehlgeschlagen', { now: nach(40), mail: true, email: 'max@example.com', accountId: ACCOUNT }) + assert.equal(v.verworfen, true) + await warteAufVersand() + const g14 = eintraege().filter((e) => e.art === 'G14') + assert.equal(g14.length, 1) + assert.equal(sink.sent.length, 1) + assert.equal(sink.sent[0].to[0].address, 'max@example.com') + + // ohne Adresse keine Mail, kein Fehler + const h = await bestelle([{ produktId: 'a1', menge: 1 }]) + assert.equal((await B.verwirf(h.orderId, 'zahlung_fehlgeschlagen', { mail: true })).verworfen, true) + await warteAufVersand() + assert.equal(eintraege().filter((e) => e.art === 'G14').length, 1) +}) + +test('pruefeOffene: bezahlt abschließen, abgelaufene und noch offene Sitzungen verwerfen, Lesefehler und junge Bestellungen überspringen', async () => { + await aufbau({ produkte: [artikel('a1', { bestand: 20 })] }) + const bezahlt = await bestelle([{ produktId: 'a1', menge: 1 }]) + const abgelaufen = await bestelle([{ produktId: 'a1', menge: 1 }]) + const offen = await bestelle([{ produktId: 'a1', menge: 1 }]) + const sepa = await bestelle([{ produktId: 'a1', menge: 1 }]) + const fehler = await bestelle([{ produktId: 'a1', menge: 1 }]) + zahle(bezahlt) + st.accountSessions(ACCOUNT).set(abgelaufen.sessionId, { ...st.accountSessions(ACCOUNT).get(abgelaufen.sessionId), status: 'expired' }) + zahle(sepa, { payment_status: 'unpaid' }) + + const spaet = nach(30 * 60 + 30 + 6 * 60) // Frist + 6 Minuten + const jung = await (async () => { + const x = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: nach(10 * 60) }) + return x + })() + st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${fehler.sessionId}`, status: 400 }) + st.calls.length = 0 + + const z = await B.pruefeOffene(spaet) + assert.deepEqual(z, { geprueft: 5, bezahlt: 1, verworfen: 2, wartend: 1, fehler: 1 }) + assert.equal(order(bezahlt.orderId).status, 'bezahlt') + assert.equal(order(abgelaufen.orderId).status, 'storniert') + assert.equal(order(offen.orderId).status, 'storniert') + assert.ok(st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${offen.sessionId}/expire` && c.account === ACCOUNT)) + assert.equal(st.calls.some((c) => c.path.endsWith('/expire') && c.path.includes(abgelaufen.sessionId)), false, 'bereits abgelaufene Sitzung wird nicht erneut beendet') + assert.equal(order(sepa.orderId).status, 'offen', 'SEPA unterwegs bleibt offen') + assert.equal(order(fehler.orderId).status, 'offen', 'Lesefehler: nächster Lauf') + assert.equal(order(jung.orderId).status, 'offen', 'Frist noch nicht um mehr als 5 Minuten verstrichen') + assert.match(warnLog(), /Abgleich der Bestellung/) + assert.equal(produkt('a1').bestand, 19) + + // Nächster Lauf: der Lesefehler ist weg + const z2 = await B.pruefeOffene(spaet) + assert.deepEqual({ ...z2 }, { geprueft: 2, bezahlt: 0, verworfen: 1, wartend: 1, fehler: 0 }) + assert.equal(order(fehler.orderId).status, 'storniert') +}) + +test('pruefeOffene: Bestellung ohne gespeicherte Sitzung wird verworfen; ohne offene Bestellungen passiert nichts', async () => { + await aufbau() + assert.deepEqual(await B.pruefeOffene(nach(7200)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }) + const b = await bestelle() + await updateDocument(config.collections.shopOrders, b.orderId, { stripeSessionId: '' }) + const z = await B.pruefeOffene(nach(7200)) + assert.equal(z.verworfen, 1) + assert.equal(order(b.orderId).status, 'storniert') +}) + +test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt gleichzeitig', async () => { + await aufbau() + const b = await bestelle() + zahle(b) + config.shop.mode = 'off' + assert.deepEqual(await J.minutenJob(nach(7200)), { aus: true }) + assert.equal(order(b.orderId).status, 'offen') + + config.shop.mode = 'pilot' + const [erster, zweiter] = await Promise.all([J.minutenJob(nach(7200)), J.minutenJob(nach(7200))]) + assert.ok(erster.bezahlt === 1 || zweiter.bezahlt === 1) + assert.ok(erster.uebersprungen === true || zweiter.uebersprungen === true) + assert.equal(order(b.orderId).status, 'bezahlt') + assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 8ea8bc3..31fa612 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -805,6 +805,29 @@ export async function startFakeStripe() { accounts.set(id, next) return next }, + /** + * Sitzung eines Haendlerkontos als abgeschlossen markieren (status complete) und die Felder setzen, die Stripe nach der Kasse + * liefert: completeSession('acct_x', 'cs_x', { payment_status: 'paid', amount_total, customer_details, shipping, payment_intent, + * payment_method_types }). shipping steht wie bei Stripe unter collected_information.shipping_details und shipping_details. + * Gibt die neue Sitzung zurueck (so schickt der Webhook sie). + */ + completeSession(accountId, id, { payment_status = 'paid', amount_total = null, customer_details = null, shipping = null, payment_intent = null, payment_method_types } = {}) { + const sessions = bucket(accountSessionMaps, accountId) + const found = sessions.get(id) + if (!found) throw new Error(`Stripe-Nachbau: keine Sitzung ${id} bei ${accountId}`) + const next = { + ...found, + status: 'complete', + payment_status, + amount_total, + customer_details, + payment_intent, + ...(payment_method_types ? { payment_method_types } : {}), + ...(shipping ? { collected_information: { shipping_details: shipping }, shipping_details: shipping } : {}), + } + sessions.set(id, next) + return next + }, /** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */ setPaymentIntent(accountId, id, { amount, currency = 'eur' }) { const obj = { id, object: 'payment_intent', amount, currency }