diff --git a/docs/funktionen/website-datenschutz-absatz.md b/docs/funktionen/website-datenschutz-absatz.md index d1c2e91..0a9a485 100644 --- a/docs/funktionen/website-datenschutz-absatz.md +++ b/docs/funktionen/website-datenschutz-absatz.md @@ -7,6 +7,7 @@ Hinweise für das Team: - Variante A, wenn WEBklar die Website hostet (Abo). Variante B, wenn die Website woanders liegt und die Pinnwand von `project.webklar.com` lädt. - Im sichtbaren Text der Kundenwebsite keine Gedankenstriche (Wunsch von Emma's Schatzkiste). Den Text unverändert übernehmen oder ohne Gedankenstriche anpassen. - Anrede und Ton an die übrige Erklärung der Website anpassen (meist Sie). `[Überschrift]`-Nummerierung der Website beachten. +- Die Löschfrist von 14 Tagen für Server-Protokolle entspricht Abschnitt 3 der Datenschutzerklärung von WEBklar (project.webklar.com/datenschutz.html). Ändert sich dort die Frist, hier mitändern. - Beide Varianten setzen voraus, dass das Skript keine Cookies setzt und nichts im Browser speichert. Das gilt für `news.js` (nur lesende Abrufe). ## Variante A: Website wird von WEBklar gehostet @@ -19,4 +20,4 @@ Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text u ### Neuigkeiten auf dieser Website -Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag. +Auf dieser Website zeigen wir unter „Neuigkeiten“ kurze Beiträge mit Text und Fotos. Diese Beiträge und Bilder lädt Ihr Browser beim Aufruf der Seite von project.webklar.com, einem Server der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, der bei der Hetzner Online GmbH in Falkenstein (Deutschland) steht. Dabei werden Ihre IP-Adresse, Datum und Uhrzeit des Aufrufs sowie Angaben zu Ihrem Browser und Betriebssystem an project.webklar.com übertragen und kurzzeitig in Server-Protokollen gespeichert. Die Pinnwand setzt keine Cookies und speichert nichts in Ihrem Browser. Wir nutzen den Dienst, damit wir Ihnen aktuelle Informationen zeigen können, ohne die Website jedes Mal neu zu veröffentlichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der einfachen und aktuellen Darstellung unserer Neuigkeiten. Die Protokolle werden nach 14 Tagen gelöscht. WEBklar verarbeitet die Daten in unserem Auftrag. [Prüfen: AVV mit WEBklar erforderlich] diff --git a/public/app.js b/public/app.js index f7d2bf9..c8dd3e2 100644 --- a/public/app.js +++ b/public/app.js @@ -137,7 +137,7 @@ function projectState(project) { return ['kb-state-off', 'Noch keine Website'] } -const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' } +const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand' } function renderProjectCard(project, features) { const li = document.createElement('li') @@ -146,7 +146,7 @@ function renderProjectCard(project, features) { const url = project.previewUrl ? escapeAttr(project.previewUrl) : '' const [stateClass, stateText] = projectState(project) const extraChips = featuresForProject(features, project.id) - .map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey) + .map((f) => FEATURE_LABELS[f.featureKey]) // unbekannte Schluessel nie roh anzeigen .filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label)) li.innerHTML = ` diff --git a/public/dashboard.html b/public/dashboard.html index 95baacc..dfe0b7a 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -168,7 +168,7 @@ -
+

News-Pinnwand

Kurze Neuigkeiten, die auf deiner Website hängen.

diff --git a/public/datenschutz.html b/public/datenschutz.html index f59fff9..d01ee08 100644 --- a/public/datenschutz.html +++ b/public/datenschutz.html @@ -86,9 +86,9 @@

6a. News-Beiträge und Fotos

Mit der Funktion „News-Pinnwand“ können Sie im Kundenportal kurze Beiträge mit Titel, Text und Foto schreiben, die auf Ihrer eigenen Website erscheinen. Dafür verarbeiten wir den Inhalt Ihrer Beiträge, hochgeladene Fotos, Zettelfarbe und Anzeigedauer sowie die Zeitpunkte von Anlage, Änderung und Abnahme. Beiträge und Fotos speichern wir auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).

-

Jedes Foto wird beim Hochladen neu kodiert und verkleinert. Dabei entfernen wir alle Metadaten, auch Angaben zum Aufnahmeort (GPS) und zur Kamera. Nicht verwendete Uploads löschen wir spätestens nach 24 Stunden.

+

Jedes Foto wird beim Hochladen neu kodiert und verkleinert. Dabei entfernen wir alle Metadaten, auch Angaben zum Aufnahmeort (GPS) und zur Kamera. Nicht verwendete Uploads löschen wir nach etwa 24 Stunden.

