Wartungs-Abo (50 EUR/Monat) ueber Stripe mit Stundenkonto im Portal
- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen', danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile) - Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite (subscriptions) und schreibt je bezahlter Rechnung 60 min gut (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig) - Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan, 15-Minuten-Takt, Mehraufwand separat ausgewiesen - Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung - Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton (§ 356a) mit Eingangsbestaetigung per Mail - Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung, als Anhang der Vertragsbestaetigung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3f56ba290
commit
65429aa3ac
17 files changed
+1677
-2
No files matched your search
Generated
+22
-1
@@ -13,7 +13,8 @@
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3"
|
||||
"nodemailer": "^9.0.3",
|
||||
"stripe": "^19.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -840,6 +841,26 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-19.3.0.tgz",
|
||||
"integrity": "sha512-3MbqRkw5LXb4LWP1LgIEYxUAYhYDDU5pcHZj4Xha6VWPnN1wrUmQ7Htsgm8wR584s0hn1aQg1lYD0Hi+F37E5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@
|
||||
"express": "^4.21.2",
|
||||
"jose": "^6.0.11",
|
||||
"node-appwrite": "^14.1.0",
|
||||
"nodemailer": "^9.0.3"
|
||||
"nodemailer": "^9.0.3",
|
||||
"stripe": "^19.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Abo-Bedingungen – Webklar Wartungs-Abo</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 760px; margin: 0 auto; padding: 48px 20px 80px; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 0 0 6px; }
|
||||
h2 { font-size: 19px; margin: 36px 0 8px; padding-top: 12px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16px; margin: 20px 0 4px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ol, ul { padding-left: 22px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box strong { display: block; margin-bottom: 4px; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td { padding: 6px 8px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||
a { color: #6d1fd1; }
|
||||
nav { font-size: 14px; margin-bottom: 26px; }
|
||||
@media print { nav { display: none; } body { background: #fff; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/kuendigen.html">Verträge hier kündigen</a> · <a href="/widerrufen.html">Vertrag widerrufen</a></nav>
|
||||
|
||||
<h1>Webklar Wartungs-Abo</h1>
|
||||
<p class="meta">Leistungsbeschreibung und Besondere Vertragsbedingungen · Stand 25.09.2026 (Version 2026-09-25)</p>
|
||||
|
||||
<div class="box">
|
||||
<strong>Das Wichtigste auf einen Blick</strong>
|
||||
<table>
|
||||
<tr><td>Preis</td><td>50,00 € pro Monat, monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</td></tr>
|
||||
<tr><td>Enthalten</td><td>Betrieb & Pflege Ihrer Website/Web-Anwendung · 1 Domain · 1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat</td></tr>
|
||||
<tr><td>Stundenguthaben</td><td>sammelt sich an; jede Stunde ist 12 Monate ab Gutschrift gültig</td></tr>
|
||||
<tr><td>Laufzeit</td><td>unbestimmt, ohne Mindestlaufzeit, monatlich zum Ende des bezahlten Monats kündbar</td></tr>
|
||||
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · kenso.gri@gmail.com · 0176 23726355</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>1. Geltungsbereich</h2>
|
||||
<ol>
|
||||
<li>Diese Bedingungen gelten für das Wartungs-Abo zwischen der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch Kenso Gri („Webklar"), und dem Kunden. Sie gehen dem allgemeinen Projektvertrag von Webklar (<a href="https://webklar.com/agb">webklar.com/agb</a>) vor, soweit sie etwas anderes regeln. Die dortige Regelung zum jährlichen Hosting (Leistungsmodul C) gilt für das Wartungs-Abo nicht.</li>
|
||||
<li>Kunden können Verbraucher (§ 13 BGB) oder Unternehmer (§ 14 BGB) sein. Regelungen, die nur für eine der beiden Gruppen gelten, sind als solche gekennzeichnet.</li>
|
||||
<li>Vertragssprache ist Deutsch. Abweichende Bedingungen des Kunden gelten nicht.</li>
|
||||
</ol>
|
||||
|
||||
<h2>2. Vertragsschluss</h2>
|
||||
<ol>
|
||||
<li>Der Kunde bestellt das Wartungs-Abo im Kundenportal unter project.webklar.com. Vor der Bestellung sieht er eine Zusammenfassung mit Leistung, Preis und Laufzeit und kann Eingabefehler korrigieren.</li>
|
||||
<li>Mit dem Klick auf „Zahlungspflichtig bestellen" gibt der Kunde ein verbindliches Angebot ab. Der Vertrag kommt zustande, sobald die erste Zahlung über unseren Zahlungsdienstleister Stripe erfolgreich autorisiert ist. Webklar bestätigt den Vertrag per E-Mail und übermittelt dabei diese Bedingungen als Datei.</li>
|
||||
<li>Den Vertragstext speichert Webklar. Die jeweils aktuelle Fassung ist unter project.webklar.com/abo-bedingungen.html abrufbar. Die bei Vertragsschluss gültige Fassung erhält der Kunde mit der Bestätigungs-E-Mail.</li>
|
||||
</ol>
|
||||
|
||||
<h2>3. Leistungsbeschreibung</h2>
|
||||
<h3>3.1 Betrieb und Pflege der Website bzw. Web-Anwendung</h3>
|
||||
<ul>
|
||||
<li>Bereitstellung der von Webklar erstellten oder übernommenen Website/Web-Anwendung auf Servern, die Webklar betreibt (Rechenzentrum in Deutschland/EU). Maßstab ist eine faire, übliche Nutzung. Übermäßige Last, etwa durch große Downloads oder Streaming, ist nicht umfasst.</li>
|
||||
<li>Einspielen von Sicherheitsupdates für Server, Laufzeitumgebung und die eingesetzten Software-Abhängigkeiten. Kritische Sicherheitsupdates spielt Webklar in der Regel innerhalb von 3 Werktagen nach Veröffentlichung ein, sonstige Updates regelmäßig im Rahmen der Wartung.</li>
|
||||
<li>Einrichtung und Verlängerung eines SSL/TLS-Zertifikats (HTTPS).</li>
|
||||
<li>Automatische Überwachung der Erreichbarkeit und Behebung von Störungen im Verantwortungsbereich von Webklar (siehe Ziff. 5).</li>
|
||||
<li>Datensicherung (siehe Ziff. 6).</li>
|
||||
<li>Nicht geschuldet sind neue Funktionen, Redesigns, Inhaltsänderungen oder Anpassungen an geänderte Rechtslagen. Solche Arbeiten können über das Stundenguthaben (Ziff. 4) oder gesondert beauftragt werden.</li>
|
||||
</ul>
|
||||
<h3>3.2 Domain</h3>
|
||||
<ul>
|
||||
<li>Eine Domain mit einer gängigen Endung (.de, .com, .eu, .net, .org) inklusive Registrierung bzw. Übernahme, Verlängerung und DNS-Verwaltung. Premium-Domains und Endungen mit höheren Registrierungskosten werden gesondert vereinbart.</li>
|
||||
<li>Inhaber der Domain ist der Kunde. Webklar wird als technischer Verwalter eingetragen. Für die Auswahl der Domain und dafür, dass sie keine Rechte Dritter verletzt, ist der Kunde verantwortlich.</li>
|
||||
</ul>
|
||||
<h3>3.3 E-Mail</h3>
|
||||
<ul>
|
||||
<li>Ein E-Mail-Postfach unter der Domain des Kunden mit IMAP/SMTP-Zugang, Spam- und Virenfilter nach dem Stand der Technik. Den Speicherplatz legt der von Webklar eingesetzte E-Mail-Anbieter fest, er beträgt mindestens 2 GB.</li>
|
||||
<li>Die Zustellung von E-Mails hängt auch von Dritten ab, etwa von Empfangsservern und Spamfiltern. Webklar kann sie daher nicht garantieren.</li>
|
||||
</ul>
|
||||
<h3>3.4 Nicht enthalten</h3>
|
||||
<ul>
|
||||
<li>Erstellung von Inhalten, Texten und Bildern, Rechtstexte (Impressum/Datenschutz), SEO, Werbung, Support für Endgeräte oder E-Mail-Programme des Kunden.</li>
|
||||
<li>Fehlerbehebung an Code oder Inhalten, die der Kunde oder Dritte ohne Absprache verändert haben.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Stundenguthaben</h2>
|
||||
<ol>
|
||||
<li>Mit jeder bezahlten Monatsgebühr erhält der Kunde 60 Minuten Arbeitszeit gutgeschrieben, die erste Stunde bereits mit der ersten Zahlung.</li>
|
||||
<li>Nicht genutzte Zeit sammelt sich an. Jede Gutschrift ist <strong>12 Monate</strong> ab dem Tag der Gutschrift gültig und verfällt danach ersatzlos. Verbraucht wird immer die älteste noch gültige Gutschrift zuerst.</li>
|
||||
<li>Das Guthaben gilt für Änderungen, Erweiterungen und Unterstützung rund um die im Abo betreute Website/Anwendung, Domain und E-Mail. Neue Projekte sind davon ausgenommen. Aufträge erteilt der Kunde über den Chat im Kundenportal oder per E-Mail.</li>
|
||||
<li>Abgerechnet wird die tatsächlich aufgewendete Zeit, je Arbeitseintrag aufgerundet auf volle 15 Minuten. Guthaben, Gutschriften und Verbrauch sind im Kundenportal jederzeit einsehbar.</li>
|
||||
<li>Reicht das Guthaben für einen Auftrag voraussichtlich nicht aus, informiert Webklar den Kunden vorher und führt den Mehraufwand nur mit seiner Zustimmung aus. Der Mehraufwand wird zum dann gültigen Stundensatz von Webklar gesondert abgerechnet.</li>
|
||||
<li>Das Guthaben hat keinen Geldwert. Es wird nicht ausgezahlt, ist nicht übertragbar und verfällt mit Ende des Vertrags.</li>
|
||||
</ol>
|
||||
|
||||
<h2>5. Verfügbarkeit, Wartung und Störungen</h2>
|
||||
<ol>
|
||||
<li>Webklar strebt eine Erreichbarkeit der Website von <strong>99 % im Jahresmittel</strong> an, gemessen am Übergabepunkt des Rechenzentrums ins Internet. Eine ununterbrochene Erreichbarkeit ist technisch nicht möglich und wird nicht geschuldet.</li>
|
||||
<li>Nicht als Ausfall zählen:
|
||||
<ul>
|
||||
<li>angekündigte Wartungsarbeiten, möglichst zwischen 22 und 6 Uhr und mit mindestens 24 Stunden Vorankündigung; bei dringenden Sicherheitsupdates auch kurzfristig,</li>
|
||||
<li>Störungen des Internets oder bei Dritten außerhalb des Einflussbereichs von Webklar (etwa Domain-Registry oder vorgelagerte Netzbetreiber),</li>
|
||||
<li>Angriffe wie DDoS, sofern Webklar zumutbare Schutzmaßnahmen getroffen hat,</li>
|
||||
<li>höhere Gewalt sowie Störungen, die der Kunde verursacht hat.</li>
|
||||
</ul></li>
|
||||
<li>Reaktionszeit: Störungen, die das Monitoring erkennt oder die der Kunde meldet, bearbeitet Webklar werktags (Mo–Fr, 9–17 Uhr) in der Regel innerhalb von 4 Stunden, sonst am nächsten Werktag. Eine Rufbereitschaft rund um die Uhr ist nicht geschuldet.</li>
|
||||
<li>Unterschreitet die Verfügbarkeit im Kalenderjahr 99 % aus Gründen, die Webklar zu vertreten hat, schreibt Webklar auf Anforderung je angefangenem Prozentpunkt 10 % einer Monatsgebühr gut, höchstens eine Monatsgebühr pro Jahr. Weitergehende gesetzliche Ansprüche, insbesondere die Minderung und bei Verbrauchern Schadensersatz nach Ziff. 11, bleiben unberührt.</li>
|
||||
</ol>
|
||||
|
||||
<h2>6. Datensicherung</h2>
|
||||
<ol>
|
||||
<li>Webklar sichert Website/Anwendung, zugehörige Datenbanken und Konfiguration mindestens täglich und bewahrt die Sicherungen mindestens 14 Tage auf. Mindestens eine Kopie liegt an einem vom Produktivserver getrennten Speicherort. Der Quellcode wird zusätzlich versioniert.</li>
|
||||
<li>Einmal pro Quartal ist eine Wiederherstellung auf Wunsch des Kunden inklusive. Weitere Wiederherstellungen, die der Kunde verursacht hat, werden über das Stundenguthaben abgerechnet.</li>
|
||||
<li>Der Kunde bewahrt eigene Kopien der Inhalte auf, die er Webklar übergeben oder selbst eingepflegt hat (Texte, Bilder, Dokumente).</li>
|
||||
</ol>
|
||||
|
||||
<h2>7. Mitwirkungspflichten des Kunden</h2>
|
||||
<ul>
|
||||
<li>Zugangsdaten zu Kundenportal und E-Mail-Postfach geheim halten und bei Verdacht auf Missbrauch sofort ändern bzw. Webklar informieren.</li>
|
||||
<li>Nur rechtmäßige Inhalte bereitstellen und dafür die nötigen Rechte besitzen. Webklar darf offensichtlich rechtswidrige Inhalte nach Hinweis vorübergehend sperren.</li>
|
||||
<li>Änderungen der Kontaktdaten und die Bereitstellung der Rechtstexte (Impressum, Datenschutzerklärung) verantwortet der Kunde.</li>
|
||||
</ul>
|
||||
|
||||
<h2>8. Preis und Zahlung</h2>
|
||||
<ol>
|
||||
<li>Die Monatsgebühr beträgt 50,00 €. Als Kleinunternehmer im Sinne von § 19 UStG berechnet Webklar keine Umsatzsteuer.</li>
|
||||
<li>Die Gebühr ist monatlich im Voraus fällig und wird über den Zahlungsdienstleister Stripe Payments Europe Ltd. mit der hinterlegten Zahlungsart eingezogen. Rechnungen erhält der Kunde per E-Mail und im Abo-Verwaltungsbereich.</li>
|
||||
<li>Schlägt eine Zahlung fehl, versucht Stripe den Einzug automatisch erneut und informiert den Kunden. Ist der Kunde mit einer Zahlung mehr als 14 Tage in Verzug, darf Webklar nach vorheriger Ankündigung in Textform und fruchtloser Nachfrist von 7 Tagen die Leistungen vorübergehend sperren, etwa die Website offline nehmen. Der Anspruch auf die Gebühr bleibt davon unberührt. Daten werden während einer Sperre nicht gelöscht.</li>
|
||||
<li>Preisänderungen kündigt Webklar mindestens 6 Wochen vorher in Textform an. Der Kunde kann dann zum Zeitpunkt des Inkrafttretens kündigen. Ohne Zustimmung gilt der bisherige Preis weiter, beide Seiten behalten ihr ordentliches Kündigungsrecht.</li>
|
||||
</ol>
|
||||
|
||||
<h2>9. Laufzeit, Kündigung und Vertragsende</h2>
|
||||
<ol>
|
||||
<li>Der Vertrag läuft auf unbestimmte Zeit, eine Mindestlaufzeit gibt es nicht. Beide Seiten können jederzeit zum Ende des laufenden, bereits bezahlten Abrechnungsmonats kündigen. Webklar muss dabei eine Frist von mindestens einem Monat einhalten.</li>
|
||||
<li>Der Kunde kann in Textform kündigen, über die Schaltfläche <a href="/kuendigen.html">„Verträge hier kündigen"</a> (ohne Anmeldung) oder im Kundenportal unter „Abo verwalten". Das Recht zur außerordentlichen Kündigung aus wichtigem Grund bleibt unberührt.</li>
|
||||
<li>Nach Vertragsende:
|
||||
<ul>
|
||||
<li>Auf Wunsch stellt Webklar dem Kunden innerhalb von 30 Tagen eine vollständige Kopie seiner Website bzw. Anwendungsdaten (Dateien und Datenbank-Export) und seiner E-Mails (IMAP-Export) bereit.</li>
|
||||
<li>Webklar gibt den Umzugscode (AuthCode) der Domain heraus und wirkt beim Umzug zu einem anderen Anbieter mit. Kündigt der Kunde die Domain nicht und zieht er sie nicht um, darf Webklar sie frühestens 30 Tage nach Vertragsende und nach Hinweis an den Kunden freigeben.</li>
|
||||
<li>Webklar löscht alle Kundendaten einschließlich der Sicherungen spätestens 60 Tage nach Vertragsende, soweit keine gesetzlichen Aufbewahrungspflichten bestehen.</li>
|
||||
</ul></li>
|
||||
<li>Nutzungsrechte und Quellcode richten sich nach dem allgemeinen Projektvertrag (Ziff. 6).</li>
|
||||
</ol>
|
||||
|
||||
<h2>10. Gewährleistung</h2>
|
||||
<p>Es gelten die gesetzlichen Regelungen. Webklar behebt Mängel der vertraglichen Leistungen, die in seinem Verantwortungsbereich liegen, innerhalb angemessener Frist.</p>
|
||||
|
||||
<h2>11. Haftung</h2>
|
||||
<ol>
|
||||
<li>Webklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei der Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und im Umfang einer übernommenen Garantie.</li>
|
||||
<li>Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet Webklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich ist eine Pflicht, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</li>
|
||||
<li>Nur gegenüber Unternehmern gilt: Die Haftung nach Absatz 2 ist je Vertragsjahr auf die Summe der in diesem Jahr gezahlten Gebühren begrenzt, mindestens jedoch auf 600 €. Für entgangenen Gewinn haftet Webklar bei leichter Fahrlässigkeit nicht.</li>
|
||||
<li>Für den Verlust von Daten haftet Webklar nur bis zu dem Aufwand, der bei ordnungsgemäßer Datensicherung durch den Kunden (Ziff. 6.3) zur Wiederherstellung nötig gewesen wäre.</li>
|
||||
<li>Die verschuldensunabhängige Haftung für Mängel, die bei Vertragsschluss schon vorhanden waren (§ 536a Abs. 1 Alt. 1 BGB), ist ausgeschlossen.</li>
|
||||
</ol>
|
||||
|
||||
<h2>12. Datenschutz und Auftragsverarbeitung</h2>
|
||||
<ol>
|
||||
<li>Soweit Webklar für den Kunden personenbezogene Daten verarbeitet, etwa Kontaktformular-Eingaben, E-Mails oder Server-Logs, tut es dies als Auftragsverarbeiter nach Art. 28 DSGVO. Die Einzelheiten regelt ein Auftragsverarbeitungsvertrag, den Webklar dem Kunden auf Anfrage und bei Unternehmern unaufgefordert zur Verfügung stellt.</li>
|
||||
<li>Die Zahlungsabwicklung erfolgt durch Stripe als eigenständigen Verantwortlichen. Es gilt die Datenschutzerklärung von Stripe.</li>
|
||||
</ol>
|
||||
|
||||
<h2>13. Änderungen dieser Bedingungen</h2>
|
||||
<p>Änderungen der Leistungsbeschreibung oder dieser Bedingungen teilt Webklar mindestens 6 Wochen vor Inkrafttreten in Textform mit. Sie werden nur mit Zustimmung des Kunden wirksam. Stimmt der Kunde nicht zu, können beide Seiten ordentlich kündigen (Ziff. 9.1).</p>
|
||||
|
||||
<h2>14. Schlussbestimmungen</h2>
|
||||
<ol>
|
||||
<li>Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Bei Verbrauchern gilt diese Rechtswahl nur, soweit dadurch nicht zwingende Schutzvorschriften des Staates entzogen werden, in dem der Verbraucher seinen gewöhnlichen Aufenthalt hat.</li>
|
||||
<li>Ist der Kunde Kaufmann, ist Gerichtsstand der Sitz von Webklar.</li>
|
||||
<li>Webklar ist nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="widerruf">Widerrufsbelehrung (für Verbraucher)</h2>
|
||||
<h3>Widerrufsrecht</h3>
|
||||
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
|
||||
<p>Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon 0176 23726355, E-Mail kenso.gri@gmail.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. Sie können den Widerruf auch über unsere Schaltfläche <a href="/widerrufen.html">„Vertrag widerrufen"</a> erklären. In diesem Fall bestätigen wir Ihnen den Eingang des Widerrufs unverzüglich per E-Mail.</p>
|
||||
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
|
||||
<h3>Folgen des Widerrufs</h3>
|
||||
<p>Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.</p>
|
||||
<p>Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen sollen, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.</p>
|
||||
|
||||
<h3>Muster-Widerrufsformular</h3>
|
||||
<div class="box">
|
||||
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
|
||||
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: kenso.gri@gmail.com:</p>
|
||||
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über die Erbringung der folgenden Dienstleistung: Webklar Wartungs-Abo</p>
|
||||
<p>– Bestellt am (*)/erhalten am (*)</p>
|
||||
<p>– Name des/der Verbraucher(s)</p>
|
||||
<p>– Anschrift des/der Verbraucher(s)</p>
|
||||
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
|
||||
<p>– Datum</p>
|
||||
<p class="meta">(*) Unzutreffendes streichen.</p>
|
||||
</div>
|
||||
<p class="meta">Ende der Widerrufsbelehrung</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+182
@@ -573,6 +573,184 @@ async function pollChatBadge() {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Dashboard --- */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ABO_FEATURES = [
|
||||
['Betrieb & Pflege Ihrer Website/Anwendung', 'Server, Updates, Sicherheit, Backups, Monitoring – damit sie online bleibt'],
|
||||
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
|
||||
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain'],
|
||||
['1 Stunde Arbeitszeit pro Monat', 'sammelt sich an, jede Stunde ist 12 Monate gültig'],
|
||||
]
|
||||
|
||||
function fmtHours(min) {
|
||||
const n = Math.max(0, Number(min || 0))
|
||||
const h = Math.floor(n / 60)
|
||||
const m = n % 60
|
||||
return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h`
|
||||
}
|
||||
|
||||
function aboFeaturesHtml() {
|
||||
return `<ul class="abo-features">${ABO_FEATURES.map(
|
||||
([t, d]) => `<li>${t}<small>${d}</small></li>`
|
||||
).join('')}</ul>`
|
||||
}
|
||||
|
||||
function renderAboOffer(card, data, { ended } = {}) {
|
||||
const available = data.available
|
||||
card.innerHTML = `
|
||||
<div class="abo-head">
|
||||
<div>
|
||||
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
|
||||
<h2 class="abo-title">Wartungs-Abo</h2>
|
||||
</div>
|
||||
<div class="abo-price"><strong>50 €</strong><span>pro Monat · monatlich kündbar</span></div>
|
||||
</div>
|
||||
${aboFeaturesHtml()}
|
||||
<div class="abo-actions">
|
||||
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
|
||||
<span class="abo-note">${available
|
||||
? 'Keine Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
|
||||
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span>
|
||||
</div>
|
||||
<div class="abo-order hidden" id="abo-order">
|
||||
<h3>Ihre Bestellung</h3>
|
||||
<table class="abo-summary">
|
||||
<tr><td>Webklar Wartungs-Abo<br><span class="muted">Leistungen laut <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungsbeschreibung</a></span></td><td>50,00 € / Monat</td></tr>
|
||||
<tr><td>Laufzeit</td><td>unbestimmt, monatlich kündbar</td></tr>
|
||||
<tr><td>Zahlung</td><td>monatlich im Voraus (Karte, SEPA u. a. über Stripe)</td></tr>
|
||||
<tr><td><strong>Gesamt heute</strong><br><span class="muted">Kein Umsatzsteuerausweis (§ 19 UStG)</span></td><td><strong>50,00 €</strong></td></tr>
|
||||
</table>
|
||||
<label class="abo-check"><input type="checkbox" id="abo-terms">
|
||||
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen inkl. Leistungsbeschreibung</a> und die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> gelesen und stimme ihnen zu.</span></label>
|
||||
<label class="abo-check"><input type="checkbox" id="abo-early">
|
||||
<span>Ich verlange ausdrücklich, dass Webklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf Wertersatz für die bis dahin erbrachten Leistungen schulde.</span></label>
|
||||
<p class="error hidden" id="abo-error"></p>
|
||||
<div class="abo-actions">
|
||||
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button>
|
||||
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart.</span>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
const startBtn = card.querySelector('#abo-start')
|
||||
const order = card.querySelector('#abo-order')
|
||||
startBtn?.addEventListener('click', () => {
|
||||
order.classList.remove('hidden')
|
||||
startBtn.classList.add('hidden')
|
||||
order.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
|
||||
card.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
|
||||
const btn = ev.currentTarget
|
||||
const errEl = card.querySelector('#abo-error')
|
||||
hideError(errEl)
|
||||
const acceptTerms = card.querySelector('#abo-terms').checked
|
||||
const earlyStart = card.querySelector('#abo-early').checked
|
||||
if (!acceptTerms || !earlyStart) {
|
||||
showError(errEl, 'Bitte bestätigen Sie beide Punkte, um zu bestellen.')
|
||||
return
|
||||
}
|
||||
btn.disabled = true
|
||||
btn.textContent = 'Weiter zur Zahlung…'
|
||||
try {
|
||||
const { url } = await api('/api/abo/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ acceptTerms, earlyStart, termsVersion: data.termsVersion }),
|
||||
})
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
showError(errEl, err.message)
|
||||
btn.disabled = false
|
||||
btn.textContent = 'Zahlungspflichtig bestellen'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderAboActive(card, data) {
|
||||
const sub = data.subscription
|
||||
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
|
||||
const statusLine = sub.cancelAtPeriodEnd
|
||||
? `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
|
||||
: sub.paymentProblem
|
||||
? '<span class="pill pill-red">Zahlung offen</span>'
|
||||
: `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
|
||||
|
||||
const usageRows = (l.usage || []).map((u) => `
|
||||
<tr><td>${fmtDateShort(u.date)}</td>
|
||||
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
|
||||
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Guthaben hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
|
||||
const creditRows = (l.credits || []).slice().reverse().map((c) => `
|
||||
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatliche Gutschrift</td>
|
||||
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `gültig bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="abo-head">
|
||||
<div>
|
||||
<p class="abo-kicker">Ihr Wartungs-Abo</p>
|
||||
<h2 class="abo-title">Stundenkonto</h2>
|
||||
</div>
|
||||
<div>${statusLine}</div>
|
||||
</div>
|
||||
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
|
||||
<div class="abo-balance">
|
||||
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbares Guthaben</span></div>
|
||||
<div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
|
||||
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
|
||||
</div>
|
||||
<p class="abo-note">Jeden Monat kommt 1 Stunde dazu. Abgerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
|
||||
Aufträge schicken Sie uns einfach über den Chat.</p>
|
||||
<div class="abo-actions" style="margin-top:14px">
|
||||
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
|
||||
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen & Bedingungen</a>
|
||||
</div>
|
||||
${usageRows || creditRows ? `
|
||||
<details class="abo-history">
|
||||
<summary>Verlauf anzeigen</summary>
|
||||
<table class="simple-table">
|
||||
<thead><tr><th>Datum</th><th>Vorgang</th><th>Stunden</th></tr></thead>
|
||||
<tbody>${usageRows}${creditRows}</tbody>
|
||||
</table>
|
||||
</details>` : ''}
|
||||
<p class="error hidden" id="abo-error"></p>`
|
||||
|
||||
card.querySelector('#abo-manage')?.addEventListener('click', async (ev) => {
|
||||
const btn = ev.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
const { url } = await api('/api/abo/portal', { method: 'POST' })
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
showError(card.querySelector('#abo-error'), err.message)
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAboCard({ afterCheckout = false } = {}) {
|
||||
const card = document.getElementById('abo-card')
|
||||
if (!card) return
|
||||
try {
|
||||
let data = await api('/api/abo')
|
||||
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
|
||||
for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) {
|
||||
card.classList.remove('hidden')
|
||||
card.innerHTML = '<p class="abo-kicker">Vielen Dank!</p><h2 class="abo-title">Ihr Abo wird aktiviert…</h2><p class="abo-note">Das dauert nur einen Moment.</p>'
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
data = await api('/api/abo')
|
||||
}
|
||||
if (data.subscription?.active) {
|
||||
renderAboActive(card, data)
|
||||
} else {
|
||||
renderAboOffer(card, data, { ended: Boolean(data.subscription) })
|
||||
}
|
||||
card.classList.remove('hidden')
|
||||
} catch (err) {
|
||||
console.warn('Abo konnte nicht geladen werden:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function initDashboardPage() {
|
||||
const meta = document.getElementById('customer-meta')
|
||||
const list = document.getElementById('projects')
|
||||
@@ -634,6 +812,10 @@ async function initDashboardPage() {
|
||||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||||
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
|
||||
|
||||
const aboParam = getQueryParam('abo')
|
||||
if (aboParam) window.history.replaceState(null, '', '/dashboard.html')
|
||||
loadAboCard({ afterCheckout: aboParam === 'success' })
|
||||
|
||||
const [{ projects }, { features }] = await Promise.all([
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
|
||||
@@ -105,6 +105,48 @@
|
||||
}
|
||||
.chat-send:disabled { opacity: 0.5; cursor: default; }
|
||||
.chat-hint { font-size: 12px; opacity: 0.55; margin-top: 6px; }
|
||||
|
||||
/* Wartungs-Abo (Startseite) */
|
||||
.abo-card {
|
||||
position: relative; margin: 6px 0 26px; padding: 26px 28px; border-radius: 22px;
|
||||
background: linear-gradient(135deg, rgba(157,61,254,0.20), rgba(255,255,255,0.06) 60%);
|
||||
border: 1px solid rgba(157,61,254,0.45);
|
||||
}
|
||||
.abo-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; flex-wrap: wrap; }
|
||||
.abo-kicker { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.7; margin: 0 0 4px; }
|
||||
.abo-title { font-size: 24px; margin: 0; }
|
||||
.abo-price { text-align: right; }
|
||||
.abo-price strong { font-size: 32px; font-variant-numeric: tabular-nums; }
|
||||
.abo-price span { display: block; font-size: 13px; opacity: 0.7; }
|
||||
.abo-features { list-style: none; padding: 0; margin: 18px 0 20px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
|
||||
.abo-features li { padding-left: 26px; position: relative; font-size: 15px; }
|
||||
.abo-features li::before { content: '✓'; position: absolute; left: 0; color: #c9a2ff; font-weight: 700; }
|
||||
.abo-features small { display: block; opacity: 0.65; font-size: 13px; }
|
||||
.abo-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.abo-btn {
|
||||
background: #9d3dfe; border: none; color: #fff; font: inherit; font-weight: 700;
|
||||
padding: 13px 24px; border-radius: 999px; cursor: pointer;
|
||||
}
|
||||
.abo-btn:hover { background: #8a25f0; }
|
||||
.abo-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.abo-note { font-size: 13px; opacity: 0.7; }
|
||||
.abo-order { margin-top: 20px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.14); }
|
||||
.abo-order h3 { margin: 0 0 10px; font-size: 17px; }
|
||||
.abo-summary { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 14px; }
|
||||
.abo-summary td { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: top; }
|
||||
.abo-summary td:last-child { text-align: right; }
|
||||
.abo-check { display: flex; gap: 10px; align-items: flex-start; margin: 10px 0; font-size: 14px; line-height: 1.45; cursor: pointer; }
|
||||
.abo-check input { margin-top: 3px; width: 18px; height: 18px; accent-color: #9d3dfe; flex: none; }
|
||||
.abo-card a { color: #d4b4ff; }
|
||||
.abo-balance { display: flex; gap: 28px; flex-wrap: wrap; margin: 18px 0; }
|
||||
.abo-stat strong { display: block; font-size: 34px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.abo-stat span { font-size: 13px; opacity: 0.7; }
|
||||
.abo-alert { padding: 10px 14px; border-radius: 12px; margin: 12px 0; font-size: 14px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45); }
|
||||
.abo-history { margin-top: 14px; }
|
||||
.abo-history summary { cursor: pointer; font-size: 14px; opacity: 0.85; }
|
||||
.abo-history table { margin-top: 10px; }
|
||||
.portal-legal { margin-top: 40px; font-size: 13px; opacity: 0.7; display: flex; gap: 18px; flex-wrap: wrap; }
|
||||
.portal-legal a { color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="portal-page dashboard-page">
|
||||
@@ -127,6 +169,9 @@
|
||||
<h1 class="portal-title">Kundenbereich</h1>
|
||||
<p id="load-error" class="error hidden"></p>
|
||||
|
||||
<!-- Wartungs-Abo: Angebot bzw. Stundenkonto -->
|
||||
<section id="abo-card" class="abo-card hidden" aria-live="polite"></section>
|
||||
|
||||
<div class="portal-tabs">
|
||||
<button class="portal-tab active" data-tab="projects">Projekte</button>
|
||||
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button>
|
||||
@@ -165,6 +210,13 @@
|
||||
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="portal-legal">
|
||||
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen</a>
|
||||
<a href="/kuendigen.html">Verträge hier kündigen</a>
|
||||
<a href="/widerrufen.html">Vertrag widerrufen</a>
|
||||
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Verträge hier kündigen – Webklar</title>
|
||||
<link rel="stylesheet" href="/legal-form.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a></nav>
|
||||
<h1>Verträge hier kündigen</h1>
|
||||
|
||||
<div id="intro">
|
||||
<p>Hier können Sie Ihr Webklar Wartungs-Abo kündigen, auch ohne Anmeldung. Eine ordentliche Kündigung wirkt zum Ende des bereits bezahlten Monats.</p>
|
||||
<button type="button" class="big-btn primary" id="start-btn">Verträge hier kündigen</button>
|
||||
<p class="muted">Alternativ im Kundenportal unter „Abo verwalten" oder formlos per E-Mail an kenso.gri@gmail.com.</p>
|
||||
</div>
|
||||
|
||||
<form id="decl-form" class="card hidden" novalidate>
|
||||
<label for="name" data-label-for="name" data-label="Name">Name <small>(Pflicht)</small></label>
|
||||
<input id="name" name="name" autocomplete="name" required maxlength="200">
|
||||
|
||||
<label for="email" data-label-for="email" data-label="E-Mail">E-Mail-Adresse, mit der Sie das Abo abgeschlossen haben <small>(Pflicht)</small></label>
|
||||
<input id="email" name="email" type="email" autocomplete="email" required maxlength="255">
|
||||
|
||||
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional, hilft bei der Zuordnung)</small></label>
|
||||
<input id="customerNumber" name="customerNumber" maxlength="50">
|
||||
|
||||
<label>Vertrag</label>
|
||||
<input value="Webklar Wartungs-Abo" disabled>
|
||||
|
||||
<label data-label-for="kind" data-label="Art der Kündigung">Art der Kündigung</label>
|
||||
<label class="radio"><input type="radio" name="kind" value="ordentlich" data-text="Ordentliche Kündigung" checked> Ordentliche Kündigung</label>
|
||||
<label class="radio"><input type="radio" name="kind" value="ausserordentlich" data-text="Außerordentliche Kündigung"> Außerordentliche Kündigung aus wichtigem Grund</label>
|
||||
|
||||
<div id="reason-wrap" class="hidden">
|
||||
<label for="reason" data-label-for="reason" data-label="Kündigungsgrund">Kündigungsgrund</label>
|
||||
<textarea id="reason" name="reason" maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<label for="effective" data-label-for="effective" data-label="Gewünschter Zeitpunkt">Zeitpunkt</label>
|
||||
<select id="effective" name="effective">
|
||||
<option value="nächstmöglicher Zeitpunkt">zum nächstmöglichen Zeitpunkt</option>
|
||||
</select>
|
||||
|
||||
<p class="error hidden" id="form-error"></p>
|
||||
<button type="submit" class="big-btn">Jetzt kündigen</button>
|
||||
<p class="muted">Sie erhalten sofort eine Eingangsbestätigung per E-Mail mit Datum und Uhrzeit sowie dem Zeitpunkt, zu dem der Vertrag endet.</p>
|
||||
</form>
|
||||
|
||||
<div id="done" class="card ok hidden">
|
||||
<h2 style="margin-top:0">Ihre Kündigung ist eingegangen</h2>
|
||||
<p>Eingang: <strong id="done-at"></strong></p>
|
||||
<dl id="done-list"></dl>
|
||||
<p>Eine Bestätigung mit dem Vertragsende haben wir an Ihre E-Mail-Adresse geschickt. Bitte speichern oder drucken Sie diese Seite für Ihre Unterlagen.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/legal-form.js"></script>
|
||||
<script>
|
||||
initLegalForm({ endpoint: '/api/abo/public/cancel', fields: ['name', 'email', 'customerNumber', 'kind', 'reason', 'effective'] })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
:root { color-scheme: light; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 620px; margin: 0 auto; padding: 48px 20px 80px; }
|
||||
h1 { font-size: 28px; line-height: 1.2; margin: 0 0 10px; }
|
||||
nav { font-size: 14px; margin-bottom: 26px; }
|
||||
a { color: #6d1fd1; }
|
||||
.card { background: #fff; border: 1px solid #e3e0da; border-radius: 16px; padding: 22px 24px; margin: 20px 0; }
|
||||
label { display: block; font-weight: 600; font-size: 14px; margin: 14px 0 4px; }
|
||||
label small { font-weight: 400; color: #6b6b70; }
|
||||
input, select, textarea { width: 100%; box-sizing: border-box; font: inherit; padding: 10px 12px; border: 1px solid #cfcac2; border-radius: 10px; background: #fff; }
|
||||
textarea { min-height: 80px; }
|
||||
.radio { display: flex; gap: 8px; align-items: center; font-weight: 400; margin: 6px 0; }
|
||||
.radio input { width: auto; }
|
||||
.big-btn { display: inline-block; margin-top: 20px; background: #b3261e; color: #fff; border: none; font: inherit; font-weight: 700; font-size: 17px; padding: 14px 26px; border-radius: 12px; cursor: pointer; }
|
||||
.big-btn.primary { background: #6d1fd1; }
|
||||
.big-btn:disabled { opacity: 0.55; cursor: default; }
|
||||
.muted { color: #6b6b70; font-size: 14px; }
|
||||
.error { color: #b3261e; font-weight: 600; }
|
||||
.hidden { display: none !important; }
|
||||
.ok { border-color: #3a8a4a; background: #f1faf2; }
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 12px 0; }
|
||||
dt { color: #6b6b70; }
|
||||
dd { margin: 0; }
|
||||
@@ -0,0 +1,57 @@
|
||||
// Gemeinsame Logik fuer Kuendigungs- (§ 312k BGB) und Widerrufsbutton (§ 356a BGB).
|
||||
// Oeffentlich, ohne Login. Zweistufig: Einstiegsbutton -> Formular -> Bestaetigungsbutton.
|
||||
function initLegalForm({ endpoint, fields }) {
|
||||
const intro = document.getElementById('intro')
|
||||
const form = document.getElementById('decl-form')
|
||||
const done = document.getElementById('done')
|
||||
const errEl = document.getElementById('form-error')
|
||||
|
||||
document.getElementById('start-btn').addEventListener('click', () => {
|
||||
intro.classList.add('hidden')
|
||||
form.classList.remove('hidden')
|
||||
form.querySelector('input').focus()
|
||||
})
|
||||
|
||||
const reasonWrap = document.getElementById('reason-wrap')
|
||||
form.querySelectorAll('[name="kind"]').forEach((r) =>
|
||||
r.addEventListener('change', () => {
|
||||
reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich')
|
||||
})
|
||||
)
|
||||
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
errEl.classList.add('hidden')
|
||||
const payload = {}
|
||||
for (const f of fields) payload[f] = form.elements[f]?.value?.trim?.() ?? form.elements[f]?.value ?? ''
|
||||
const btn = form.querySelector('button[type="submit"]')
|
||||
btn.disabled = true
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`)
|
||||
const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'short' })
|
||||
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||
document.getElementById('done-at').textContent = at
|
||||
document.getElementById('done-list').innerHTML = fields
|
||||
.filter((f) => payload[f])
|
||||
.map((f) => `<dt>${esc(form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f)}</dt><dd>${esc(
|
||||
form.elements[f]?.type === 'radio' || form.elements[f] instanceof RadioNodeList
|
||||
? form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
|
||||
: payload[f]
|
||||
)}</dd>`)
|
||||
.join('')
|
||||
form.classList.add('hidden')
|
||||
done.classList.remove('hidden')
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message
|
||||
errEl.classList.remove('hidden')
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -54,6 +54,11 @@
|
||||
<p class="login-hint">
|
||||
Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem.
|
||||
</p>
|
||||
<p class="login-hint">
|
||||
<a href="/kuendigen.html" style="color:inherit">Verträge hier kündigen</a> ·
|
||||
<a href="/widerrufen.html" style="color:inherit">Vertrag widerrufen</a> ·
|
||||
<a href="/abo-bedingungen.html" style="color:inherit">Abo-Bedingungen</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Vertrag widerrufen – Webklar</title>
|
||||
<link rel="stylesheet" href="/legal-form.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html#widerruf">Widerrufsbelehrung</a></nav>
|
||||
<h1>Vertrag widerrufen</h1>
|
||||
|
||||
<div id="intro">
|
||||
<p>Als Verbraucher können Sie Ihr Webklar Wartungs-Abo innerhalb von 14 Tagen nach Vertragsschluss ohne Angabe von Gründen widerrufen. Einzelheiten stehen in der <a href="/abo-bedingungen.html#widerruf">Widerrufsbelehrung</a>.</p>
|
||||
<button type="button" class="big-btn primary" id="start-btn">Vertrag widerrufen</button>
|
||||
</div>
|
||||
|
||||
<form id="decl-form" class="card hidden" novalidate>
|
||||
<label for="name" data-label-for="name" data-label="Name">Name <small>(Pflicht)</small></label>
|
||||
<input id="name" name="name" autocomplete="name" required maxlength="200">
|
||||
|
||||
<label for="email" data-label-for="email" data-label="E-Mail">E-Mail-Adresse, mit der Sie das Abo abgeschlossen haben <small>(Pflicht)</small></label>
|
||||
<input id="email" name="email" type="email" autocomplete="email" required maxlength="255">
|
||||
|
||||
<label for="customerNumber" data-label-for="customerNumber" data-label="Kundennummer">Kundennummer <small>(optional)</small></label>
|
||||
<input id="customerNumber" name="customerNumber" maxlength="50">
|
||||
|
||||
<label>Vertrag</label>
|
||||
<input value="Webklar Wartungs-Abo" disabled>
|
||||
|
||||
<p class="error hidden" id="form-error"></p>
|
||||
<button type="submit" class="big-btn">Widerruf bestätigen</button>
|
||||
<p class="muted">Sie erhalten sofort eine Eingangsbestätigung per E-Mail.</p>
|
||||
</form>
|
||||
|
||||
<div id="done" class="card ok hidden">
|
||||
<h2 style="margin-top:0">Ihr Widerruf ist eingegangen</h2>
|
||||
<p>Eingang: <strong id="done-at"></strong></p>
|
||||
<dl id="done-list"></dl>
|
||||
<p>Eine Eingangsbestätigung haben wir an Ihre E-Mail-Adresse geschickt. Es erfolgen keine weiteren Abbuchungen. Die Erstattung rechnen wir gesondert ab.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/legal-form.js"></script>
|
||||
<script>
|
||||
initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['name', 'email', 'customerNumber'] })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Wartungs-Abo: legt die Collections subscriptions + hourCredits in woms-database an
|
||||
* und ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
|
||||
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
|
||||
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
|
||||
*/
|
||||
const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '')
|
||||
const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05'
|
||||
const apiKey = process.env.APPWRITE_API_KEY
|
||||
const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database'
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('APPWRITE_API_KEY erforderlich')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function aw(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${endpoint}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': projectId,
|
||||
'X-Appwrite-Key': apiKey,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.message || `Appwrite ${response.status}`)
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function ensure(label, fn) {
|
||||
try {
|
||||
await fn()
|
||||
console.log(`+ ${label}`)
|
||||
} catch (e) {
|
||||
if (e.status === 409 || /already exists/i.test(e.message)) {
|
||||
console.log(`= ${label} (existiert)`)
|
||||
} else {
|
||||
throw new Error(`${label}: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAttributes(collectionId, keys, maxSec = 60) {
|
||||
for (let i = 0; i < maxSec; i += 1) {
|
||||
const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes`)
|
||||
const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available')
|
||||
if (!pending.length) return
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Attribute in ${collectionId} wurden nicht verfuegbar`)
|
||||
}
|
||||
|
||||
const col = (id) => `/databases/${databaseId}/collections/${id}`
|
||||
|
||||
async function ensureCollection(id, name, attributes, indexes) {
|
||||
// Keine Dokument-Permissions: Zugriff ausschliesslich ueber den Server-API-Key
|
||||
await ensure(`Collection ${id}`, () =>
|
||||
aw(`/databases/${databaseId}/collections`, {
|
||||
method: 'POST',
|
||||
body: { collectionId: id, name, permissions: [], documentSecurity: false },
|
||||
})
|
||||
)
|
||||
for (const a of attributes) {
|
||||
const { type, key, ...rest } = a
|
||||
await ensure(`${id}.${key}`, () =>
|
||||
aw(`${col(id)}/attributes/${type}`, { method: 'POST', body: { key, required: false, ...rest } })
|
||||
)
|
||||
}
|
||||
await waitForAttributes(id, attributes.map((a) => a.key))
|
||||
for (const idx of indexes) {
|
||||
await ensure(`${id} index ${idx.key}`, () =>
|
||||
aw(`${col(id)}/indexes`, { method: 'POST', body: idx })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureCollection(
|
||||
'subscriptions',
|
||||
'Abos (Stripe)',
|
||||
[
|
||||
{ type: 'string', key: 'customerId', size: 64 },
|
||||
{ type: 'string', key: 'email', size: 255 },
|
||||
{ type: 'string', key: 'stripeCustomerId', size: 64 },
|
||||
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
|
||||
{ type: 'string', key: 'status', size: 32 },
|
||||
{ type: 'string', key: 'planKey', size: 64 },
|
||||
{ type: 'integer', key: 'priceAmount' },
|
||||
{ type: 'datetime', key: 'startedAt' },
|
||||
{ type: 'datetime', key: 'currentPeriodEnd' },
|
||||
{ type: 'boolean', key: 'cancelAtPeriodEnd' },
|
||||
{ type: 'datetime', key: 'canceledAt' },
|
||||
{ type: 'datetime', key: 'endedAt' },
|
||||
{ type: 'string', key: 'termsVersion', size: 32 },
|
||||
{ type: 'datetime', key: 'termsAcceptedAt' },
|
||||
{ type: 'datetime', key: 'earlyStartConsentAt' },
|
||||
{ type: 'datetime', key: 'cancelRequestedAt' },
|
||||
{ type: 'datetime', key: 'withdrawalRequestedAt' },
|
||||
{ type: 'datetime', key: 'confirmationSentAt' },
|
||||
],
|
||||
[
|
||||
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||
{ key: 'idx_stripe_customer', type: 'key', attributes: ['stripeCustomerId'] },
|
||||
]
|
||||
)
|
||||
|
||||
await ensureCollection(
|
||||
'hourCredits',
|
||||
'Abo-Stundengutschriften',
|
||||
[
|
||||
{ type: 'string', key: 'customerId', size: 64 },
|
||||
{ type: 'string', key: 'stripeSubscriptionId', size: 64 },
|
||||
{ type: 'string', key: 'stripeInvoiceId', size: 64 },
|
||||
{ type: 'integer', key: 'minutes' },
|
||||
{ type: 'datetime', key: 'grantedAt' },
|
||||
{ type: 'datetime', key: 'expiresAt' },
|
||||
{ type: 'string', key: 'source', size: 32 },
|
||||
{ type: 'string', key: 'note', size: 500 },
|
||||
],
|
||||
[{ key: 'idx_customer', type: 'key', attributes: ['customerId'] }]
|
||||
)
|
||||
|
||||
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
|
||||
await ensure('worksheets.countsToPlan', () =>
|
||||
aw(`${col('worksheets')}/attributes/boolean`, {
|
||||
method: 'POST',
|
||||
body: { key: 'countsToPlan', required: false, default: false },
|
||||
})
|
||||
)
|
||||
await waitForAttributes('worksheets', ['countsToPlan'])
|
||||
|
||||
console.log('Fertig.')
|
||||
@@ -47,6 +47,30 @@ export const config = {
|
||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
|
||||
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
|
||||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
|
||||
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
|
||||
priceAmountCents: 5000,
|
||||
productName: 'Webklar Wartungs-Abo',
|
||||
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
|
||||
invoiceFooter:
|
||||
process.env.STRIPE_INVOICE_FOOTER ||
|
||||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
|
||||
},
|
||||
|
||||
abo: {
|
||||
termsVersion: '2026-09-25',
|
||||
minutesPerMonth: 60,
|
||||
creditValidityMonths: 12,
|
||||
usageRoundingMinutes: 15,
|
||||
},
|
||||
|
||||
smtp: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import featuresRoutes from './routes/features.js'
|
||||
import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import chatRoutes from './routes/chat.js'
|
||||
import aboRoutes from './routes/abo.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
@@ -48,6 +49,7 @@ app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/abo', aboRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
constructWebhookEvent,
|
||||
createAboCheckout,
|
||||
createBillingPortalUrl,
|
||||
getCurrentSubscription,
|
||||
grantCreditForInvoice,
|
||||
isStripeConfigured,
|
||||
syncSubscription,
|
||||
} from '../services/stripeBilling.js'
|
||||
|
||||
const router = Router()
|
||||
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||||
|
||||
const fmtDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
|
||||
const fmtDateTime = (d) =>
|
||||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
function publicSubscription(sub) {
|
||||
if (!sub) return null
|
||||
return {
|
||||
status: sub.status,
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||||
startedAt: sub.startedAt,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||||
endedAt: sub.endedAt,
|
||||
priceAmount: sub.priceAmount,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kunde (eingeloggt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||||
return res.json({
|
||||
available: isStripeConfigured(),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
ledger,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). */
|
||||
router.post('/checkout', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||||
}
|
||||
const { acceptTerms, earlyStart, termsVersion } = req.body || {}
|
||||
if (acceptTerms !== true || earlyStart !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
|
||||
}
|
||||
if (termsVersion !== config.abo.termsVersion) {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
const now = new Date().toISOString()
|
||||
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now })
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Bestellung fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Stripe-Kundenportal: Zahlungsart, Rechnungen, Kuendigung. */
|
||||
router.post('/portal', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
||||
}
|
||||
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Portal nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendContractConfirmation(subDoc) {
|
||||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||||
const terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null)
|
||||
await sendCustomerMail(
|
||||
subDoc.email,
|
||||
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung',
|
||||
[
|
||||
'Guten Tag,',
|
||||
'',
|
||||
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
|
||||
'',
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
|
||||
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
|
||||
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
||||
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
|
||||
'',
|
||||
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
|
||||
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
|
||||
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
|
||||
'',
|
||||
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
|
||||
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
'',
|
||||
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
||||
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
'',
|
||||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com',
|
||||
],
|
||||
terms
|
||||
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
|
||||
: []
|
||||
)
|
||||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||||
confirmationSentAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStripeEvent(event) {
|
||||
const obj = event.data.object
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
||||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
||||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
||||
`Stripe-Abo: ${obj.subscription}`,
|
||||
])
|
||||
await sendContractConfirmation(sub).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
case 'invoice.paid': {
|
||||
const result = await grantCreditForInvoice(obj)
|
||||
if (result.granted) {
|
||||
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'invoice.payment_failed': {
|
||||
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
||||
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
||||
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
|
||||
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
|
||||
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
|
||||
])
|
||||
return
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await syncSubscription(obj.id)
|
||||
return
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = await syncSubscription(obj.id)
|
||||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
||||
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
|
||||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
||||
])
|
||||
return
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/webhook', async (req, res) => {
|
||||
let event
|
||||
try {
|
||||
event = constructWebhookEvent(req.rawBody, req.get('stripe-signature'))
|
||||
} catch (err) {
|
||||
console.warn('[abo] Webhook-Signatur ungueltig:', err.message)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
try {
|
||||
await handleStripeEvent(event)
|
||||
return res.json({ received: true })
|
||||
} catch (err) {
|
||||
// 500 -> Stripe wiederholt das Event (Handler sind idempotent)
|
||||
console.error(`[abo] Webhook ${event.type} fehlgeschlagen:`, err.message)
|
||||
return res.status(500).json({ error: 'processing failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oeffentlich: Kuendigungsbutton (§ 312k BGB) + Widerrufsbutton (§ 356a BGB)
|
||||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recentRequests = new Map() // ip -> [timestamps]
|
||||
function rateLimited(ip) {
|
||||
const now = Date.now()
|
||||
const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > 10
|
||||
}
|
||||
|
||||
function validDeclaration(body) {
|
||||
const name = String(body?.name || '').trim().slice(0, 200)
|
||||
const email = String(body?.email || '').trim().slice(0, 255)
|
||||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
|
||||
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
|
||||
reason: String(body?.reason || '').trim().slice(0, 2000),
|
||||
effective: String(body?.effective || '').trim().slice(0, 40),
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveSubscriptionByEmail(email) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||||
(await getCustomerByEmail(email.toLowerCase()).catch(() => null))
|
||||
if (!customer) return null
|
||||
const sub = await getCurrentSubscription(customer.$id)
|
||||
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||||
}
|
||||
|
||||
const effectiveText = sub
|
||||
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
|
||||
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` Name: ${d.name}`,
|
||||
` E-Mail: ${d.email}`,
|
||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
` Gewünschter Zeitpunkt: ${d.effective || 'nächstmöglicher Zeitpunkt'}`,
|
||||
d.reason ? ` Grund: ${d.reason}` : null,
|
||||
'',
|
||||
effectiveText,
|
||||
'Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und',
|
||||
'Ihr Postfach stellen wir Ihnen auf Wunsch gemäß Ziff. 9 der Abo-Bedingungen zur Verfügung.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
|
||||
d.reason ? `Grund: ${d.reason}` : '',
|
||||
sub
|
||||
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
|
||||
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
router.post('/public/withdraw', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
}
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` Name: ${d.name}`,
|
||||
` E-Mail: ${d.email}`,
|
||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||||
'',
|
||||
'Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen',
|
||||
'über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf',
|
||||
'erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
sub
|
||||
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
|
||||
'TODO: Widerrufsfrist prüfen (14 Tage ab Vertragsschluss), Abo in Stripe sofort beenden,',
|
||||
'Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen erstatten (Stripe -> Rückerstattung).',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,131 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Abo-Stundenkonto eines Kunden.
|
||||
*
|
||||
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
|
||||
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden, je Eintrag
|
||||
* auf 15 min aufgerundet.
|
||||
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
|
||||
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
|
||||
* Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4).
|
||||
*/
|
||||
|
||||
function parseSheetDate(ws) {
|
||||
const raw = ws.startDate || ws.endDate || ''
|
||||
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw)
|
||||
if (m) return new Date(`${m[3]}-${m[2]}-${m[1]}T12:00:00Z`)
|
||||
const d = new Date(raw || ws.createdAt || ws.$createdAt)
|
||||
return Number.isNaN(d.getTime()) ? new Date(ws.$createdAt) : d
|
||||
}
|
||||
|
||||
function roundUsage(minutes) {
|
||||
const step = config.abo.usageRoundingMinutes
|
||||
return Math.ceil(Math.max(0, Number(minutes) || 0) / step) * step
|
||||
}
|
||||
|
||||
export async function listCredits(customerId) {
|
||||
return listDocuments(config.collections.hourCredits, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderAsc('grantedAt'),
|
||||
Query.limit(1000),
|
||||
])
|
||||
}
|
||||
|
||||
export async function listPlanUsage(customerId) {
|
||||
const workorders = await listDocuments(config.collections.workorders, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.limit(5000),
|
||||
])
|
||||
if (!workorders.length) return []
|
||||
const byId = new Map(workorders.map((w) => [w.$id, w]))
|
||||
const ids = [...byId.keys()]
|
||||
const sheets = []
|
||||
// Appwrite begrenzt die Werte-Liste pro equal-Query -> in Bloecken abfragen
|
||||
for (let i = 0; i < ids.length; i += 100) {
|
||||
const chunk = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', ids.slice(i, i + 100)),
|
||||
Query.equal('countsToPlan', true),
|
||||
Query.limit(5000),
|
||||
])
|
||||
sheets.push(...chunk)
|
||||
}
|
||||
return sheets
|
||||
.filter((ws) => Number(ws.totalTime) > 0)
|
||||
.map((ws) => {
|
||||
const wo = byId.get(ws.workorderId) || {}
|
||||
return {
|
||||
id: ws.$id,
|
||||
wsid: ws.wsid || '',
|
||||
woid: ws.woid || wo.woid || '',
|
||||
topic: wo.topic || wo.title || '',
|
||||
date: parseSheetDate(ws).toISOString(),
|
||||
rawMinutes: Number(ws.totalTime) || 0,
|
||||
minutes: roundUsage(ws.totalTime),
|
||||
details: String(ws.details || '').slice(0, 300),
|
||||
employee: ws.employeeName || ws.employeeShort || '',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
|
||||
/** Reine Berechnung (testbar ohne DB). */
|
||||
export function computeLedger(credits, usage, { now = new Date(), active = true } = {}) {
|
||||
const pool = credits
|
||||
.map((c) => ({
|
||||
id: c.$id,
|
||||
minutes: Number(c.minutes) || 0,
|
||||
remaining: Number(c.minutes) || 0,
|
||||
grantedAt: c.grantedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
}))
|
||||
.sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt)))
|
||||
|
||||
let overageMinutes = 0
|
||||
const entries = usage.map((u) => {
|
||||
let need = u.minutes
|
||||
const at = new Date(u.date)
|
||||
for (const c of pool) {
|
||||
if (!need) break
|
||||
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
|
||||
if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue
|
||||
const take = Math.min(need, c.remaining)
|
||||
c.remaining -= take
|
||||
need -= take
|
||||
}
|
||||
overageMinutes += need
|
||||
return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need }
|
||||
})
|
||||
|
||||
const valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now)
|
||||
const expiredMinutes = pool
|
||||
.filter((c) => c.remaining > 0 && new Date(c.expiresAt) <= now)
|
||||
.reduce((s, c) => s + c.remaining, 0)
|
||||
const balanceMinutes = active ? valid.reduce((s, c) => s + c.remaining, 0) : 0
|
||||
const nextExpiry = active && valid.length
|
||||
? valid.reduce((a, c) => (new Date(c.expiresAt) < new Date(a.expiresAt) ? c : a))
|
||||
: null
|
||||
|
||||
return {
|
||||
balanceMinutes,
|
||||
grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0),
|
||||
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
|
||||
overageMinutes,
|
||||
expiredMinutes,
|
||||
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
|
||||
credits: pool.map((c) => ({
|
||||
grantedAt: c.grantedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
minutes: c.minutes,
|
||||
remaining: active && new Date(c.expiresAt) > now ? c.remaining : 0,
|
||||
expired: new Date(c.expiresAt) <= now,
|
||||
})),
|
||||
usage: entries.reverse(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLedger(customerId, { active = true } = {}) {
|
||||
const [credits, usage] = await Promise.all([listCredits(customerId), listPlanUsage(customerId)])
|
||||
return computeLedger(credits, usage, { active })
|
||||
}
|
||||
@@ -54,3 +54,42 @@ export async function sendChatNotification(customer, text) {
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mail an die Webklar-Admins (Abo-Ereignisse). Fehler werden nur geloggt. */
|
||||
export async function sendAdminNotice(subject, lines) {
|
||||
try {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
|
||||
await mailer.sendMail({
|
||||
from: `"Webklar Kundenportal" <${config.smtp.from}>`,
|
||||
to: config.chatNotify.emails.join(', '),
|
||||
subject: `[Abo] ${subject}`,
|
||||
text: lines.join('\n'),
|
||||
})
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('[mailer] Admin-Hinweis fehlgeschlagen:', err.message)
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
||||
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
||||
*/
|
||||
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||
await mailer.sendMail({
|
||||
from: `"Webklar" <${config.smtp.from}>`,
|
||||
to,
|
||||
bcc: config.chatNotify.emails.join(', ') || undefined,
|
||||
subject,
|
||||
text: lines.join('\n'),
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import Stripe from 'stripe'
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
Query,
|
||||
updateDocument,
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Wartungs-Abo ueber Stripe Billing.
|
||||
*
|
||||
* Wahrheit ueber den Abo-Status ist immer Stripe: jedes Webhook-Event laedt das
|
||||
* Subscription-Objekt frisch nach und spiegelt es in die Appwrite-Collection
|
||||
* `subscriptions` (Dokument-ID = Stripe-Subscription-ID -> Upsert ohne Duplikate).
|
||||
* Stundengutschriften haengen an bezahlten Rechnungen (Dokument-ID = Invoice-ID
|
||||
* -> ein Webhook-Retry kann nie doppelt gutschreiben).
|
||||
*/
|
||||
|
||||
// Status, in denen der Kunde das Abo "hat" (past_due = Zahlung fehlgeschlagen, Stripe versucht erneut)
|
||||
export const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due'])
|
||||
|
||||
let stripeClient = null
|
||||
export function getStripe() {
|
||||
if (!config.stripe.secretKey) {
|
||||
const err = new Error('Zahlungen sind noch nicht eingerichtet (STRIPE_SECRET_KEY fehlt).')
|
||||
err.status = 503
|
||||
throw err
|
||||
}
|
||||
if (!stripeClient) {
|
||||
stripeClient = new Stripe(config.stripe.secretKey, { maxNetworkRetries: 2 })
|
||||
}
|
||||
return stripeClient
|
||||
}
|
||||
|
||||
export function isStripeConfigured() {
|
||||
return Boolean(config.stripe.secretKey)
|
||||
}
|
||||
|
||||
let cachedPriceId = null
|
||||
/** Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. */
|
||||
export async function ensureAboPrice() {
|
||||
if (cachedPriceId) return cachedPriceId
|
||||
const stripe = getStripe()
|
||||
const { data } = await stripe.prices.list({
|
||||
lookup_keys: [config.stripe.priceLookupKey],
|
||||
active: true,
|
||||
limit: 1,
|
||||
})
|
||||
if (data[0]) {
|
||||
cachedPriceId = data[0].id
|
||||
return cachedPriceId
|
||||
}
|
||||
const product = await stripe.products.create({
|
||||
name: config.stripe.productName,
|
||||
description:
|
||||
'Betrieb & Pflege Ihrer Website/Web-Anwendung, 1 Domain, 1 E-Mail-Postfach, 1 Stunde Arbeitszeit pro Monat (12 Monate gültig).',
|
||||
metadata: { webklar: 'wartungs-abo' },
|
||||
})
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
currency: 'eur',
|
||||
unit_amount: config.stripe.priceAmountCents,
|
||||
recurring: { interval: 'month' },
|
||||
tax_behavior: 'inclusive',
|
||||
lookup_key: config.stripe.priceLookupKey,
|
||||
nickname: 'Wartungs-Abo monatlich 50 EUR',
|
||||
})
|
||||
cachedPriceId = price.id
|
||||
return cachedPriceId
|
||||
}
|
||||
|
||||
let cachedPortalConfigId = null
|
||||
/** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */
|
||||
async function ensurePortalConfiguration() {
|
||||
if (cachedPortalConfigId) return cachedPortalConfigId
|
||||
const stripe = getStripe()
|
||||
const { data } = await stripe.billingPortal.configurations.list({ active: true, limit: 20 })
|
||||
const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo')
|
||||
if (own) {
|
||||
cachedPortalConfigId = own.id
|
||||
return own.id
|
||||
}
|
||||
const created = await stripe.billingPortal.configurations.create({
|
||||
business_profile: {
|
||||
headline: 'Webklar – Wartungs-Abo verwalten',
|
||||
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
},
|
||||
default_return_url: `${config.publicBaseUrl}/dashboard.html`,
|
||||
features: {
|
||||
invoice_history: { enabled: true },
|
||||
payment_method_update: { enabled: true },
|
||||
customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] },
|
||||
subscription_cancel: {
|
||||
enabled: true,
|
||||
mode: 'at_period_end',
|
||||
cancellation_reason: {
|
||||
enabled: true,
|
||||
options: ['too_expensive', 'unused', 'switched_service', 'other'],
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: { webklar: 'wartungs-abo' },
|
||||
})
|
||||
cachedPortalConfigId = created.id
|
||||
return created.id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Appwrite-Spiegel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listSubscriptionsForCustomer(customerId) {
|
||||
if (!customerId) return []
|
||||
return listDocuments(config.collections.subscriptions, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(20),
|
||||
])
|
||||
}
|
||||
|
||||
/** Aktuell laufendes Abo (oder das zuletzt beendete, falls keins laeuft). */
|
||||
export async function getCurrentSubscription(customerId) {
|
||||
const subs = await listSubscriptionsForCustomer(customerId)
|
||||
return subs.find((s) => ACTIVE_STATUSES.has(s.status)) || subs[0] || null
|
||||
}
|
||||
|
||||
function toIso(unixSeconds) {
|
||||
return unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null
|
||||
}
|
||||
|
||||
function periodEndOf(sub) {
|
||||
// Seit API 2025-03 liegt current_period_end am Subscription-Item
|
||||
return sub.current_period_end || sub.items?.data?.[0]?.current_period_end || null
|
||||
}
|
||||
|
||||
async function upsertSubscriptionDoc(id, data) {
|
||||
const existing = await getDocument(config.collections.subscriptions, id).catch(() => null)
|
||||
if (existing) return updateDocument(config.collections.subscriptions, id, data)
|
||||
try {
|
||||
return await createDocument(config.collections.subscriptions, data, id)
|
||||
} catch (err) {
|
||||
// Paralleles Event hat das Dokument gerade angelegt
|
||||
if (err.status === 409) return updateDocument(config.collections.subscriptions, id, data)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Stripe-Subscription frisch laden und nach Appwrite spiegeln. */
|
||||
export async function syncSubscription(subscriptionId) {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const meta = sub.metadata || {}
|
||||
const customerId = meta.webklarCustomerId
|
||||
if (!customerId) {
|
||||
console.warn(`[stripe] Subscription ${sub.id} ohne webklarCustomerId - ignoriert`)
|
||||
return null
|
||||
}
|
||||
const customer = typeof sub.customer === 'string' ? null : sub.customer
|
||||
const doc = await upsertSubscriptionDoc(sub.id, {
|
||||
customerId,
|
||||
email: meta.email || customer?.email || '',
|
||||
stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer?.id,
|
||||
stripeSubscriptionId: sub.id,
|
||||
status: sub.status,
|
||||
planKey: config.stripe.priceLookupKey,
|
||||
priceAmount: sub.items?.data?.[0]?.price?.unit_amount ?? config.stripe.priceAmountCents,
|
||||
startedAt: toIso(sub.start_date),
|
||||
currentPeriodEnd: toIso(periodEndOf(sub)),
|
||||
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
|
||||
canceledAt: toIso(sub.canceled_at),
|
||||
endedAt: toIso(sub.ended_at),
|
||||
termsVersion: meta.termsVersion || null,
|
||||
termsAcceptedAt: meta.termsAcceptedAt || null,
|
||||
earlyStartConsentAt: meta.earlyStartConsentAt || null,
|
||||
})
|
||||
return doc
|
||||
}
|
||||
|
||||
function addMonths(date, months) {
|
||||
const d = new Date(date)
|
||||
d.setUTCMonth(d.getUTCMonth() + months)
|
||||
return d
|
||||
}
|
||||
|
||||
function subscriptionIdOfInvoice(invoice) {
|
||||
return (
|
||||
invoice.subscription ||
|
||||
invoice.parent?.subscription_details?.subscription ||
|
||||
invoice.lines?.data?.[0]?.parent?.subscription_item_details?.subscription ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
|
||||
export async function grantCreditForInvoice(invoice) {
|
||||
const ref = subscriptionIdOfInvoice(invoice)
|
||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||
if (!(invoice.amount_paid > 0)) return { skipped: 'Betrag 0' }
|
||||
|
||||
const sub = await syncSubscription(subscriptionId)
|
||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||
|
||||
const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000)
|
||||
try {
|
||||
await createDocument(
|
||||
config.collections.hourCredits,
|
||||
{
|
||||
customerId: sub.customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripeInvoiceId: invoice.id,
|
||||
minutes: config.abo.minutesPerMonth,
|
||||
grantedAt: grantedAt.toISOString(),
|
||||
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
||||
source: 'stripe_invoice',
|
||||
note: invoice.number ? `Rechnung ${invoice.number}` : '',
|
||||
},
|
||||
invoice.id
|
||||
)
|
||||
return { granted: true, customerId: sub.customerId, subscription: sub }
|
||||
} catch (err) {
|
||||
if (err.status === 409) return { skipped: 'bereits gutgeschrieben' }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkout / Kundenportal / Kuendigung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getOrCreateStripeCustomer(customer) {
|
||||
const stripe = getStripe()
|
||||
const subs = await listSubscriptionsForCustomer(customer.$id)
|
||||
const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId
|
||||
if (known) return known
|
||||
|
||||
const found = await stripe.customers
|
||||
.search({ query: `metadata['webklarCustomerId']:'${customer.$id}'`, limit: 1 })
|
||||
.catch(() => ({ data: [] }))
|
||||
if (found.data[0]) return found.data[0].id
|
||||
|
||||
const created = await stripe.customers.create({
|
||||
email: customer.email || undefined,
|
||||
name: customer.companyName || customer.name || undefined,
|
||||
preferred_locales: ['de'],
|
||||
address: customer.street
|
||||
? {
|
||||
line1: customer.street,
|
||||
postal_code: customer.postalCode || undefined,
|
||||
city: customer.city || undefined,
|
||||
country: customer.country && customer.country.length === 2 ? customer.country : 'DE',
|
||||
}
|
||||
: undefined,
|
||||
invoice_settings: { footer: config.stripe.invoiceFooter },
|
||||
metadata: { webklarCustomerId: customer.$id, kundennummer: customer.code || '' },
|
||||
})
|
||||
return created.id
|
||||
}
|
||||
|
||||
export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartConsentAt }) {
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
err.status = 409
|
||||
throw err
|
||||
}
|
||||
const stripe = getStripe()
|
||||
const [priceId, stripeCustomerId] = await Promise.all([
|
||||
ensureAboPrice(),
|
||||
getOrCreateStripeCustomer(customer),
|
||||
])
|
||||
const meta = {
|
||||
webklarCustomerId: customer.$id,
|
||||
email: customer.email || '',
|
||||
termsVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt,
|
||||
earlyStartConsentAt,
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
client_reference_id: customer.$id,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
locale: 'de',
|
||||
billing_address_collection: 'required',
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
subscription_data: {
|
||||
metadata: meta,
|
||||
description: 'Webklar Wartungs-Abo',
|
||||
},
|
||||
metadata: meta,
|
||||
custom_text: {
|
||||
submit: {
|
||||
message:
|
||||
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 50,00 € monatlich, monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
|
||||
})
|
||||
return session.url
|
||||
}
|
||||
|
||||
export async function createBillingPortalUrl(customerId) {
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
if (!sub?.stripeCustomerId) {
|
||||
const err = new Error('Kein Abo vorhanden.')
|
||||
err.status = 404
|
||||
throw err
|
||||
}
|
||||
const configuration = await ensurePortalConfiguration()
|
||||
const session = await getStripe().billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
configuration,
|
||||
locale: 'de',
|
||||
return_url: `${config.publicBaseUrl}/dashboard.html`,
|
||||
})
|
||||
return session.url
|
||||
}
|
||||
|
||||
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
|
||||
export async function cancelAtPeriodEnd(subscriptionId) {
|
||||
await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
||||
return syncSubscription(subscriptionId)
|
||||
}
|
||||
|
||||
export function constructWebhookEvent(rawBody, signature) {
|
||||
if (!config.stripe.webhookSecret) {
|
||||
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')
|
||||
err.status = 503
|
||||
throw err
|
||||
}
|
||||
return getStripe().webhooks.constructEvent(rawBody, signature, config.stripe.webhookSecret)
|
||||
}
|
||||
Reference in new issue
Block a user