Funktionen/News: Schlussdurchgang (Webhook-Retry, Checkout-Sitzung, Rechnungslink, Widerruf Funktion, UI- und Rechtstext-Korrekturen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 17:38:15 +00:00
1 parent 0dc57fe5e8
commit 4b7f73abba
22 files changed
+400 -57

No files matched your search

@@ -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]
+2 -2
View File
@@ -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 = `
+1 -1
View File
@@ -168,7 +168,7 @@
</section>
<!-- News-Pinnwand: Buchung, Einrichtungsstatus oder Editor (funktionen.js, initNews) -->
<section class="kb-view" id="view-news" aria-live="polite">
<section class="kb-view" id="view-news">
<header class="kb-head">
<h1>News-Pinnwand</h1>
<p>Kurze Neuigkeiten, die auf deiner Website hängen.</p>
+2 -2
View File
@@ -86,9 +86,9 @@
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
<h2 id="news">6a. News-Beiträge und Fotos</h2>
<p>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).</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>7. Abos und Zahlung über Stripe</h2>
<p>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.</p>
+2 -2
View File
@@ -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')
+11 -4
View File
@@ -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 ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
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 = `<p class="muted">${esc(err.message)}</p>` })
})
const stage = root.querySelector('#news-stage')
const thanks = ordered && feature.state !== 'active'
const thanks = ordered
? '<p class="fn-thanks" role="status">Danke! Sobald die Zahlung da ist, legen wir los.</p>'
: ''
if (feature.state === 'active') renderEditor(stage, project)
@@ -60,10 +60,10 @@
<p>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.</p>
<h2 id="einbau">3. Einbau und Freischaltung</h2>
<p>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.</p>
<p>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.</p>
<h2 id="ende">4. Ende der Zusammenarbeit</h2>
<p>Endet die Zusammenarbeit mit uns, schalten wir die Pinnwand ab. Ihre Beiträge und Fotos löschen wir nach den Fristen der Datenschutzerklärung (<a href="/datenschutz.html#news">Abschnitt 6a</a>).</p>
<p>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 (<a href="/datenschutz.html#news">Abschnitt 6a</a>, dort Abschnitt 7).</p>
<h2 id="inhalte">5. Ihre Inhalte</h2>
<p>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.</p>
+1
View File
@@ -20,6 +20,7 @@
<label class="radio"><input type="radio" name="order" value="abschluss" data-text="Abschluss meines Abos" checked> Abschluss meines Abos</label>
<label class="radio"><input type="radio" name="order" value="wechsel" data-text="Wechsel ins Wartungs-Abo"> Wechsel ins Wartungs-Abo</label>
<label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label>
<label class="radio"><input type="radio" name="order" value="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
</fieldset>
<label for="name" data-label-for="name" data-label="Name">Name</label>
@@ -8,7 +8,7 @@
Absender: „WEBklar“ <Portal-Postfach>
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).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -50,6 +50,9 @@ a{color:#111111;}
<!-- WENN zusatzspeicher -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; war die Einrichtung auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist abgeschlossen, verrechnen wir gegebenenfalls einen Wertersatz (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
<!-- ENDE pruefen -->
@@ -8,7 +8,7 @@
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
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
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -60,6 +60,9 @@ a{color:#111111;}
<!-- WENN zusatzspeicher -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Admin-Bereich abschalten. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
<!-- ENDE keine_frist -->
@@ -7,8 +7,9 @@
Betreff: Neue Funktion bestellt: News-Pinnwand
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
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.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -39,6 +40,9 @@ a{color:#111111;}
<!-- WENN zahlung_fehlgeschlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zahlung fehlgeschlagen</div><div class="mut" style="color:#4a4843;">Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.</div></td></tr></table></td></tr>
<!-- ENDE zahlung_fehlgeschlagen -->
<!-- WENN ungeklaert -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bezahlt, aber nicht zuzuordnen</div><div class="mut" style="color:#4a4843;">Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: {{sitzung}}</div></td></tr></table></td></tr>
<!-- ENDE ungeklaert -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
+51 -13
View File
@@ -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,
+39 -5
View File
@@ -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 })
+96 -12
View File
@@ -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.
+5
View File
@@ -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) {
+1
View File
@@ -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(),
},
+102 -7
View File
@@ -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)'))
})
+8
View File
@@ -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('<h2>7. Abos und Zahlung über Stripe</h2>'), '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')
})
+2 -2
View File
@@ -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 })
+26
View File
@@ -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'\]/)
})
+12
View File
@@ -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(
+21
View File
@@ -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]')