From 77fb8c8f445475cc72ce50b56eec7230f509eec9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:44:48 +0000 Subject: [PATCH] Shop Review B I-1: Eingangsmail G15 bei SEPA, G09 nimmt die Bestellung ausdruecklich an G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt, aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des Anbieters. Co-Authored-By: Claude Opus 5.5 --- .../G09 Bestellbestaetigung.html | 7 ++- .../G15 Bestellung eingegangen.html | 53 ++++++++++++++++++ server/routes/shopWebhook.js | 3 +- server/services/shop/bestellungen.js | 46 ++++++++++++++- server/services/shop/mails.js | 19 ++++--- server/services/termine/outbox.js | 10 ++-- test/mailTemplates.test.js | 27 +++++++-- test/shopAbschluss.test.js | 56 +++++++++++++++++++ test/shopMails.test.js | 30 ++++++++++ test/shopWebhook.test.js | 8 ++- test/termineOutbox.test.js | 6 +- 11 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 server/mail-templates/G15 Bestellung eingegangen.html diff --git a/server/mail-templates/G09 Bestellbestaetigung.html b/server/mail-templates/G09 Bestellbestaetigung.html index 24265e9..877e851 100644 --- a/server/mail-templates/G09 Bestellbestaetigung.html +++ b/server/mail-templates/G09 Bestellbestaetigung.html @@ -7,12 +7,12 @@ Betreff: Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}} Absender: „“ · Antworten an die E-Mail-Adresse des Händlers Empfänger: Käufer (Adresse aus der Bestellung bzw. der Widerrufserklärung) · ohne BCC - Platzhalter: {{bestellnummer}}, {{datum}}, {{positionen}}, {{zwischensumme}}, {{versand}}, {{gesamt}}, {{steuer_hinweis}}, {{lieferart_text}}, {{lieferadresse}}, {{abholort}}, {{lieferzeit}}, {{zahlungsart}}, {{ausschluss_text}}, {{agb}}, {{widerrufsbelehrung}}, {{widerrufsformular}}, {{gewaehrleistung}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}} + Platzhalter: {{bestellnummer}}, {{datum}}, {{zahlungsdatum}}, {{positionen}}, {{zwischensumme}}, {{versand}}, {{gesamt}}, {{steuer_hinweis}}, {{lieferart_text}}, {{lieferadresse}}, {{abholort}}, {{lieferzeit}}, {{zahlungsart}}, {{ausschluss_text}}, {{agb}}, {{widerrufsbelehrung}}, {{widerrufsformular}}, {{gewaehrleistung}}, {{farbe}}, {{betrieb_name}}, {{anbieter_zeile}}, {{impressum_url}}, {{datenschutz_url}}, {{widerruf_url}} Optionale Blöcke (vom Server gesetzt, versand und abholung schließen sich aus): versand, abholung, ausschluss Sie-Form, keine Anrede-Blöcke. Footer: Anbieter-Zeile mit Name, Anschrift und E-Mail, Impressum, Datenschutz, Link zur Widerrufsfunktion. positionen = eine Zeile je Position „Menge × Name (Variante) – Summe“; lieferadresse mehrzeilig; agb, widerrufsbelehrung, widerrufsformular und gewaehrleistung sind die vollen Texte (Zeilenumbrüche werden zu
). Block versand: Lieferadresse. Block abholung: Abholort. Block ausschluss: Hinweis auf Artikel ohne Widerrufsrecht (ausschluss_text). - Ist die Mail zu lang für die Warteschlange, ersetzt der Server den AGB-Text durch einen Link auf die AGB im Shop. + datum = Bestelldatum (Zeitpunkt der Bestellung), zahlungsdatum = Tag des Zahlungseingangs. Mit dieser Mail nimmt der Händler die Bestellung an (Vertragsschluss). Ist die Mail zu lang für die Warteschlange, wird sie nicht versendet (der Marker an der Bestellung bleibt offen und der Versand wird wiederholt), sie wird nie durch einen Link ersetzt. --> @@ -33,10 +33,11 @@ a{color:#111111;} - + diff --git a/server/mail-templates/G15 Bestellung eingegangen.html b/server/mail-templates/G15 Bestellung eingegangen.html new file mode 100644 index 0000000..4fc7528 --- /dev/null +++ b/server/mail-templates/G15 Bestellung eingegangen.html @@ -0,0 +1,53 @@ + + + + + + + + +Wir haben Ihre Bestellung {{bestellnummer}} erhalten + + + + +
Vielen Dank für Ihre Bestellung
Guten Tag,
wir haben Ihre Bestellung bei {{betrieb_name}} erhalten und Ihre Zahlung ist eingegangen. Bitte bewahren Sie diese E-Mail auf: Sie enthält alle Angaben zu Ihrem Vertrag.
Guten Tag,
wir haben Ihre Bestellung bei {{betrieb_name}} erhalten und Ihre Zahlung ist eingegangen. Mit dieser E-Mail nehmen wir Ihre Bestellung an. Der Kaufvertrag ist damit geschlossen. Bitte bewahren Sie diese E-Mail auf: Sie enthält alle Angaben zu Ihrem Vertrag.
+
Bestellnummer{{bestellnummer}}
Bestelldatum{{datum}}
Zahlungsdatum{{zahlungsdatum}}
Anbieter{{anbieter_zeile}}
Ihre Artikel
+ + + + diff --git a/server/routes/shopWebhook.js b/server/routes/shopWebhook.js index e76db4e..0b929b5 100644 --- a/server/routes/shopWebhook.js +++ b/server/routes/shopWebhook.js @@ -3,7 +3,7 @@ import { config } from '../config.js' import { getDocument, listDocuments, Query, updateDocument } from '../services/appwriteAdmin.js' import { withLocks } from '../services/keyedLock.js' import { connectEreignis, statusAusKonto, statusMitHinweis } from '../services/shop/connect.js' -import { schliesseAb, verwirf } from '../services/shop/bestellungen.js' +import { meldeEingang, schliesseAb, verwirf } from '../services/shop/bestellungen.js' import { bucheStripeErstattung } from '../services/shop/erstattungen.js' import { ladeShop, leereCache } from '../services/shop/kontext.js' @@ -56,6 +56,7 @@ async function verarbeite(event, now) { switch (event.type) { case 'checkout.session.completed': if (objekt.payment_status === 'paid') await schliesseAb({ accountId, session: objekt, now }) + else await meldeEingang({ accountId, session: objekt, now }) break case 'checkout.session.async_payment_succeeded': await schliesseAb({ accountId, session: objekt, now }) diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index 2992cbd..41cc2ff 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -604,6 +604,46 @@ export async function schliesseAb({ accountId, session, now = new Date() }) { }) } +/** + * Eingang der Bestellung (G15): Stripe meldet die Sitzung abgeschlossen, die Zahlung steht aber noch aus (SEPA-Lastschrift, + * payment_status unpaid / no_payment_required). Die Bestellung bleibt offen, der Kaeufer bekommt einmal eine Eingangsmail ohne + * Vertragsschluss (der Vertrag kommt mit G09 nach Zahlungseingang zustande). Marker `mailsEingangAt` im Bestell-JSON, Versuche in + * mailsVersuche.eingang (hoechstens MAIL_VERSUCHE_MAX). Aufrufer: Webhook (checkout.session.completed) und pruefeOffene. + * Nie ein Wurf wegen der Mail; falsches Konto, fremde Sitzung oder eine nicht mehr offene Bestellung bleiben wirkungslos. + * @param {{ accountId: string, session: object, now?: Date }} p + * @returns {Promise<{ gesendet: boolean }>} + */ +export async function meldeEingang({ accountId, session, now = new Date() }) { + const orderId = session?.metadata?.orderId + if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { gesendet: false } + if (session.payment_status !== 'unpaid' && session.payment_status !== 'no_payment_required') return { gesendet: false } + const an = kurz(session.customer_details?.email, 320) + if (!an) return { gesendet: false } + const jetzt = new Date(now) + + return withLocks([`shop-order:${orderId}`], async () => { + const order = await bestellungLesen(orderId) + if (!order || order.status !== 'offen') return { gesendet: false } + const shop = await shopDokument(order.shopId) + if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) return { gesendet: false } + const daten = order.daten + const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {} + if (daten.mailsEingangAt || (versuche.eingang ?? 0) >= MAIL_VERSUCHE_MAX) return { gesendet: false } + let ok = false + try { + leereCache(shop.siteKey) + const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt }) + if (!ctx) throw new Error('Shop-Kontext nicht gefunden') + ok = (await anKaeufer('G15', ctx, order, { an }, jetzt)).ok + } catch (err) { + console.warn(`[shop] G15 zur Bestellung ${orderId.slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + const neu = ok ? { ...daten, mailsEingangAt: jetzt.toISOString() } : { ...daten, mailsVersuche: { ...versuche, eingang: (versuche.eingang ?? 0) + 1 } } + await updateDocument(col(), orderId, { bestellung: JSON.stringify(neu) }) + return { gesendet: ok } + }) +} + /** * Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung. * @param {string} orderId @@ -692,7 +732,11 @@ export async function pruefeOffene(now = new Date()) { 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 + } else { + // SEPA unterwegs: async_payment_succeeded/failed entscheidet; bis dahin hat der Kaeufer die Eingangsmail (G15) zu bekommen + zaehler.wartend += 1 + await meldeEingang({ accountId: shop.stripeAccountId, session, now }) + } continue } if (session?.status === 'open') { diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js index 0bb0b54..2d7c99b 100644 --- a/server/services/shop/mails.js +++ b/server/services/shop/mails.js @@ -13,7 +13,7 @@ import { gewaehrleistungsHinweis } from './rechtstexte.js' * Mails des Webshops (Spezifikation 2026-10-05-webshop-design.md, Abschnitte 5.3, 5.4 und 8; Plan Task E4). * * Kaeufer (G09 Bestellbestaetigung, G10 versendet, G11 abholbereit, G12 Eingang Widerruf, G13 Erstattung, G14 Zahlung - * fehlgeschlagen) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname, + * fehlgeschlagen, G15 Eingang der Bestellung) laufen durch die Mail-Warteschlange des Terminplaners (termine/outbox.js einreihen): Absendername = Shopname, * Absenderadresse bleibt SMTP_FROM, Antworten an die E-Mail des Haendlers, nie BCC. featureId = ID des portalFeatures-Dokuments des * Shops, bookingId = Bestell-ID. Der Betreff steht in der Vorlage (G-Vorlagen des Shops haben keinen {{betreff}}-Wert). Die * Empfaenger-Kappe der Terminplaner-Mails (limits.darfEmpfaenger) gilt hier nicht: G09 ist Vertragspflicht. @@ -36,7 +36,7 @@ import { gewaehrleistungsHinweis } from './rechtstexte.js' */ const LEER = '–' -const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14']) +const GAST_ARTEN = Object.freeze(['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']) /** Grenzen des Outbox-Schemas (html 100000, text 50000) mit etwas Luft. */ const MAX_HTML = 95000 const MAX_TEXT = 47000 @@ -87,7 +87,7 @@ function anbieterZeile(a) { const ort = [text(a.plz), text(a.ort)].filter(Boolean).join(' ') const land = text(a.land) && a.land !== 'DE' ? (LANDNAMEN[a.land] ?? a.land) : '' const anschrift = [text(a.strasse), ort, land].filter(Boolean).join(', ') - return [text(a.name), anschrift, text(a.email)].filter(Boolean).join(', ') || LEER + return [text(a.name), anschrift, text(a.email), text(a.telefon) ? `Tel. ${einzeilig(a.telefon)}` : ''].filter(Boolean).join(', ') || LEER } const siteKeyVon = (ctx) => ctx?.shop?.siteKey || ctx?.doc?.siteKey || '' @@ -143,9 +143,13 @@ function eingangText(eingang) { return `${datumKurz(d)}, ${uhrzeit(d)} Uhr` } -const datumText = (order, now) => { - const ms = zeitMs(order?.bezahltAt || order?.erstelltAt) - return datumKurz(Number.isFinite(ms) ? new Date(ms) : now) +/** Datum des ersten brauchbaren Zeitpunkts (Berliner Tag), sonst des Ersatzdatums. */ +const datumVon = (now, ...zeitpunkte) => { + for (const wert of zeitpunkte) { + const ms = zeitMs(wert) + if (Number.isFinite(ms)) return datumKurz(new Date(ms)) + } + return datumKurz(now) } // ---------------------------------------------------------------- Werte @@ -184,7 +188,8 @@ export function kaeuferWerte(ctx, order, now = new Date()) { impressum_url: text(rechtstexte.impressumUrl) || shop, datenschutz_url: text(rechtstexte.datenschutzUrl) || shop, bestellnummer: oderLeer(order?.bestellnummer), - datum: datumText(order, now), + datum: datumVon(now, order?.erstelltAt), + zahlungsdatum: datumVon(now, order?.bezahltAt, order?.erstelltAt), positionen: positionenText(snap.positionen), zwischensumme: text(texte.zwischensumme) || cent(summen.zwischensummeCent), versand: text(texte.versand) || (lieferart === 'abholung' ? 'Abholung – kostenlos' : cent(summen.versandCent)), diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index e12d26d..1ea035a 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -39,7 +39,7 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js' * pause Anmeldepause laeuft * budget Plaetze im Fenster >= mailsProStunde * reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt - * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12) und den normalen Mails vorbehalten + * ein Teil der Stunde den Vorrang-Mails (G01, G02, G08, Webshop G09, G12, G15) und den normalen Mails vorbehalten * kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen * * Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage. @@ -73,10 +73,10 @@ const iso = (wertMs) => new Date(wertMs).toISOString() /** * Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). Dazu die beiden - * Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger) und G12 Eingangsbestaetigung des Widerrufs. Die + * Pflichtmails des Webshops: G09 Bestellbestaetigung (dauerhafter Datentraeger), G12 Eingangsbestaetigung des Widerrufs und G15 Eingang der Bestellung (SEPA, Zahlung noch offen). Die * uebrigen Shop-Mails G10, G11, G13, G14 sind 'normal'. */ -export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12']) +export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08', 'G09', 'G12', 'G15']) /** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */ export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21']) @@ -119,7 +119,7 @@ function gesamtBremse(nowMs) { * 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz. * @param {number} nowMs * @param {string} featureId - * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G14') + * @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21', Shop: 'G09' ... 'G15') * @returns {null|'pause'|'budget'|'reserve'|'kontingent'} */ export function pruefePlatz(nowMs, featureId, art) { @@ -442,7 +442,7 @@ async function verarbeiteKlasse(klasse, nowMs, now, lauf) { } /** - * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12) vor normalen vor nachrangigen (G05, K20, K21), + * Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08, G09, G12, G15) vor normalen vor nachrangigen (G05, K20, K21), * in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege * (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen * kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht. diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index b6ce0cd..6701f74 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -8,10 +8,10 @@ const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 14), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21'] +const EXPECTED = [...ids('G', 15), ...ids('K', 26), ...ids('T', 19), 'T20', 'T21'] // GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) -const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14'] +const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on])) @@ -1083,11 +1083,11 @@ test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Pre const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url'] -test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => { +test('G09–G15: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => { const erwartet = { G09: { s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}', - p: ['bestellnummer', 'datum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'], + p: ['bestellnummer', 'datum', 'zahlungsdatum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'], b: ['versand', 'abholung', 'ausschluss'], }, G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] }, @@ -1095,6 +1095,7 @@ test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des We G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] }, G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] }, G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] }, + G15: { s: 'Wir haben Ihre Bestellung {{bestellnummer}} erhalten', p: ['bestellnummer', 'datum', 'positionen', 'gesamt'], b: [] }, } assert.deepEqual(Object.keys(erwartet), SHOP_GUEST) for (const [id, { s, p, b }] of Object.entries(erwartet)) { @@ -1105,7 +1106,7 @@ test('G09–G14: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des We } }) -test('G09–G14: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => { +test('G09–G15: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => { for (const id of SHOP_GUEST) { const tpl = loadTemplate(id) const source = sourceOf(id) @@ -1131,6 +1132,7 @@ const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, b const G09_WERTE = { datum: '05.10.2026', + zahlungsdatum: '07.10.2026', positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €', zwischensumme: '36,50 €', versand: '4,90 €', @@ -1158,6 +1160,10 @@ test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Liefe for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) { assert.ok(mail.text.includes(teil), teil) } + // I-1 b: ausdrückliche Annahme, Bestelldatum und Zahlungsdatum getrennt + assert.match(mail.text, /Mit dieser E-Mail nehmen wir Ihre Bestellung an\. Der Kaufvertrag ist damit geschlossen\./) + assert.match(mail.text, /Bestelldatum: 05\.10\.2026/) + assert.match(mail.text, /Zahlungsdatum: 07\.10\.2026/) assert.match(mail.text, /Lieferadresse: Erika Beispiel/) assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/) assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/) @@ -1171,6 +1177,17 @@ test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs assert.match(mail.text, /Abholung – kostenlos/) }) +test('G15: Eingang ohne Vertragsschluss - Lastschrift in Bearbeitung, Bestellbestätigung folgt, Positionen und Gesamt', () => { + const mail = g('G15', { datum: '05.10.2026', positionen: '2 × Tasse (blau) – 24,00 €', gesamt: '41,40 €' }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten') + assert.equal(loadTemplate('G15').file, 'G15 Bestellung eingegangen.html') + for (const teil of ['26-K7QM2X', '05.10.2026', '2 × Tasse (blau) – 24,00 €', '41,40 €', 'Ihre Zahlung per Lastschrift ist noch in Bearbeitung. Sobald sie bei uns eingegangen ist, erhalten Sie die Bestellbestätigung; erst damit kommt der Kaufvertrag zustande.']) { + assert.ok(mail.text.includes(teil), teil) + } + assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|Widerrufsbelehrung|Allgemeine Geschäftsbedingungen/) +}) + test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => { const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true }) assert.deepEqual(mit.problems, []) diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js index a45c281..9a67620 100644 --- a/test/shopAbschluss.test.js +++ b/test/shopAbschluss.test.js @@ -234,6 +234,62 @@ test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_su assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/) }) +test('meldeEingang I-1: nur bei unbezahlter, abgeschlossener Sitzung einer offenen Bestellung - einmal G15 an die Adresse der Sitzung, Marker, kein Vertragstext', async () => { + await aufbau() + const b = await bestelle() + const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) + assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(60) }), { gesendet: false }, 'bezahlt ist Sache von schliesseAb') + assert.deepEqual(await B.meldeEingang({ accountId: 'acct_fremd', session: unbezahlt, now: nach(60) }), { gesendet: false }, 'fremdes Konto') + assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, customer_details: null }, now: nach(60) }), { gesendet: false }, 'ohne Adresse') + await warteAufVersand() + assert.equal(sink.sent.length, 0) + + assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }), { gesendet: true }) + assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(120) }), { gesendet: false }, 'nur einmal') + await warteAufVersand() + assert.equal(sink.sent.length, 1) + assert.equal(sink.sent[0].to[0].address, 'max@example.com') + assert.match(sink.sent[0].subject, /^Wir haben Ihre Bestellung \S+ erhalten$/) + assert.doesNotMatch(sink.sent[0].text, /Kaufvertrag ist damit geschlossen/) + assert.equal(order(b.orderId).status, 'offen') + assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString()) + + // Später bezahlt: G09 folgt, der Marker bleibt erhalten + await B.schliesseAb({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(3 * 86400) }) + await warteAufVersand() + assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString()) + assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(4 * 86400) }), { gesendet: false }, 'nicht mehr offen') +}) + +test('meldeEingang I-1: scheitert das Einreihen, bleibt der Marker leer und die Versuche werden gezählt (höchstens 5)', async () => { + await aufbau() + const b = await bestelle() + const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) + await deleteDocument(config.collections.portalFeatures, FEATURE) // Shop-Kontext fehlt: das Einreihen scheitert + K.leereCache() + for (let i = 0; i < 7; i += 1) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60 + i) }), { gesendet: false }) + const d = JSON.parse(order(b.orderId).bestellung) + assert.equal(d.mailsEingangAt, undefined) + assert.equal(d.mailsVersuche.eingang, 5) + assert.match(warnLog(), /G15 zur Bestellung/) +}) + +test('pruefeOffene I-1: SEPA unterwegs - der Minutenjob holt die Eingangsmail G15 nach, ein zweiter Lauf schickt keine zweite', async () => { + await aufbau() + const sepa = await bestelle([{ produktId: 'a1', menge: 1 }]) + zahle(sepa, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) + const spaet = nach(30 * 60 + 30 + 6 * 60) + const z = await B.pruefeOffene(spaet) + assert.deepEqual(z, { geprueft: 1, bezahlt: 0, verworfen: 0, wartend: 1, fehler: 0 }) + await warteAufVersand() + assert.equal(sink.sent.length, 1) + assert.equal(sink.sent[0].to[0].address, 'max@example.com') + await B.pruefeOffene(nach(30 * 60 + 30 + 8 * 60)) + await warteAufVersand() + assert.equal(sink.sent.length, 1) + assert.equal(order(sepa.orderId).status, 'offen') +}) + test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => { await aufbau() const a = await bestelle() diff --git a/test/shopMails.test.js b/test/shopMails.test.js index e401c54..d438b60 100644 --- a/test/shopMails.test.js +++ b/test/shopMails.test.js @@ -146,6 +146,19 @@ test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Sho assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum') }) +test('kaeuferWerte I-1: Bestelldatum ist der Tag der Bestellung, Zahlungsdatum der des Zahlungseingangs (SEPA Tage später)', () => { + const w = kaeuferWerte(ctxMit(), bestellung({ erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-08T08:30:00.000Z' }), JETZT) + assert.equal(w.datum, '05.10.2026') + assert.equal(w.zahlungsdatum, '08.10.2026') + const offen = kaeuferWerte(ctxMit(), bestellung({ bezahltAt: undefined }), JETZT) + assert.equal(offen.zahlungsdatum, '05.10.2026', 'ohne Zahlung gilt das Bestelldatum') +}) + +test('kaeuferWerte M-5: Telefonnummer des Anbieters steht in der Anbieterzeile, wenn vorhanden', () => { + const mit = kaeuferWerte(ctxMit(einstellungen({ anbieter: { ...einstellungen().anbieter, telefon: '0 63 02 / 12 34' } })), bestellung(), JETZT) + assert.equal(mit.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example, Tel. 0 63 02 / 12 34') +}) + // ---------------------------------------------------------------- Käufermails test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => { @@ -179,6 +192,23 @@ test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage') }) +test('G15: Eingang der Bestellung geht an die übergebene Adresse (noch keine Käuferadresse an der Bestellung), ohne Rechtstexte und ohne Vertragsschluss', async () => { + const offen = bestellung({ kaeuferEmail: undefined, bezahltAt: undefined, status: 'offen' }) + const erg = await anKaeufer('G15', ctxMit(), offen, { an: 'sepa@example.com' }, JETZT) + assert.equal(erg.ok, true) + await warteAufVersand() + const [doc] = eintraege() + assert.equal(doc.art, 'G15') + assert.equal(doc.bookingId, 'o_bestellung01') + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.equal(mail.to[0].address, 'sepa@example.com') + assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten') + assert.match(mail.text, /noch in Bearbeitung/) + assert.match(mail.text, /2 × Tasse \(blau\) – 24,00/) + assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|AGB Zeile 1|Belehrung Zeile 1/) +}) + test('G09 Abholung: Abholort statt Lieferadresse', async () => { const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT) assert.equal(erg.ok, true) diff --git a/test/shopWebhook.test.js b/test/shopWebhook.test.js index ec0c778..ae68996 100644 --- a/test/shopWebhook.test.js +++ b/test/shopWebhook.test.js @@ -233,7 +233,7 @@ test('checkout.session.completed (bezahlt) ⇒ bezahlt, Bestand, Mails; Wiederho assert.equal(daten(b.orderId).verlauf.filter((v) => v.was === 'bezahlt').length, 1) }) -test('SEPA: completed unbezahlt ändert nichts, async_payment_succeeded bucht', async () => { +test('SEPA: completed unbezahlt lässt die Bestellung offen und schickt einmal die Eingangsmail G15, async_payment_succeeded bucht', async () => { await aufbau() const b = await bestelle() const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) @@ -241,7 +241,11 @@ test('SEPA: completed unbezahlt ändert nichts, async_payment_succeeded bucht', assert.equal(order(b.orderId).status, 'offen') assert.equal(produkt('a1').bestand, 5) await warteAufVersand() - assert.equal(sink.sent.length, 0) + assert.deepEqual(sink.sent.map((m) => [m.to[0].address, m.subject.startsWith('Wir haben Ihre Bestellung')]), [['max@example.com', true]], 'nur die Eingangsmail an den Käufer') + assert.ok(daten(b.orderId).mailsEingangAt, 'Marker gesetzt') + assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200) + await warteAufVersand() + assert.equal(sink.sent.length, 1, 'Wiederholung des Ereignisses: keine zweite Eingangsmail') const bezahlt = { ...unbezahlt, payment_status: 'paid' } assert.equal((await sende(ereignis('checkout.session.async_payment_succeeded', bezahlt))).status, 200) assert.equal(order(b.orderId).status, 'bezahlt') diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index faaf274..a6f73c4 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -308,10 +308,10 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb /** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */ const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra }) -test('mailKlasse: G01/G02/G08 (Webshop: G09/G12) vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { - assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08', 'G09', 'G12']) +test('mailKlasse: G01/G02/G08 (Webshop: G09/G12/G15) vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => { + assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08', 'G09', 'G12', 'G15']) assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21']) - const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'vorrang', G10: 'normal', G11: 'normal', G12: 'vorrang', G13: 'normal', G14: 'normal', K20: 'nachrangig', K21: 'nachrangig' } + const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', G09: 'vorrang', G10: 'normal', G11: 'normal', G12: 'vorrang', G13: 'normal', G14: 'normal', G15: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' } assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet) assert.equal(mailKlasse('unbekannt'), 'normal') })