Veröffentlichte Beiträge sind auf Ihrer Website öffentlich sichtbar, bis die von Ihnen gewählte Anzeigedauer endet oder Sie den Beitrag abnehmen. Die Besucher Ihrer Website sehen dabei nur den Inhalt der Beiträge, nicht Ihre Kundendaten. Sie können Beiträge und Fotos jederzeit im Kundenportal löschen. Was beim Aufruf der Pinnwand durch einen Besucher übertragen wird, steht in der Datenschutzerklärung Ihrer Website.

-

Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag). Nach dem Ende der Geschäftsbeziehung löschen wir Ihre Beiträge und Fotos zusammen mit den übrigen Portal-Daten nach den Fristen in Abschnitt 6 und 7 und schalten die Funktion ab.

+

Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag). Auf Ihren Wunsch oder wenn die Zusammenarbeit endet und wir die Funktion abschalten, ist die Pinnwand auf Ihrer Website nicht mehr erreichbar. Beiträge und Fotos löschen wir, wenn Sie sie selbst löschen, andernfalls nach den Fristen in Abschnitt 7.

7. Abos und Zahlung über Stripe

Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.

diff --git a/public/embed/news.js b/public/embed/news.js index fcd0117..a3fe666 100644 --- a/public/embed/news.js +++ b/public/embed/news.js @@ -34,7 +34,7 @@ '.wkn-polaroid figcaption{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-top:12px}', '.wkn-polaroid figcaption b{font:600 17px/1.25 var(--wkn-font-display);color:var(--wkn-ink);overflow-wrap:break-word;hyphens:auto}', '.wkn-combo{container:wkn-card/inline-size}', - '.wkn-combo-in{display:grid;grid-template-columns:1.25fr 1fr;align-items:center}', + '.wkn-combo-in{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);align-items:center}', '.wkn-combo .wkn-polaroid{transform:rotate(-2deg)}', '.wkn-combo .wkn-note{margin-left:-28px;transform:rotate(2.5deg);z-index:1}', '.wkn-combo::before{left:28%}', @@ -57,7 +57,7 @@ '.wkn-more{margin-top:24px;padding-inline:20px}.wkn-more .wkn-btn{width:100%}', '.wkn-swipehint{display:flex}', '}', - '@container wkn-card (max-width:340px){.wkn-combo-in{grid-template-columns:1fr}.wkn-combo .wkn-note{margin:-26px 12px 0 26px}}', + '@container wkn-card (max-width:340px){.wkn-combo-in{grid-template-columns:minmax(0,1fr)}.wkn-combo .wkn-note{margin:-26px 12px 0 26px}}', '@media (prefers-reduced-motion:reduce){.wkn-pin,.wkn-btn,.wkn-dots i{transition:none}}' ].join('\n') diff --git a/public/funktionen.js b/public/funktionen.js index 68f237b..7a425b0 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -64,7 +64,7 @@ const isSetup = (state) => state === 'ordered' || state === 'setup' // Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste function pickProject(projects) { - return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects[0] + return projects.find((p) => newsOf(p).state === 'active') || projects.find((p) => isSetup(newsOf(p).state)) || projects.find((p) => newsOf(p).state === 'off') || projects[0] } /* ------------------------------------------------------------ Kacheln --- */ @@ -80,7 +80,7 @@ function newsTile(projects) { const active = states.filter((s) => s === 'active').length let pill = ['Aus', 'off'] let cta = `Einrichten lassen · ${PRICE}` - if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } + if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' } const target = pickProject(projects) const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -126,7 +126,14 @@ export async function initNews(root, { projectId } = {}) { const project = projects.find((p) => p.projectId === selection.projectId) || pickProject(projects) selection.projectId = project.projectId const feature = newsOf(project) - const ordered = /[?&]bestellt=1\b/.test(window.location.hash) + // Rueckkehr von Stripe (#news?bestellt=1): Flag nur einmal auswerten und aus der Adresse nehmen + const orderedFlag = /[?&]bestellt=1\b/.test(window.location.hash) + if (orderedFlag) window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#news`) + const ordered = orderedFlag && isSetup(feature.state) + // Editor nicht neu aufbauen, solange Projekt und Zustand gleich sind: Entwurf und hochgeladenes Foto bleiben + const renderKey = `${project.projectId}:${feature.state}` + if (feature.state === 'active' && root.dataset.newsKey === renderKey && root.querySelector('.nw')) return + root.dataset.newsKey = feature.state === 'active' ? renderKey : '' root.innerHTML = ` ${projects.length > 1 ? ` @@ -140,7 +147,7 @@ export async function initNews(root, { projectId } = {}) { initNews(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) }) const stage = root.querySelector('#news-stage') - const thanks = ordered && feature.state !== 'active' + const thanks = ordered ? '

Danke! Sobald die Zahlung da ist, legen wir los.

' : '' if (feature.state === 'active') renderEditor(stage, project) diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html index 10e7648..ba7197b 100644 --- a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html @@ -60,10 +60,10 @@

Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

3. Einbau und Freischaltung

-

Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau dauert erfahrungsgemäß meist innerhalb von 1–2 Wochen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.

+

Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.

4. Ende der Zusammenarbeit

-

Endet die Zusammenarbeit mit uns, schalten wir die Pinnwand ab. Ihre Beiträge und Fotos löschen wir nach den Fristen der Datenschutzerklärung (Abschnitt 6a).

+

Auf Ihren Wunsch schalten wir die Pinnwand ab; das tun wir auch, wenn die Zusammenarbeit endet und wir die Funktion bei Ihnen abschalten. Ihre Beiträge und Fotos werden gelöscht, wenn Sie sie selbst löschen, andernfalls nach den Fristen der Datenschutzerklärung (Abschnitt 6a, dort Abschnitt 7).

5. Ihre Inhalte

Sie sind für die Inhalte Ihrer Beiträge und Fotos verantwortlich. Sie versichern, dass Sie die nötigen Rechte an Texten und Fotos haben und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.

diff --git a/public/widerrufen.html b/public/widerrufen.html index 968a493..3086da5 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -20,6 +20,7 @@ + diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index 2b5d421..543bd11 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -8,7 +8,7 @@ Absender: „WEBklar“ Empfänger: E-Mail aus dem Formular · BCC intern Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, pruefen + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). --> @@ -50,6 +50,9 @@ a{color:#111111;} + + + diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index d6038bd..56ee548 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.html @@ -8,7 +8,7 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen --> @@ -60,6 +60,9 @@ a{color:#111111;} + + + diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html index 4a52403..306e137 100644 --- a/server/mail-templates/T16 Funktion bestellt.html +++ b/server/mail-templates/T16 Funktion bestellt.html @@ -7,8 +7,9 @@ Betreff: Neue Funktion bestellt: News-Pinnwand Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com - Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{woid}}, {{sitekey}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen + Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{woid}}, {{sitekey}}, {{sitzung}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert + ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt. zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer. --> @@ -39,6 +40,9 @@ a{color:#111111;} + + +   diff --git a/server/routes/abo.js b/server/routes/abo.js index eed9c3c..fe0e4b6 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -18,7 +18,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' -import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent } from '../services/siteFeatures.js' +import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -704,13 +704,13 @@ export function publicRateLimitState() { // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert const CONTRACTS = ['abo', 'zusatzspeicher'] -const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion'] // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } /** * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. - * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -782,6 +782,7 @@ function contractLabel(contract, sub) { function orderLabel(order, sub, { customer = false } = {}) { if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' + if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` } @@ -1067,6 +1068,25 @@ async function withdrawOrder(sub, d, receivedAt) { } } +const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' } + +/** + * Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags- + * ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team + * bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand. + */ +async function withdrawFeature(d, receivedAt) { + const customer = + (await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null)) + const orders = customer ? await paidFeatureOrders(customer.$id) : [] + const ends = orders.filter((o) => o.orderedAt).map((o) => withdrawalDeadline(o.orderedAt).endsAt) + const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null + const lines = orders + .map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`) + .join('\n') + return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline } +} + /** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ function describeWithdrawn(events) { return events @@ -1085,11 +1105,19 @@ router.post('/public/withdraw', async (req, res) => { let sub = null let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } + // Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) + const feature = d.order === 'funktion' + let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false } try { - sub = await findActiveSubscriptionByEmail(d.email) - if (sub) { - result = await withdrawOrder(sub, d, receivedAt) - sub = result.sub || sub + if (feature) { + fn = await withdrawFeature(d, receivedAt) + result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) } + } else { + sub = await findActiveSubscriptionByEmail(d.email) + if (sub) { + result = await withdrawOrder(sub, d, receivedAt) + sub = result.sub || sub + } } } catch (err) { console.error('[abo] Widerruf nicht verarbeitbar:', err.message) @@ -1103,7 +1131,7 @@ router.post('/public/withdraw', async (req, res) => { values: { name: d.name, bestellung: orderLabel(d.order, sub, { customer: true }), - produkt: productLabelOf(sub), + produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber, @@ -1113,7 +1141,8 @@ router.post('/public/withdraw', async (req, res) => { abschluss: inTime && d.order === 'abschluss', wechsel: inTime && d.order === 'wechsel', zusatzspeicher: inTime && d.order === 'zusatzspeicher', - pruefen: !inTime, + funktion: feature, + pruefen: !inTime && !feature, }, }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) @@ -1125,11 +1154,19 @@ router.post('/public/withdraw', async (req, res) => { name: d.name, kundennummer: d.customerNumber, bestellung: orderLabel(d.order, sub), - produkt: productLabelOf(sub), + produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', - rechtzeitig: !sub + rechtzeitig: feature + ? !fn.customer + ? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)' + : !fn.orders.length + ? 'ungeprüft (keine bezahlte Funktion gefunden)' + : inTime + ? 'RECHTZEITIG' + : 'NACH Fristende, vor einer Ablehnung bitte prüfen' + : !sub ? 'ungeprüft (kein Abo gefunden)' : inTime ? 'RECHTZEITIG' @@ -1142,18 +1179,19 @@ router.post('/public/withdraw', async (req, res) => { : result.deadline ? 'ungeprüft (keine offene Frist: Bestellung gescheitert)' : 'ungeprüft (keine Bestellung dieser Art gefunden)', - ereignisse: describeWithdrawn(result.withdrawn), + ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn), pruefhinweis, }, blocks: { kundennummer: Boolean(d.customerNumber), abo_gefunden: Boolean(sub), auf_kuendigung_gesetzt: result.canceled, - kein_abo_gefunden: !sub, + kein_abo_gefunden: !sub && !feature, kuendigung_fehlgeschlagen: result.cancelFailed, abschluss: inTime && d.order === 'abschluss', wechsel: inTime && d.order === 'wechsel', zusatzspeicher: inTime && d.order === 'zusatzspeicher', + funktion: feature, keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn, nachweis_fehlt: Boolean(sub) && !result.event, wechsel_offen: result.openSwitchWithdrawn, diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 2b639f3..cc65bb7 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -3,8 +3,15 @@ import { config } from '../config.js' import { getDocument } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { withLocks } from '../services/keyedLock.js' -import { createFeatureCheckout } from '../services/stripeBilling.js' -import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js' +import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js' +import { + DISABLED_MESSAGE, + assertOwnProject, + backfillInvoiceLinks, + getFeatureDoc, listCustomerFeatures, + markOrdered, + runActivation, +} from '../services/siteFeatures.js' const router = Router() const DAY_MS = 24 * 60 * 60 * 1000 @@ -20,6 +27,7 @@ router.get('/', requireSession, async (req, res) => { const customerId = getSessionCustomerId(req) // Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message)) + await backfillInvoiceLinks(customerId) res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate, @@ -30,6 +38,27 @@ router.get('/', requireSession, async (req, res) => { } }) +/** + * Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal + * (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft + * oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409. + */ +async function openOrderSession(doc) { + const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) + if (!doc.stripeSessionId) return null + let session + try { + session = await retrieveCheckoutSession(doc.stripeSessionId) + } catch (err) { + console.warn('[funktionen] Sitzung nicht abrufbar:', err.message) + if (Date.now() - new Date(doc.orderedAt || 0).getTime() < DAY_MS) throw conflict() + return null + } + if (session.status === 'open' && session.url) return { id: session.id, url: session.url } + if (session.status === 'expired') return null + throw conflict() +} + router.post('/news/checkout', requireSession, async (req, res) => { try { if (req.session.viewAs) { @@ -47,12 +76,17 @@ router.post('/news/checkout', requireSession, async (req, res) => { // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { - // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409 + // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung + // richtet sich nach ihrer gespeicherten Stripe-Sitzung const existing = await getFeatureDoc(customerId, projectId, 'news') - const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS - if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) { + if (existing?.state === 'setup' || existing?.state === 'active') { throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) } + if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 }) + if (existing?.state === 'ordered') { + const open = await openOrderSession(existing) + if (open) return open + } const customer = await getDocument(config.collections.customers, customerId) const now = new Date().toISOString() const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 6f2bf54..9302e90 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -3,12 +3,15 @@ import { config } from '../config.js' import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' -import { getStripe } from './stripeBilling.js' +import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = ['news'] export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] +export const DISABLED_MESSAGE = + 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.' + const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' function httpError(status, message) { @@ -143,12 +146,64 @@ export async function listCustomerFeatures(customerId) { return result } +/** + * Rechnungslink nachtragen: Stripe legt die Rechnung eines Zahlungs-Checkouts erst kurz nach der Zahlung an, + * der Webhook sieht sie oft noch nicht. Best effort - Fehler werden geloggt und nie weitergereicht. + */ +export async function backfillInvoiceLinks(customerId) { + try { + const docs = await listDocuments(config.collections.portalFeatures, [ + Query.equal('customerId', customerId), + Query.limit(1000), + ]) + for (const doc of docs) { + if ((doc.state !== 'setup' && doc.state !== 'active') || doc.invoiceUrl || !doc.stripeSessionId) continue + try { + const session = await retrieveCheckoutSession(doc.stripeSessionId, { expandInvoice: true }) + const invoice = session.invoice && typeof session.invoice === 'object' ? session.invoice : null + if (!invoice?.hosted_invoice_url) continue + await updateDocument(config.collections.portalFeatures, doc.$id, { + stripeInvoiceId: invoice.id || doc.stripeInvoiceId || '', + invoiceUrl: invoice.hosted_invoice_url, + }) + } catch (err) { + console.warn(`[funktionen] Rechnungslink fuer Sitzung ${doc.stripeSessionId} nicht nachgetragen:`, err.message) + } + } + } catch (err) { + console.warn('[funktionen] Rechnungslinks nicht nachgetragen:', err.message) + } +} + +/** + * Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09). + * Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht. + */ +export async function paidFeatureOrders(customerId) { + const docs = await listDocuments(config.collections.portalFeatures, [ + Query.equal('customerId', customerId), + Query.limit(1000), + ]) + const paid = docs.filter((d) => ['setup', 'active', 'disabled'].includes(d.state) && d.unlockedByPurchase) + const result = [] + for (const doc of paid) { + result.push({ + projekt: await projectName(doc.projectId).catch(() => ''), + state: doc.state, + orderedAt: doc.orderedAt || '', + ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null, + }) + } + return result +} + export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) const doc = await ensureFeatureDoc(customerId, projectId, key) if (doc.state === 'setup' || doc.state === 'active') { throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet') } + if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE) return updateDocument(config.collections.portalFeatures, doc.$id, { featureKey: key, ...stateFields('ordered'), @@ -174,22 +229,26 @@ async function findBySession(sessionId) { /** * Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst * wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId - * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. + * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook: + * 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus). */ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { if (!sessionId) return { doc: null, created: false } return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) if (!doc) return { doc: null, created: false } - if (doc.state !== 'ordered') return { doc, created: false } + const resumable = doc.state === 'setup' && !doc.ticketId + if (doc.state !== 'ordered' && !resumable) return { doc, created: false } - const setup = await updateDocument(config.collections.portalFeatures, doc.$id, { - ...stateFields('setup'), - purchaseStatus: 'paid', - unlockedByPurchase: true, - stripeInvoiceId: invoiceId || '', - invoiceUrl: invoiceUrl || '', - }) + const setup = resumable + ? doc + : await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('setup'), + purchaseStatus: 'paid', + unlockedByPurchase: true, + stripeInvoiceId: invoiceId || '', + invoiceUrl: invoiceUrl || '', + }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) const { ticketId } = await createFeatureTicket({ customerId: doc.customerId, @@ -354,8 +413,27 @@ async function teamNotice(doc, customer, { failed = false } = {}) { projekt: await projectName(doc.projectId), woid: failed ? '' : await ticketWoidOf(doc), sitekey: failed ? '' : doc.siteKey || '', + sitzung: '', }, - blocks: { zahlung_fehlgeschlagen: failed }, + blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false }, + }) +} + +/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */ +async function unmatchedNotice(session) { + const { customerId, projectId } = session.metadata || {} + const customer = customerId ? await customerOf(customerId).catch(() => null) : null + await sendAdminNotice({ + template: 'T16', + values: { + kunde_name: customer?.name || customer?.email || customerId || 'unbekannt', + email: customer?.email || '', + projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '', + woid: '', + sitekey: '', + sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`, + }, + blocks: { zahlung_fehlgeschlagen: false, ungeklaert: true }, }) } @@ -371,6 +449,11 @@ async function adoptPaidSession(session) { console.error( `[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen` ) + try { + await unmatchedNotice(session) + } catch (err) { + console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err.message) + } return false } await updateDocument(config.collections.portalFeatures, doc.$id, { @@ -384,7 +467,8 @@ async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } let { doc, created } = await markPaid(paid) - if (!doc && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid)) + // Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an + if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid)) if (!created) return // Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und // wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200. diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 4da6906..aaae8fb 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -633,6 +633,11 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news', return { id: session.id, url: session.url } } +/** Gespeicherte Funktions-Sitzung abrufen (status open|complete|expired); mit expand die Rechnung als Objekt. */ +export async function retrieveCheckoutSession(id, { expandInvoice = false } = {}) { + return getStripe().checkout.sessions.retrieve(id, expandInvoice ? { expand: ['invoice'] } : {}) +} + export async function createBillingPortalUrl(customerId) { const sub = await getCurrentSubscription(customerId) if (!sub?.stripeCustomerId) { diff --git a/server/services/ticketAdmin.js b/server/services/ticketAdmin.js index 515a332..a2e35a0 100644 --- a/server/services/ticketAdmin.js +++ b/server/services/ticketAdmin.js @@ -96,6 +96,7 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit `Domain: ${domain}`, `siteKey: ${siteKey}`, `Einbau: ${embed}`, + 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', ].join('\n'), createdAt: new Date().toISOString(), }, diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index 08711bd..8383882 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' @@ -98,16 +99,26 @@ test('checkout_legt_session_an', async () => { assert.equal(doc.consentText, config.funktionen.consentText) assert.ok(doc.consentAt) assert.ok(doc.consentUserAgent) - // zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung - assert.equal((await checkout('p1')).status, 409) + // zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung + const again = await checkout('p1') + assert.equal(again.status, 200) + assert.equal(again.body.url, res.body.url) assert.equal(sessions().length, 1) }) -test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => { - const doc = await sf.getFeatureDoc('c1', 'p1', 'news') - aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() }) - assert.equal((await checkout('p1')).status, 200) +test('abgelaufene_sitzung_darf_neu_gestartet_werden', async () => { + sessions().at(-1).status = 'expired' + const res = await checkout('p1') + assert.equal(res.status, 200) assert.equal(sessions().length, 2) + assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).stripeSessionId, sessions().at(-1).id) +}) + +test('abgeschlossene_sitzung_409', async () => { + sessions().at(-1).status = 'complete' + const before = sessions().length + assert.equal((await checkout('p1')).status, 409) + assert.equal(sessions().length, before) }) test('checkout_bei_setup_409', async () => { @@ -192,7 +203,8 @@ test('parallele_checkouts_eine_sitzung', async () => { aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' }) const before = sessions().length const results = await Promise.all([checkout('p8'), checkout('p8')]) - assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]) + assert.deepEqual(results.map((r) => r.status), [200, 200]) + assert.equal(results[0].body.url, results[1].body.url) assert.equal(sessions().length, before + 1) }) @@ -300,3 +312,86 @@ test('ticket_fehlt_markiert', async () => { assert.equal(missing.ticketMissing, true) assert.equal(missing.ticketWoid, null) }) + +// ------------------------------------------------ Schlussdurchgang --- +test('webhook_retry_setzt_ticketlose_einrichtung_fort', async () => { + aw.seed('websiteProjects', { $id: 'pr1', customerId: 'c1', projectName: 'Retry' }) + await checkout('pr1') + const id = sessions().at(-1).id + const wosBefore = workorders().length + const mailsBefore = mails.sent.length + aw.failNext({ collection: 'workorders', method: 'POST', status: 400 }) + const first = await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1'))) + assert.equal(first.status, 500) + const stuck = await sf.getFeatureDoc('c1', 'pr1', 'news') + assert.equal(stuck.state, 'setup') + assert.ok(!stuck.ticketId) + assert.equal(mails.sent.length, mailsBefore) + assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))).status, 200) + const fixed = await sf.getFeatureDoc('c1', 'pr1', 'news') + assert.ok(fixed.ticketId) + assert.equal(workorders().length, wosBefore + 1) + assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand']) + // weiterer Retry: nichts mehr + await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1'))) + assert.equal(workorders().length, wosBefore + 1) + assert.equal(mails.sent.length - mailsBefore, 2) +}) + +test('rechnungslink_wird_nachgetragen', async () => { + aw.seed('websiteProjects', { $id: 'pi1', customerId: 'c1', projectName: 'Rechnung' }) + await checkout('pi1') + const session = sessions().at(-1) + await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pi1'))) + const listed = async () => (await get('/api/funktionen', { cookie })).projects.find((p) => p.projectId === 'pi1').features.news + assert.equal((await listed()).invoiceUrl, null) + st.invoices.set('in_late', { id: 'in_late', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/late' }) + session.invoice = 'in_late' + assert.equal((await listed()).invoiceUrl, 'https://invoice.stripe.test/late') + const doc = await sf.getFeatureDoc('c1', 'pi1', 'news') + assert.equal(doc.stripeInvoiceId, 'in_late') + // Stripe-Fehler lassen die Liste nicht scheitern + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), invoiceUrl: '' }) + st.failNext({ method: 'GET', path: /checkout\/sessions/, status: 400 }) + assert.equal((await get('/api/funktionen', { cookie })).projects.length > 0, true) +}) + +test('bezahlt_ohne_passende_bestellung_meldet_dem_team', async () => { + aw.seed('websiteProjects', { $id: 'pu1', customerId: 'c1', projectName: 'Unklar' }) + const mailsBefore = mails.sent.length + const res = await postWebhook(event('checkout.session.completed', paidSession('cs_verwaist', 'pu1'))) + assert.equal(res.status, 200) + const notice = mails.sent.slice(mailsBefore) + assert.equal(notice.length, 1) + assert.equal(notice[0].subject, 'Neue Funktion bestellt: News-Pinnwand') + assert.match(notice[0].text, /cs_verwaist/) + assert.match(notice[0].text, /nicht zuzuordnen/) + assert.doesNotMatch(notice[0].text, /Zahlung fehlgeschlagen/) + assert.equal(workorders().filter((t) => t.topic.includes('Unklar')).length, 0) +}) + +test('abgeschaltete_funktion_nicht_buchbar', async () => { + aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Abgeschaltet' }) + const doc = await sf.adminActivate('c1', 'pd1', 'news', { by: 'test' }) + await sf.disable(doc.$id, { by: 'test' }) + const before = sessions().length + const res = await checkout('pd1') + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.') + assert.equal(sessions().length, before) +}) + +test('statische_quellen: news_label, minmax_grid, ticket_anleitung', () => { + const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8') + assert.match(read('public/app.js'), /FEATURE_LABELS = \{[^}]*news: 'News-Pinnwand'/) + assert.doesNotMatch(read('public/app.js'), /FEATURE_LABELS\[f\.featureKey\] \|\| f\.featureKey/) + const embed = read('public/embed/news.js') + assert.ok(embed.includes('grid-template-columns:minmax(0,1.25fr) minmax(0,1fr)')) + assert.ok(embed.includes('.wkn-combo-in{grid-template-columns:minmax(0,1fr)}')) + assert.ok(!read('public/dashboard.html').includes('id="view-news" aria-live')) +}) + +test('ticket_details_nennen_die_anleitung', () => { + const doc = [...aw.docs('workorders').values()].find((t) => String(t.topic).startsWith('News-Pinnwand einbauen')) + assert.ok(doc.details.includes('Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)')) +}) diff --git a/test/legalTexts.test.js b/test/legalTexts.test.js index a843bde..af0580f 100644 --- a/test/legalTexts.test.js +++ b/test/legalTexts.test.js @@ -473,12 +473,20 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s) assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend')) assert.ok(html.includes('

7. Abos und Zahlung über Stripe

'), 'keine Umnummerierung') + assert.ok(news.includes('nach etwa 24 Stunden') && !news.includes('spätestens nach 24 Stunden')) + assert.ok(news.includes('nach den Fristen in Abschnitt 7') && !news.includes('Abschnitt 6 und 7')) + assert.ok(!news.includes('Endet die Zusammenarbeit, schalten wir')) const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html') assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend')) const text = strip(lb) + assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir')) + assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß')) for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s) const muster = read('docs/funktionen/website-datenschutz-absatz.md') + // 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt + assert.ok(!muster.includes('nach 14 Tagen gelöscht') || strip(html).includes('Die Protokolle löschen wir nach 14 Tagen')) + assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]')) assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche') }) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 7e5f589..4623389 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -260,11 +260,11 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`) for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`) assert.ok(loadTemplate('K04').placeholders.includes('bestellung')) - assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen']) + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen']) for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`) for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`) for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`) - for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`) + for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`) const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' } const speicher = renderMail('K03', k03, { zusatzspeicher: true }) diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 3ba3d4f..343394a 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -452,6 +452,31 @@ test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerr assert.match(t09().text, /Menge .*zurück/) }) +test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Funktion, Projekt und Fristende', async () => { + await resetSubH({ units: 0 }) + const orderedAt = daysAgo(3) + aw.seed('websiteProjects', { $id: 'pf1', customerId: 'c1', projectName: 'Bäckerei Funktion' }) + aw.seed('portalFeatures', { $id: 'f_pf1_news', customerId: 'c1', projectId: 'pf1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt }) + const before = st.calls.length + const res = await withdraw({ order: 'funktion' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') + assert.deepEqual(await listContractEvents('c1'), [], 'kein Vertragsereignis, kein Abo betroffen') + assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined) + assert.equal(aw.docs('portalFeatures').get('f_pf1_news').state, 'setup') + assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/) + assert.match(k04().text, /Wir prüfen Ihren Widerruf/) + assert.doesNotMatch(k04().text, /Es erfolgen keine weiteren Abbuchungen/) + const team = t09().text + assert.match(team, /Funktion \(z\. B\. News-Pinnwand\)/) + assert.match(team, /NICHTS automatisch geändert/) + assert.match(team, /Bäckerei Funktion: in Einrichtung/) + assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt))) + assert.match(team, /RECHTZEITIG/) + assert.doesNotMatch(team, /KEIN aktives Abo/) + aw.docs('portalFeatures').delete('f_pf1_news') +}) + test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => { const startedAt = daysAgo(2) await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } }) @@ -656,6 +681,7 @@ test('widerrufen.html: Radioknöpfe order', () => { assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/) assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/) assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/) + assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/) assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/) assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/) }) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index bfdd172..381053d 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -314,10 +314,22 @@ export async function startFakeStripe() { subscription: null, metadata: data.metadata || {}, subscription_data: data.subscription_data || {}, + status: 'open', + payment_status: 'unpaid', + invoice: null, } checkoutSessions.set(id, obj) return [200, obj] } + // Sitzung abrufen (Funktions-Checkout); expand=invoice ersetzt die Rechnungs-ID durch das Objekt. + // Tests setzen status/payment_status/invoice direkt im Objekt aus st.checkoutSessions. + if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) { + const found = checkoutSessions.get(m[1]) + if (!found) return noSuch('checkout.session', m[1]) + const expandInvoice = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'invoice') + const inv = typeof found.invoice === 'string' ? invoices.get(found.invoice) : null + return [200, expandInvoice && inv ? { ...found, invoice: inv } : found] + } if (method === 'GET' && path === '/v1/billing_portal/configurations') { const active = query.get('active') const found = [...billingPortalConfigurations.values()].filter( diff --git a/test/ui/funktionen.ui.mjs b/test/ui/funktionen.ui.mjs index eda4c1e..4c1aec0 100644 --- a/test/ui/funktionen.ui.mjs +++ b/test/ui/funktionen.ui.mjs @@ -34,6 +34,7 @@ const SCENARIOS = { off: [proj('p1', 'Bäckerei Berg', 'off')], setup: [proj('p1', 'Bäckerei Berg', 'setup')], active: [proj('p1', 'Bäckerei Berg', 'active')], + disabled: [proj('p1', 'Bäckerei Berg', 'disabled')], multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')], } const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.' @@ -173,6 +174,17 @@ try { const back = await page.textContent('#view-news') check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen')) check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active'))) + check(`${label} bestellt_flag_aus_hash`, await page.evaluate(() => !window.location.hash.includes('bestellt')), await page.evaluate(() => window.location.hash)) + // Flag ohne bestellte Funktion (Zustand off): kein Dankeshinweis + await open('#news?bestellt=1', 'off') + await page.waitForSelector('#news-order') + check(`${label} bestellt_flag_ignoriert_bei_off`, !(await page.textContent('#view-news')).includes('Danke!')) + // Abgeschaltet: Pille und Knopf der Kachel, Status statt Buchung + await open('#funktionen', 'disabled') + await page.waitForSelector('[data-fn-tile="news"] .fn-pill') + const dis = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim() })) + check(`${label} kachel_abgeschaltet`, dis.pill === 'Abgeschaltet' && dis.btn === 'Status ansehen', JSON.stringify(dis)) + check(`${label} news_ohne_aria_live`, await page.evaluate(() => !document.getElementById('view-news').hasAttribute('aria-live'))) // Editor await open('#news', 'active') @@ -188,6 +200,15 @@ try { check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field')) const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`)) check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; ')) + // Zurueck zum Tab und wieder in News (gleiches Projekt, gleicher Zustand): Entwurf bleibt stehen + await page.fill('#nw-title', 'Entwurf bleibt') + await page.evaluate(() => document.querySelector('.kb-nav-item[data-view="funktionen"]').click()) + await page.waitForSelector('[data-fn-tile="news"] .fn-tile-cta') + await page.click('[data-fn-tile="news"] .fn-tile-cta') + await page.waitForSelector('#nw-title') + await page.waitForTimeout(200) + check(`${label} editor_entwurf_bleibt`, (await page.inputValue('#nw-title')) === 'Entwurf bleibt') + await page.fill('#nw-title', 'Neue Winterjacken') const lists = await page.textContent('#view-news') check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen')) await page.click('[data-del]')