80 KiB
Plan 2 – Sichtbarer Teil: Verträge, Hosting-Abo, Zusatzspeicher, Wechsel (mit Vertragsbedingungen Version 4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Ziel: Kunden sehen den Bereich „Verträge“, können das Hosting-Abo (15 €) bestellen, bekommen im Wartungs-Abo beliebig viele Postfächer, buchen Zusatzspeicher per Plus/Minus und wechseln jederzeit vom Hosting- ins Wartungs-Abo. All das geht zusammen mit den Vertragsbedingungen Version 4 live.
Aufbau:
- Grundlage aus Plan 1: Produktkatalog in
server/config.jsundserver/services/products.js. - Drei neue Dienste:
contractEvents.jsfür Nachweise, Widerrufsfristen und Speicher-AufgabenmailStorage.jsfür Plus und MinusplanSwitch.jsfür den Wechsel
- Abrechnung: Stripe bleibt die Wahrheit. Dieselbe Subscription bekommt eine zweite Position (Zusatzspeicher) bzw. einen neuen Preis (Wechsel).
- Oberfläche: Vanilla JS in
public/app.jsundpublic/dashboard.html. - Texte: Rechtstexte als HTML mit PDF, Mails aus
server/mail-templates/. - Andere Repos: Website und Ticketsystem liegen je in einem eigenen Repo und bekommen einen eigenen Worktree.
Technik:
- Node 20, ESM, Express, stripe-node 19.3.0 (API
2025-10-29.clover) - Appwrite-REST,
node --testund die Nachbauten aus Plan 1 (test/support/) - Playwright-Prüfungen im Scratchpad
- Website: React/Vite mit vitest
- Ticketsystem: React/Vite
Spec: docs/superpowers/specs/2026-09-27-vertraege-hosting-abo-design.md, Abschnitte 1–13 und Anhang.
Global Constraints
Preise und Laufzeiten
- Hosting 15,00 €, Wartung 50,00 €, Zusatzspeicher 1,00 € je 5 GB und Monat. Überall, wo ein Preis steht, steht auch „Gemäß § 19 UStG ohne Umsatzsteuer“.
- Zusatzspeicher: 0 bis 10 Pakete. Plus und Minus gibt es nur bei Stripe-Status
activeund ohne Kündigung (cancelAtPeriodEndodercancelAtgesetzt heißt gekündigt). - Wechsel:
- Nur ein Hosting-Abo mit Status
activekann wechseln, und nur ohne Kündigung. - Nur mit Sofortbeginn, das Pflicht-Häkchen ist gesetzt.
- Neue Mindestlaufzeit 12 Monate ab Wechseltag (
termStartAt). Der Abbuchungstag wird der Wechseltag. - Der Rückwechsel läuft nur von Hand und hat keinen Knopf.
- Nur ein Hosting-Abo mit Status
Wörtliche Beschriftungen und Adressen
-
Knopfbeschriftungen: „Zahlungspflichtig bestellen“ (Neuabschluss, Wechsel, Speicher-Plus), „Speichern“ (Speicher-Minus), „Zum Wartungs-Abo wechseln“, „Postfach anfragen“, „Mein Vertrag“, „Abo abschließen“. Unverändert bleiben „Verträge hier kündigen“, „Vertrag widerrufen“ und „Abo verwalten & Rechnungen“.
-
Hashes:
#vertraege#stunden#bestellen-hostingund#bestellen-wartung#wechsel
Die alten
#wartungund#aboführen zu#vertraege.#gutschriftbleibt.
Rechtstexte
- Adresse und PDF: Die Bedingungen bleiben unter
/abo-bedingungen.html. Das neue PDF heißtpublic/WEBklar-Abo-Vertragsunterlagen.pdf. - Archiv Version 3:
public/vertragsunterlagen/v3/abo-bedingungen.htmlundpublic/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf.public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdfbleibt als Fassung 3 bestehen. - Versionsstand:
config.abo.termsVersionhat das FormatJJJJ-MM-TT-v4und gilt für beide Abos. Das Datum ist der Tag der Freigabe durch den Nutzer. Das Dokument zeigt „Version 4 · Stand TT.MM.JJJJ“ mit demselben Datum. - Widerrufsbelehrung: Das gesetzliche Muster bleibt wortgleich mit Version 3.
Namen im Code
- Mailvorlagen: neu sind K12 (Wechsel), K13 (Zusatzspeicher), T12 (Wechsel, intern) und T13 (Speicher-Aufgabe, intern). K11 ist schon vergeben.
contractEvents.typeistabschluss,wechsel,speicher,kuendigungoderwiderruf.statusistoffen,erledigtoderwiderrufen.- Stripe-Metadaten am Abo:
webklarCustomerId,email,productKey,termStartAt,termsVersion,termsAcceptedAt,earlyStartConsentAt,consentIp,consentUserAgent,switchedFrom,switchedAt. - Testrabatte:
WEBKLAR-TEST-050(Wartung, amount_off 4950, besteht schon) undWEBKLAR-TEST-050-HOSTING(Hosting, amount_off 1450). - lookup_keys werden in Stripe nie übertragen (
transfer_lookup_key). Ein neuer Preis bekommt einen neuen lookup_key im Katalog, denn Zuordnung und Stundenregel hängen am lookup_key (Plan 1).
Deploy und Produktion
- Kundenportal: Push nach
mainnur mit Freigabe des Nutzers. - Website: Push nach
mainvonJUSN/Webklarnur mit Freigabe des Nutzers. - Ticketsystem: Der Nutzer pusht den Worktree auf
test. Niemals einen Feature-Branch pushen, denn das Ticketsystem deployt jeden gepushten Branch. - Lesen oder Schreiben in Produktion (Appwrite, Stripe live) nur mit ausdrücklicher Freigabe.
Tests: gegen die Nachbauten, nie gegen Produktion. npm test muss unter Node 20 grün sein.
Review Focus
- Plus, dann Minus, dann wieder Plus im selben Monat. Pakete, die für diesen Monat schon bezahlt sind, kosten kein zweites Mal. Test: Task 8, „erneutes Plus bis zur bezahlten Menge kostet nichts“.
- Die Wechsel-Rechnung braucht eine Bankbestätigung (3-D Secure) oder scheitert.
- Das Portal leitet auf die Stripe-Zahlungsseite der Rechnung weiter, bzw. zeigt wie bei jeder Abbuchung das Zahlungsproblem.
- Der Wechsel bleibt als Ereignis nachweisbar.
- Test: Task 9, „offene Rechnung liefert paymentUrl“.
- Gekündigtes Abo, Status
trialingoderpast_due. Wechsel, Plus und Minus werden mit 409 und klarer Meldung abgelehnt, die Knöpfe sind nicht zu sehen. Tests: Task 8 und Task 9 („gekündigt: 409“), Task 15 (Knöpfe ausgeblendet). - Öffentliche Kündigung oder öffentlicher Widerruf ohne passenden Vertrag. Das gilt für eine unbekannte E-Mail-Adresse, „nur Zusatzspeicher“ ohne gebuchten Speicher und einen Widerruf nach Fristende. Der Kunde bekommt trotzdem eine Eingangsbestätigung („wir prüfen und melden uns“), es passiert keine automatische Änderung, und das Team bekommt einen Hinweis. Tests: Task 10.
- Bestandskunde mit Bedingungen Version 3 (oder Testkonto mit v1/v2).
- Der Link „Vertragsbedingungen“ zeigt die Fassung 3, und K01 hängt das v3-PDF an.
- Plus verlangt die Zustimmung zu den Regeln zum Zusatzspeicher.
- Tests: Task 7 (
termsDocFor), Task 8 (Plus ohne Zustimmung ergibt 400), Task 12 (K01-Anhang).
Vorbereitung
- Plan 1 ist live:
git -C /home/webklar/apps/kundenbereich.webklar.com/build log --oneline -8enthält die Commits aus Plan 1. - Portal-Worktree aktualisieren:
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege fetch origin
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege rebase origin/main
cd /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege && npm test
Erwartet: # fail 0
- Website-Worktree anlegen:
git -C /home/webklar/apps/webklar.com/build worktree add -b feature/hosting-abo ../dev-hosting-abo origin/main
ln -s ../build/node_modules /home/webklar/apps/webklar.com/dev-hosting-abo/node_modules
- Ticketsystem-Worktree anlegen:
git -C /home/webklar/apps/ticket.webklar.com/build fetch origin
git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/abo-stundenkonto ../dev-abo-stunden origin/test
node_modules wird wie in den bestehenden Ticketsystem-Worktrees verlinkt.
Dateien
| Datei | Aufgabe |
|---|---|
public/abo-bedingungen.html (ändern) |
Vertragsbedingungen Version 4 für beide Abos |
public/vertragsunterlagen/v3/* (neu) |
Archiv der Fassung 3 |
public/WEBklar-Abo-Vertragsunterlagen.pdf (neu, erzeugt) |
PDF der Fassung 4 |
scripts/build-legal-pdfs.sh (ändern) |
erzeugt das neue PDF, Fassung 3 bleibt |
public/datenschutz.html, docs/WEBklar-Datenschutzerklaerung.pdf (ändern) |
Datenschutz |
docs/leitfaden-empfehlungsgeber.html und PDF (ändern) |
beide Abos |
server/services/contractEvents.js (neu) |
Nachweise, Widerrufsfristen, Speicher-Aufgaben |
server/services/mailStorage.js (neu) |
Zusatzspeicher: Plan, Vorschau, Änderung, bezahlte Menge |
server/services/planSwitch.js (neu) |
Wechsel: Vorschau und Ausführung |
server/services/products.js (ändern) |
termsDocFor, productView |
server/services/stripeBilling.js (ändern) |
ensurePrice, Testrabatte, Kundenportal, Spiegel (termStartAt, storageUnits), Checkout je Produkt |
server/routes/abo.js (ändern) |
GET /api/abo, Checkout, Speicher, Wechsel, öffentliche Formulare, Webhook |
server/routes/storageTasks.js (neu) |
Admin-API „E-Mail-Speicher“ |
server/routes/portalAdmin.js (ändern) |
bindet storageTasks ein |
server/routes/admin/customers.js (ändern) |
GET /:customerId/abo für das Ticketsystem |
server/services/offers.js, server/routes/offers.js (ändern) |
Angebote je Produkt |
server/services/takeoverCredits.js (ändern) |
Texte „Abo“ statt „Wartungs-Abo“ |
server/mail-templates/* (ändern und neu) |
Mails mit Produktnamen sowie K12, K13, T12, T13 |
scripts/setup-abo-collections.mjs (ändern) |
neue Felder und Collection contractEvents |
scripts/stripe-products.mjs (neu) |
Stripe-Produkte einrichten bzw. prüfen (Livegang) |
public/dashboard.html, public/app.js, public/kundenbereich.css (ändern) |
Navigation und Ansicht „Verträge“, Bestellseiten, Speicher, Wechsel |
public/kuendigen.html, public/widerrufen.html, public/legal-form.js (ändern) |
Auswahl des Vertrags bzw. der Bestellung |
public/admin.html, public/admin.js (ändern) |
Reiter „E-Mail-Speicher“, Abo-Anzeige, Angebot mit Produkt |
test/*.test.js (neu und ändern) |
Tests je Task |
Website src/pages/HostingAbo.tsx (neu), WartungsAbo.tsx, AGB.tsx, src/App.tsx, src/components/Footer.tsx, src/test/abos.test.tsx (neu) |
Website |
Ticketsystem src/lib/customerAdminApi.js, src/hooks/useCustomerAbo.js (neu), src/components/CreateWorksheetModal.jsx, src/components/WorksheetList.jsx |
Schalter „Vom Abo-Stundenkonto abziehen“ nur bei Wartung |
Block A – Texte (Entwürfe für die Rechtsrunde)
Die Tasks 1–4 liefern Entwürfe. Gate: Der Nutzer gibt die Texte in seiner Rechtsrunde frei, bevor Task 20 startet. Die Tasks 5–19 dürfen parallel laufen.
Task 1: Vertragsbedingungen Version 4, Archiv der Fassung 3, PDFs
Files:
- Create:
public/vertragsunterlagen/v3/abo-bedingungen.html(Kopie der heutigen Datei),public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf(Kopie) - Modify:
public/abo-bedingungen.html,scripts/build-legal-pdfs.sh,server/config.js(abo.termsVersion) - Create (erzeugt):
public/WEBklar-Abo-Vertragsunterlagen.pdf - Test:
test/legalTexts.test.js
Interfaces:
-
Consumes: Spec, Abschnitte 3 und 8 (Inhalt)
-
Produces:
- Anker in der Fassung 4:
#widerruf,#hosting(Leistungsbeschreibung A),#wartung(Leistungsbeschreibung B),#zusatzspeicher,#wechsel,#avv config.abo.termsVersion = '<Entwurfsdatum>-v4'. Task 20 setzt das Freigabedatum.
- Anker in der Fassung 4:
-
Step 1: Write the failing test –
test/legalTexts.test.js
const text = (file) => readFileSync(new URL(`../public/${file}`, import.meta.url), 'utf8')
.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ')
const between = (s, a, b) => s.slice(s.indexOf(a), s.indexOf(b, s.indexOf(a)))
const v3 = text('vertragsunterlagen/v3/abo-bedingungen.html'), v4 = text('abo-bedingungen.html')
test('Widerrufsbelehrung wortgleich mit Fassung 3', () => {
const muster = (s) => between(s, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
assert.ok(muster(v3).length > 1500)
assert.equal(muster(v4), muster(v3))
})
test('Fassung 3 unverändert archiviert', () => {
const sha = createHash('sha256').update(readFileSync(new URL('../public/vertragsunterlagen/v3/abo-bedingungen.html', import.meta.url))).digest('hex')
assert.equal(sha, execSync('git show 18c3cdf:public/abo-bedingungen.html | sha256sum').toString().slice(0, 64))
})
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
for (const s of ['15,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG']) assert.ok(v4.includes(s), s)
const wartung = between(v4, 'Leistungsbeschreibung B', 'Widerrufsbelehrung')
assert.ok(!wartung.includes('1 E-Mail-Postfach'))
})
test('Version und Stand passen zu termsVersion', () => {
const [, y, m, d] = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(v4.includes(`Version 4 · Stand ${d}.${m}.${y}`))
})
- Step 2: Run test to verify it fails
Run: node --test test/legalTexts.test.js
Expected: FAIL, das Archiv fehlt.
- Step 3: Fassung 3 archivieren
Die beiden Dateien werden mit cp angelegt. git mv geht nicht, weil /abo-bedingungen.html bleibt.
-
Step 4: Fassung 4 schreiben
- Vorher klären (Spec 13, Punkt 7): Der Nutzer sagt, wie Kontaktformulare heute auf den Kundenseiten laufen. Zur Prüfung dient
grep -rl "<form" /var/www/previews/*/index.htmlbzw. das Bundle der Kundenseiten. Die Antwort bestimmt den Satz zu Formularen in Leistungsbeschreibung A. - Aufbau nach Spec 8.1: allgemeiner Teil, Leistungsbeschreibung A und B, Widerrufsbelehrung (Musterteil 1:1 aus Fassung 3), Anlagen 1–3.
- Inhalt: alle Punkte der Checkliste in Spec 8.2 sowie die Regeln aus Spec 3.1–3.6.
- Hinweise zum Widerruf beim Wechsel und beim Zusatzspeicher stehen außerhalb der Musterbelehrung, in einem eigenen Abschnitt mit dem Anker
#widerruf-hinweise. - Kopfzeile: „Version 4 · Stand “. Dasselbe Datum steht in
config.abo.termsVersion.
- Vorher klären (Spec 13, Punkt 7): Der Nutzer sagt, wie Kontaktformulare heute auf den Kundenseiten laufen. Zur Prüfung dient
-
Step 5:
scripts/build-legal-pdfs.shanpassen- Die Zeile
pdf public/abo-bedingungen.html public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdfwird zupdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf. Die Fassung 3 wird nicht neu erzeugt. - Run:
CHROME=$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | tail -1) scripts/build-legal-pdfs.sh - Expected:
-> public/WEBklar-Abo-Vertragsunterlagen.pdf
- Die Zeile
-
Step 6: Run test to verify it passes
Run: node --test test/legalTexts.test.js && npm test
Expected: PASS
- Step 7: Commit
git add public/abo-bedingungen.html public/vertragsunterlagen public/WEBklar-Abo-Vertragsunterlagen.pdf scripts/build-legal-pdfs.sh server/config.js test/legalTexts.test.js
git commit -m "Vertragsbedingungen Version 4 (Entwurf): Hosting- und Wartungs-Abo, Postfächer, Zusatzspeicher, Wechsel; Fassung 3 archiviert
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 2: Datenschutzerklärung
Files:
- Modify:
public/datenschutz.html,docs/WEBklar-Datenschutzerklaerung.pdf(erzeugt) - Test:
test/legalTexts.test.js(ergänzen)
Interfaces:
-
Consumes: Spec 8.3, Zeile Datenschutzerklärung
-
Produces: –
-
Step 1: Failing test
test('Datenschutz: Abos, UptimeRobot, neuer Stand', () => {
const ds = text('datenschutz.html')
for (const s of ['Abos und Zahlung über Stripe', 'UptimeRobot s. r. o.', 'Obchodná 507/2', '811 06 Bratislava',
'Standardvertragsklauseln', 'im Rahmen unserer Abos']) assert.ok(ds.includes(s), s)
assert.ok(!ds.includes('insbesondere das Wartungs-Abo'))
assert.ok(!ds.includes('Stand 27.09.2026'))
})
- Step 2: Run
node --test test/legalTexts.test.js. Erwartet: FAIL. - Step 3: Abschnitte ändern
- Abschnitte 2, 3, 7, 8, 9 und 12 nach Spec 8.3.
- Abschnitt 7 nennt als Nachweisdaten ausdrücklich auch „Wechsel“ und „Zusatzspeicher“.
- Stand ist das Entwurfsdatum.
- Step 4: PDF erzeugen mit
scripts/build-legal-pdfs.sh, dannnode --test test/legalTexts.test.js && npm test. Erwartet: PASS. - Step 5: Commit mit
git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf test/legalTexts.test.jsund der Nachricht „Datenschutz (Entwurf): unsere Abos, Überwachung mit UptimeRobot“.
Task 3: Übernahmegutschrift, Prüftexte, Leitfaden
Files:
- Modify:
public/app.js:TAKEOVER_CONDITIONS, ab Z. ~1046server/config.js:takeover.conditionsVersionserver/services/takeoverCredits.js: Beschriftungen und Fehlermeldungen mit „Wartungs-Abo“docs/leitfaden-empfehlungsgeber.htmlund das PDF
- Test:
test/legalTexts.test.js(ergänzen)
Interfaces:
-
Consumes: –
-
Produces:
config.takeover.conditionsVersion= Entwurfsdatum (FormatJJJJ-MM-TT) -
Step 1: Failing test
test('Übernahmegutschrift und Leitfaden: produktneutral', () => {
const app = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8')
const cond = between(app, 'const TAKEOVER_CONDITIONS', ']')
assert.ok(!cond.includes('Wartungs-Abo') && cond.includes('Ihr Abo'))
assert.notEqual(config.takeover.conditionsVersion, '2026-09-25')
const tc = readFileSync(new URL('../server/services/takeoverCredits.js', import.meta.url), 'utf8')
assert.ok(!/Wartungs-Abo (läuft|ist beendet)|kein (laufendes )?Wartungs-Abo/.test(tc))
assert.ok(!tc.includes('config.stripe.priceAmountCents'))
const lf = readFileSync(new URL('../docs/leitfaden-empfehlungsgeber.html', import.meta.url), 'utf8')
assert.ok(lf.includes('Hosting-Abo') && lf.includes('beliebig viele Postfächer'))
})
- Step 2: Run. Erwartet: FAIL.
- Step 3: Texte ändern
- Der Anspruch lautet künftig: „Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach)“.
- Die Prüf-Labels in
takeoverCredits.jssagen „Abo“. - Die Plausibilitätsprüfung „mehr als das Abo“ (Z. ~641) vergleicht mit
getProduct(subscription?.productKey || 'wartung').amountCentsstatt mitconfig.stripe.priceAmountCents. - Der Leitfaden nennt beide Abos mit Preisen, und das PDF wird neu erzeugt.
- Step 4: Run
node --test test/legalTexts.test.js && npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Übernahmegutschrift und Leitfaden: für beide Abos (Entwurf)“.
Task 4: Website – Hosting-Seite, Wartungs-Seite, Footer, AGB
Files (Worktree /home/webklar/apps/webklar.com/dev-hosting-abo):
- Create:
src/pages/HostingAbo.tsx,src/test/abos.test.tsx - Modify:
src/pages/WartungsAbo.tsxsrc/App.tsx: Route/hosting-abosrc/components/Footer.tsx: Link{ id: 13, title: "Hosting-Abo", to: "/hosting-abo" }unter „Seiten“src/pages/AGB.tsx: Modul C und Nr. 9
Interfaces:
-
Consumes: Spec 3.1, 8.3 (Zeilen AGB und Website)
-
Produces: Die Route
/hosting-aboexistiert. -
Step 1: Failing test –
src/test/abos.test.tsx(vitest mit Testing Library)
it("Hosting-Abo-Seite: Preis, Postfach, Links", () => {
render(<MemoryRouter><HostingAbo /></MemoryRouter>)
expect(screen.getByText(/15,00 € pro Monat/)).toBeInTheDocument()
expect(screen.getByText(/1 E-Mail-Postfach/)).toBeInTheDocument()
expect(screen.getByRole("link", { name: "Verträge hier kündigen" })).toHaveAttribute("href", "https://project.webklar.com/kuendigen.html")
expect(screen.getByRole("link", { name: /Vertragsunterlagen als PDF/ })).toHaveAttribute("href", "https://project.webklar.com/WEBklar-Abo-Vertragsunterlagen.pdf")
})
it("Wartungs-Abo-Seite: beliebig viele Postfächer, Zusatzspeicher", () => {
render(<MemoryRouter><WartungsAbo /></MemoryRouter>)
expect(screen.getByText(/beliebig viele Postfächer/)).toBeInTheDocument()
expect(screen.queryByText("1 E-Mail-Postfach")).toBeNull()
expect(screen.getByText(/1,00 €/)).toBeInTheDocument()
})
it("AGB Modul C verweist auf das Hosting-Abo", () => {
render(<MemoryRouter><AGB /></MemoryRouter>)
expect(screen.getByText(/Hosting-Abo/)).toBeInTheDocument()
expect(screen.queryByText(/verlängern sich automatisch/)).toBeNull()
})
- Step 2: Run
Run: cd /home/webklar/apps/webklar.com/dev-hosting-abo && npx vitest run src/test/abos.test.tsx
Expected: FAIL (Modul HostingAbo fehlt)
- Step 3: Seiten umsetzen
HostingAbo.tsxist aufgebaut wieWartungsAbo.tsx, mit Inhalten aus Spec 3.1 und 3.2 (statische Websites, Zusatzspeicher, Wechsel jederzeit).WartungsAbo.tsx:- Die Karte „1 E-Mail-Postfach“ heißt jetzt „Beliebig viele Postfächer“ („zusammen 5 GB, Zusatzspeicher je 5 GB für 1,00 €/Monat“).
- Dazu kommt ein Hinweis auf das Hosting-Abo.
- Das PDF heißt
WEBklar-Abo-Vertragsunterlagen.pdf.
AGB.tsx: Modul C sagt künftig „Hosting bieten wir als Hosting-Abo an; es gelten die Vertragsbedingungen der Abos (Link)“. Bestehende Jahresverträge behalten ihre Fassung. Nr. 9 verliert „Hosting-Leistungen sind jährlich im Voraus zu zahlen“.
- Step 4: Tests und Build
Run: npx vitest run && npx vite build
Expected: PASS, Build ohne Fehler
- Step 5: Commit im Website-Worktree mit der Nachricht „Hosting-Abo-Seite, Wartungs-Abo mit beliebig vielen Postfächern, AGB Modul C (Entwurf)“ und der Co-Authored-By-Zeile.
⛔ Gate Rechtsrunde: Den Nutzer die Texte aus Task 1–4 prüfen lassen:
abo-bedingungen.html, das PDF,datenschutz.html, die Übernahmegutschrift, den Leitfaden, die Website-Seiten und die AGB. Änderungswünsche fließen in diese Tasks zurück. Task 20 startet erst nach der Freigabe.
Block B – Server
Task 5: Datenmodell und Dienst contractEvents
Files:
- Modify:
scripts/setup-abo-collections.mjssubscriptionsbekommt{ type: 'datetime', key: 'termStartAt' },{ type: 'integer', key: 'storageUnits' },{ type: 'integer', key: 'storagePaidUnits' }und{ type: 'datetime', key: 'storagePaidPeriodEnd' }.aboOffersbekommt{ type: 'string', key: 'productKey', size: 16 }.- Neue Collection
contractEvents(„Vertragsereignisse“):- Attribute:
customerId(64),subscriptionId(64),type(16),productKey(16),details(2000),termsVersion(32),consentAt(datetime),consentIp(64),consentUserAgent(300),earlyStartConsentAt(datetime),status(16),dueAt(datetime),doneAt(datetime),doneBy(255),confirmationSentAt(datetime) - Indizes:
idx_customerundidx_type_statusüber (type,status)
- Attribute:
- Create:
server/services/contractEvents.js - Modify:
server/config.js(collections.contractEvents) - Test:
test/contractEvents.test.js
Interfaces:
-
Consumes:
- Nachbauten und
schemaKeys()aus Plan 1 (test/stripeBilling.test.js). Die Hilfsfunktion wird dazu nachtest/support/schema.jsverschoben. withdrawalDeadline(contractAt)ausserver/services/deadlines.js
- Nachbauten und
-
Produces:
-
recordContractEvent({ customerId, subscriptionId, type, productKey, details = {}, consent = null, status = 'erledigt', dueAt = null }): liefert das Dokument.consenthat die Form{ termsVersion, consentAt, consentIp, consentUserAgent, earlyStartConsentAt? },detailswird als JSON gespeichert. -
listContractEvents(customerId): liefert die Dokumente, neueste zuerst, mit geparstemdetails. -
openWithdrawals(events, now = new Date()): liefert[{ type, eventId, until }]. Das umfasst:abschlussundwechselspeichermitdetails.toUnits > details.fromUnits
Jeweils mit
status !== 'widerrufen'undnow <= withdrawalDeadline(consentAt).endsAt. -
listOpenStorageTasks(): lieferttype = speicherundstatus = offen, sortiert nachdueAtaufsteigend. -
completeContractEvent(id, staffEmail): setztstatus: 'erledigt',doneAtunddoneBy.
-
-
Step 1: Failing test –
test/contractEvents.test.js(Appwrite-Nachbau mit Schema aus dem Setup-Skript fürcontractEventsundsubscriptions)
test('Ereignis speichern und lesen (details als JSON)', async () => {
await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_1', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 2 }, consent: { termsVersion: '2026-10-05-v4', consentAt: '2026-10-05T10:00:00.000Z', consentIp: '1.2.3.4', consentUserAgent: 'UA' },
status: 'offen', dueAt: '2026-10-05T10:00:00.000Z' })
const [e] = await listContractEvents('c1')
assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 })
assert.equal((await listOpenStorageTasks()).length, 1)
})
test('Widerrufsfristen je Bestellung', () => {
const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status })
const now = new Date('2026-10-10T12:00:00Z')
const open = openWithdrawals([ev('abschluss', '2026-09-01T10:00:00Z'), ev('wechsel', '2026-10-05T10:00:00Z'),
ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }), ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 })], now)
assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher'])
assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0)
})
test('Aufgabe erledigen', async () => {
const [t] = await listOpenStorageTasks()
const done = await completeContractEvent(t.$id, 'kenso@webklar.com')
assert.equal(done.status, 'erledigt'); assert.equal(done.doneBy, 'kenso@webklar.com')
})
- Step 2: Run
node --test test/contractEvents.test.js. Erwartet: FAIL, das Modul fehlt. - Step 3: Umsetzen: Setup-Skript, Dienst und die Konfiguration
collections.contractEvents(UmgebungsvariableAPPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents'). - Step 4: Run
node --test test/contractEvents.test.js && npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Vertragsereignisse: Nachweise, Widerrufsfristen, Speicher-Aufgaben“.
Task 6: Stripe-Preise je Produkt, Testrabatte, Kundenportal, Spiegel
Files:
- Modify:
server/services/stripeBilling.js,server/services/products.js - Create:
scripts/stripe-products.mjs - Test:
test/stripeBilling.test.js(ergänzen),test/products.test.js(ergänzen)
Interfaces:
-
Consumes: Katalog und
describeItems(Plan 1), Nachbauten -
Produces:
ensurePrice(key):keyist'wartung','hosting'oder'mailStorage', zurück kommtPromise<priceId>.- Die Funktion ersetzt
ensureAboPrice, und alle Aufrufer werden umgestellt. - Sie legt Produkt und Preis an mit
tax_behavior: 'inclusive',recurring.interval: 'month'undlookup_key. - Die Produkt-Metadaten
webklarsindwartungs-abo,hosting-abobzw.mail-speicher. - Das Ergebnis wird je Schlüssel zwischengespeichert.
- Die Funktion ersetzt
ensureTestCoupon(productKey): liefert'WEBKLAR-TEST-050'für Wartung bzw.'WEBKLAR-TEST-050-HOSTING'für Hosting.amount_offistamountCents - 50.ensurePortalConfiguration(): Die Überschrift ist'WEBklar – Abo verwalten'. Eine bestehende eigene Konfiguration mit anderer Überschrift wird perbillingPortal.configurations.updateangepasst.termStartOf(stripeSub): liefert einDateausmetadata.termStartAt, sonsttrial_end, sonststart_date. Es ersetztserviceStartOfincancelSubscriptionAt.- Spiegel:
syncSubscriptionschreibt zusätzlichtermStartAt(ISO) undstorageUnits. termsDocFor(termsVersion)inproducts.js:- Endet die Version auf
-v4, liefert die Funktion{ url: '/abo-bedingungen.html', pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf' }. - Bei jeder anderen Version:
{ url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' }.
- Endet die Version auf
productView(key): liefert{ key, name, amountCents, minTermMonths, minutesPerMonth, features }.featuresist eine Liste von[titel, detail], neu im Katalog. Die Texte stammen aus Spec 3.1 und stimmen mit Fassung 4 überein.- Die Oberfläche übernimmt diese Liste und hat keine eigenen
ABO_FEATURESmehr.
scripts/stripe-products.mjs:node scripts/stripe-products.mjs --pruefenlistet, was fehlt oder abweicht.- Ohne Schalter ruft das Skript
ensurePricefür alle drei auf, setzt die Produktbeschreibung von Wartung auf den Katalogtext und passt das Kundenportal an.
-
Step 1: Failing tests
// test/products.test.js
test('Vertragsunterlagen je Version', () => {
assert.equal(termsDocFor('2026-10-05-v4').pdfFile, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.equal(termsDocFor('2026-09-25-v3').url, '/vertragsunterlagen/v3/abo-bedingungen.html')
assert.equal(termsDocFor(null).url, '/vertragsunterlagen/v3/abo-bedingungen.html')
})
test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => {
const w = productView('wartung').features.flat().join(' ')
assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach'))
assert.ok(productView('hosting').features.flat().join(' ').includes('1 E-Mail-Postfach'))
})
// test/stripeBilling.test.js (Stripe-Nachbau braucht dazu: POST /v1/products, POST /v1/prices, GET/POST /v1/coupons,
// GET/POST /v1/billing_portal/configurations und POST /v1/billing_portal/configurations/:id)
test('ensurePrice legt Hosting einmal an', async () => {
const id1 = await ensurePrice('hosting'); const id2 = await ensurePrice('hosting')
assert.equal(id1, id2)
const created = st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices')
assert.equal(created.length, 1)
assert.equal(created[0].body['lookup_key'], 'webklar_hosting_monatlich_1500')
assert.equal(created[0].body['unit_amount'], '1500')
})
test('Testrabatt Hosting: 14,50 € Nachlass', async () => {
assert.equal(await ensureTestCoupon('hosting'), 'WEBKLAR-TEST-050-HOSTING')
assert.equal(st.calls.find((c) => c.path === '/v1/coupons' && c.method === 'POST').body['amount_off'], '1450')
})
test('Spiegel: termStartAt aus Metadaten (Wechsel), sonst Leistungsbeginn; storageUnits', async () => {
// sub_s: metadata.termStartAt '2026-10-05T10:00:00.000Z', Zusatzspeicher mit Menge 3
const doc = await syncSubscription('sub_s')
assert.equal(doc.termStartAt, '2026-10-05T10:00:00.000Z'); assert.equal(doc.storageUnits, 3)
})
- Step 2: Run
node --test test/products.test.js test/stripeBilling.test.js. Erwartet: FAIL. - Step 3: Umsetzen:
ensurePrice,ensureTestCoupon(productKey), Kundenportal-Überschrift,termStartOf, den Spiegel,termsDocFor,productViewmitfeaturesim Katalog und das Skript. - Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Stripe: Preise je Produkt, Testrabatt Hosting, Kundenportal ‚Abo verwalten‘, Mindestlaufzeit ab termStartAt“.
Task 7: Neuabschluss je Produkt und GET /api/abo
Files:
- Modify:
server/services/stripeBilling.js(createAboCheckout),server/routes/abo.js(GET /,POST /checkout,publicSubscription,minTermEndOf, Webhookcheckout.session.completed) - Create:
test/support/testApp.js - Test:
test/aboRoutes.test.js
Interfaces:
-
Consumes:
ensurePrice,ensureTestCoupon,termsDocFor,productView(Task 6)recordContractEvent,listContractEvents,openWithdrawals(Task 5)
-
Produces:
-
createAboCheckout(customer, consent, productKey, offer):- Position, Beschreibung,
custom_text(Preis und „Mindestlaufzeit 12 Monate, danach monatlich kündbar“) und Testrabatt richten sich nach dem Produkt. - Die Metadaten bekommen
productKey.
- Position, Beschreibung,
-
POST /api/abo/checkout: Der Body bekommt zusätzlichproductKey('wartung'oder'hosting'). Ein anderer Wert ergibt 400. -
GET /api/abo:{ available, testAccount, termsVersion, lateStartAt, products: [productView('hosting'), productView('wartung')], mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }, offers: { hosting: offerView|null, wartung: offerView|null }, ledger: <nur bei productKey 'wartung', sonst null>, subscription: null | { ...bisherige Felder, productKey, termsVersion, termsUrl, // termsDocFor(sub.termsVersion).url storage: { units, pendingSetup, // boolean pendingReduction, // null | { units, effectiveAt } }, canChangeStorage, // status 'active' und nicht gekündigt canSwitch, // wie canChangeStorage, dazu productKey 'hosting' withdrawals, // openWithdrawals(...) withdrawalOpen, // withdrawals.length > 0 withdrawalUntil, // spätestes until } } -
minTermEndOf(doc): rechnet abdoc.termStartAt || doc.trialEnd || doc.startedAt. -
Webhook
checkout.session.completed: zusätzlichrecordContractEvent({ type: 'abschluss', productKey, consent aus den Metadaten }). -
startTestApp(routers)(intest/support/testApp.js): liefertPromise<{ url, close }>. Die App nutztexpress.jsonmitrawBodyundcookieParser(SESSION_SECRET). -
sessionCookie(session): liefert einen signierten Cookiewebklar_portal_session(mitcookie-signature) für jedes Session-Objekt. Kunde:{ customerId, appwriteUserId }. Admin:{ role: 'admin', appwriteUserId, name, email }. -
Anfrage-Helfer in
test/support/testApp.js, die die Tasks 8–13 nutzen:get(path, { cookie, headers })liefert den JSON-Body und wirft ab Status 400.rawGet(path, opts)liefert{ status, body }.post(path, body, { cookie, headers })liefert{ status, body }.
-
test/support/fixtures.jsstellt bereit:- Kunde
c1mitk@example.invalid hostingDoc:subscriptions-Dokumentsub_hvonc1mitproductKey 'hosting', Statusactive,termsVersionv4,storageUnits: 0undcurrentPeriodEndin 20 TagenwartungDoc- Kunde
c2mit einem zweiten Hosting-Abosub_h2 consent = { termsVersion: config.abo.termsVersion, consentAt, consentIp: '127.0.0.1', consentUserAgent: 'test' }- Funktionen
customerCookie()undadminCookie()
- Kunde
-
Angebote:
offers.hostingbleibt bis Task 11null.offers.wartungkommt aus dem heutigengetOpenOffer(customerId).
-
-
Step 1: Failing test –
test/aboRoutes.test.js
test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => {
// Appwrite: Kunde c1, Abo-Dokument sub_h (productKey hosting, storageUnits 2, termsVersion '2026-10-05-v4', status active)
const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]])
assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2)
assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null)
assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html')
})
test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => {
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger)
})
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
const res = await post('/api/abo/checkout', { productKey: 'hosting', acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '' })
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/)
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
})
test('Checkout: unbekanntes Produkt → 400', async () => { assert.equal((await post('/api/abo/checkout', { productKey: 'gold' })).status, 400) })
test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => {
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false)
})
test('Widerrufsfristen aus den Ereignissen', async () => {
// Ereignis 'speicher' (0 → 2) mit consentAt vor 2 Tagen
const r = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher'])
assert.equal(r.subscription.withdrawalOpen, true)
})
- Step 2: Run
node --test test/aboRoutes.test.js. Erwartet: FAIL. - Step 3: Umsetzen
- Checkout je Produkt. Ein Angebot gibt es bis Task 11 nur für Wartung. Bei Hosting mit
offerIdgibt es 409 („Ihr Angebot gilt für ein anderes Abo“). GET /api/aboundpublicSubscriptionerweitern.- Im Webhook das Ereignis
abschlussschreiben.
- Checkout je Produkt. Ein Angebot gibt es bis Task 11 nur für Wartung. Bei Hosting mit
- Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Abo-API: Bestellung je Produkt, Katalog, Speicherstand, Wechsel-Möglichkeit“.
Task 8: Zusatzspeicher
Files:
- Create:
server/services/mailStorage.js - Modify:
server/routes/abo.js(POST /storage/preview,POST /storage, Webhookinvoice.paidruftrecordPaidStorage) - Test:
test/mailStorage.test.js
Interfaces:
-
Consumes:
ensurePrice('mailStorage'),describeItems,syncSubscription,getCurrentSubscriptionrecordContractEvent- Mailversand K13 und T13 (Task 12 liefert die Vorlagen; bis dahin schlägt der Versand fehl, der Ablauf aber nicht)
-
Produces:
planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }): liefert{ direction, steps }.directionist'plus','minus'oder'none',stepseine Liste[{ quantity, proration_behavior }].- Ist
newUnitskeine ganze Zahl zwischen 0 undmaxUnits, wirft die Funktion einen Fehler mit Status 400. - Plus bis
paidUnits: ein Schritt mitnone. - Plus über
paidUnits, wenncurrentUnits < paidUnits: erst{ paidUnits, none }, dann{ newUnits, create_prorations }. - Sonst Plus: ein Schritt
create_prorations. - Minus: ein Schritt mit
none.
paidUnitsOf(doc): Stimmtdoc.storagePaidPeriodEndmitdoc.currentPeriodEndüberein, liefert die Funktiondoc.storagePaidUnits, sonstdoc.storageUnits || 0.previewStorageChange(customerId, newUnits): liefert{ direction, units, totalGb, monthlyCents, deltaMonthlyCents, prorationCents, nextChargeAt, effectiveAt }.- Das anteilige Plus kommt aus
invoices.createPreview(mitsubscription_details.items,proration_behavior,proration_date). effectiveAtist beim MinuscurrentPeriodEnd, sonst jetzt.
- Das anteilige Plus kommt aus
changeStorage(customerId, newUnits, consent): liefert{ subscription, event }.- Plus ohne
consentergibt 400 („Bitte stimmen Sie den Regeln zum Zusatzspeicher zu“). - Ist das Abo nicht
activeoder gekündigt, gibt es 409. - Die Stripe-Schritte laufen nacheinander. Die erste Position wird mit
items: [{ price, quantity }]angelegt. - Beim Plus wird
storagePaidUnits = max(paidUnits, newUnits)mitstoragePaidPeriodEnd = currentPeriodEndgesetzt. - Bei 0 Paketen bleibt die Position mit
quantity: 0bestehen. Stripe erlaubt 0 bei Stückpreisen, so behält ein späteres Plus seinen Posten. - Es entsteht das Ereignis
speicher: Statusoffen,dueAt= jetzt (Plus) bzw.currentPeriodEnd(Minus),details: { fromUnits, toUnits }. - Danach gehen K13 an den Kunden (beim Plus mit PDF) und T13 ans Team.
- Plus ohne
recordPaidStorage(freshInvoice): Hat die Rechnung eine reguläre Zusatzspeicher-Zeile (nicht anteilig), setzt die FunktionstoragePaidUnitsauf derenquantityundstoragePaidPeriodEndaufline.period.end.- Alle Rechnungszeilen laden (Hinweis aus dem Review von Plan 1):
invoices.retrievebettet höchstens 10 Zeilen ein. Anteilige Zeilen stehen vor den Abo-Zeilen, und mehrere Speicheränderungen in einem Monat können die Wartungs-Zeile hinausschieben.grantCreditForInvoiceundrecordPaidStorageladen deshalb beilines.has_morealle Zeilen perinvoices.listLineItems(id, { limit: 100 }).- Test: Rechnung mit 11 Zeilen, die Wartungs-Zeile ist die elfte. Ergebnis: 60 Minuten.
- Routen:
POST /api/abo/storage/previewmit dem Body{ units }POST /api/abo/storagemit dem Body{ units, acceptTerms, termsVersion }, dabei istacceptTermsbeim Plus Pflicht- Im Modus „Als Kunde ansehen“ antworten beide mit 403.
-
Step 1: Failing test –
test/mailStorage.test.js
test('Plan: erstes Plus anteilig', () => {
assert.deepEqual(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 2, maxUnits: 10 }),
{ direction: 'plus', steps: [{ quantity: 2, proration_behavior: 'create_prorations' }] })
})
test('Plan: Minus ohne Erstattung', () => {
assert.deepEqual(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).steps,
[{ quantity: 1, proration_behavior: 'none' }])
})
test('Plan: erneutes Plus bis zur bezahlten Menge kostet nichts', () => {
assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 3, maxUnits: 10 }).steps,
[{ quantity: 3, proration_behavior: 'none' }])
assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 5, maxUnits: 10 }).steps,
[{ quantity: 3, proration_behavior: 'none' }, { quantity: 5, proration_behavior: 'create_prorations' }])
})
test('Plan: Grenzen', () => {
for (const n of [-1, 11, 1.5, '2']) assert.throws(() => planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: n, maxUnits: 10 }), (e) => e.status === 400)
assert.equal(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).direction, 'none')
})
test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => {
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }), 3)
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }), 1)
})
test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { /* Nachbauten: sub_h active, ohne Speicher */
await changeStorage('c1', 2, consent)
const upd = st.calls.filter((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST')
assert.equal(upd.at(-1).body['proration_behavior'], 'create_prorations')
assert.equal(upd.at(-1).body['items[0][quantity]'], '2')
const [ev] = await listContractEvents('c1'); assert.equal(ev.type, 'speicher'); assert.equal(ev.status, 'offen')
assert.equal(aw.docs('subscriptions').get('sub_h').storagePaidUnits, 2)
})
test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => {
await assert.rejects(changeStorage('c1', 3, null), (e) => e.status === 400)
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true })
await assert.rejects(changeStorage('c1', 3, consent), (e) => e.status === 409)
})
test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => {
await recordPaidStorage({ lines: { data: [{ amount: 300, quantity: 3, period: { end: 1793000000 },
pricing: { price_details: { price: 'price_s' } }, parent: { type: 'subscription_item_details',
subscription_item_details: { proration: false, subscription: 'sub_h' } } }] } })
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storagePaidUnits, 3); assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
})
- Step 2: Run
node --test test/mailStorage.test.js. Erwartet: FAIL. - Step 3: Umsetzen: Dienst, Routen und Webhook-Anschluss wie oben beschrieben.
- Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH“.
Task 9: Wechsel vom Hosting- ins Wartungs-Abo
Files:
- Create:
server/services/planSwitch.js - Modify:
server/routes/abo.js(POST /switch/preview,POST /switch) - Test:
test/planSwitch.test.js
Interfaces:
-
Consumes:
ensurePrice('wartung'),ensureTestCoupon('wartung'),describeItems,syncSubscriptionrecordContractEvent- Mails K12 und T12 (Task 12)
-
Produces:
previewSwitch(customerId): liefert{ prorationDate, amountDueCents, creditCents, monthlyCents, nextChargeAt, minTermEnd }.- Grundlage ist
invoices.createPreviewmitsubscription_details: { items: [{ id: aboItemId, price: wartungPriceId }], billing_cycle_anchor: 'now', proration_behavior: 'create_prorations', proration_date }. creditCentsist die Summe der negativen anteiligen Zeilen als positiver Betrag.minTermEndliegt 12 Monate nachprorationDate.
- Grundlage ist
switchToWartung(customerId, consent, prorationDate): liefert{ subscription, paymentUrl }.- Voraussetzungen:
productKeyisthosting, der Statusactive, das Abo nicht gekündigt. Sonst gibt es 409. - Ist
prorationDateälter als 30 Minuten, gibt es 409 („Bitte Seite neu laden“). - Der Aufruf lautet
subscriptions.update(subId, { items: [{ id: aboItemId, price }], billing_cycle_anchor: 'now', proration_behavior: 'create_prorations', proration_date, payment_behavior: 'allow_incomplete', metadata: {…}, expand: ['latest_invoice'] }). metadataenthält{ productKey: 'wartung', termStartAt, termsVersion, termsAcceptedAt, earlyStartConsentAt, consentIp, consentUserAgent, switchedFrom: 'hosting', switchedAt }.- Bei einem Testkonto werden die Rabatte neu gesetzt:
WEBKLAR-TEST-050-HOSTINGfällt weg,WEBKLAR-TEST-050kommt dazu, andere Rabatte bleiben. paymentUrlistlatest_invoice.hosted_invoice_url, falls die Rechnung nochopenist, sonstnull.- Danach folgen das Ereignis
wechsel(mitconsentunddetails: { from: 'hosting', amountDueCents, prorationDate }) und die Mails K12 (Kunde, PDF der Fassung 4) und T12.
- Voraussetzungen:
- Routen:
POST /api/abo/switch/previewPOST /api/abo/switchmit dem Body{ acceptTerms: true, earlyStart: true, termsVersion, prorationDate }. Fehlt eines davon, gibt es 400. StimmttermsVersionnicht, gibt es 409.- Im Modus „Als Kunde ansehen“ antworten beide mit 403.
-
Step 1: Failing test –
test/planSwitch.test.js
test('Vorschau: Gutschrift und heute fällig aus der Stripe-Vorschau', async () => {
// Stripe-Nachbau: POST /v1/invoices/create_preview liefert die Zeilen -900 (anteilig) und 5000
const p = await previewSwitch('c1')
assert.equal(p.creditCents, 900); assert.equal(p.amountDueCents, 4100); assert.equal(p.monthlyCents, 5000)
})
test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () => {
const r = await switchToWartung('c1', consent, p.prorationDate)
const call = st.calls.findLast((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST')
assert.equal(call.body['billing_cycle_anchor'], 'now')
assert.equal(call.body['metadata[productKey]'], 'wartung')
assert.equal(call.body['metadata[switchedFrom]'], 'hosting')
assert.equal((await listContractEvents('c1'))[0].type, 'wechsel')
assert.equal(r.paymentUrl, null)
})
test('Offene Rechnung liefert paymentUrl', async () => {
st.nextSubscriptionUpdate = { latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
const r = await switchToWartung('c2', consent, Math.floor(Date.now() / 1000))
assert.equal(r.paymentUrl, 'https://pay.example/inv')
assert.equal((await listContractEvents('c2'))[0].type, 'wechsel')
})
test('Gekündigt, Wartung, trialing, past_due: 409', async () => {
for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) {
aw.seed('subscriptions', { ...hostingDoc, ...patch })
await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000)), (e) => e.status === 409, JSON.stringify(patch))
}
})
test('Alte Vorschau (> 30 min): 409', async () => {
await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000) - 31 * 60), (e) => e.status === 409)
})
test('Route: ohne earlyStart 400', async () => {
const res = await post('/api/abo/switch', { acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: 1 }, { cookie: customerCookie() })
assert.equal(res.status, 400)
})
- Step 2: Run
node --test test/planSwitch.test.js. Erwartet: FAIL. - Step 3: Umsetzen: Dienst und Routen wie oben. Der Stripe-Nachbau bekommt
POST /v1/invoices/create_previewundPOST /v1/subscriptions/:id, dazunextSubscriptionUpdate: Dieses Objekt wird einmalig in die nächste Antwort vonPOST /v1/subscriptions/:idgemischt. - Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate“.
Task 10: Öffentliche Kündigung und öffentlicher Widerruf mit Auswahl
Files:
- Modify:
server/routes/abo.js(validDeclaration,/public/cancel,/public/withdraw),public/kuendigen.html,public/widerrufen.html,public/legal-form.js - Test:
test/publicForms.test.js
Interfaces:
-
Consumes:
changeStorage(Task 8),listContractEventsundopenWithdrawals(Task 5), Mails K03, K04, T08, T09 (Task 12) -
Produces:
validDeclaration(body): hat zusätzlichcontract('abo'oder'zusatzspeicher', Standard'abo') undorder('abschluss','wechsel'oder'zusatzspeicher', Standard'abschluss').- Kündigung „zusatzspeicher“: Mit gebuchtem Speicher ruft die Route
changeStorage(customerId, 0, null)auf. Die Kündigung wirkt zum Periodenende, dazu kommt das Ereigniskuendigung. Ohne gebuchten Speicher entsteht keine Änderung, die Eingangsbestätigung sagt „wir prüfen“. - Widerruf: Liegt eine offene Frist des gewählten Typs vor, entsteht das Ereignis
widerrufmitdetails: { order, eventId }, und das Bezugsereignis bekommt den Statuswiderrufen.abschluss: wie heutecancelAtPeriodEnd.wechselundzusatzspeicher: T09 nennt die Handgriffe (Preis zurück bzw. Menge zurück, Erstattung).- Ohne offene Frist gibt es keine Änderung, nur das Ereignis
widerrufmitdetails: { order, eventId: null }. Die Bestätigung sagt „wir prüfen“, und T09 geht mit dem Hinweis „keine offene Frist gefunden“ raus.
- Formulare:
kuendigen.htmlhat Radioknöpfename="contract": „Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)“ und „Nur den E-Mail-Zusatzspeicher“.widerrufen.htmlhat Radioknöpfename="order": „Abschluss meines Abos“, „Wechsel ins Wartungs-Abo“ und „E-Mail-Zusatzspeicher“.legal-form.jsübernimmt die Auswahl in den Request und in die Bestätigungsseite. Die Zeile „Vertrag“ zeigt dabei die gewählte Beschriftung statt fest „WEBklar Wartungs-Abo“.
-
Step 1: Failing test –
test/publicForms.test.js(App mitaboRoutes, Nachbauten, SMTP aus)
test('Kündigung nur Zusatzspeicher: Menge 0 ohne Erstattung, Ereignis', async () => {
await post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', contract: 'zusatzspeicher' })
assert.equal(st.calls.findLast((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h').body['items[0][quantity]'], '0')
assert.equal((await listContractEvents('c1'))[0].type, 'kuendigung')
})
test('Kündigung Zusatzspeicher ohne gebuchten Speicher: 200, keine Stripe-Änderung', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 0 }); const before = st.calls.length
const res = await post('/api/abo/public/cancel', { name: 'K', email: 'k@example.invalid', contract: 'zusatzspeicher' })
assert.equal(res.status, 200); assert.equal(st.calls.filter((c) => c.method === 'POST').length, st.calls.slice(0, before).filter((c) => c.method === 'POST').length)
})
test('Unbekannte E-Mail: 200, keine Änderung', async () => {
const before = st.calls.length
const res = await post('/api/abo/public/cancel', { name: 'X', email: 'niemand@example.invalid', contract: 'abo' })
assert.equal(res.status, 200); assert.equal(st.calls.length, before)
})
test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Preiswechsel', async () => {
// Ereignis 'wechsel' mit consentAt vor 3 Tagen
await post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', order: 'wechsel' })
const evs = await listContractEvents('c1')
assert.equal(evs.find((e) => e.type === 'wechsel').status, 'widerrufen')
assert.equal(st.calls.filter((c) => c.method === 'POST' && /subscriptions/.test(c.path)).length, 0)
})
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
// Ereignis 'wechsel' mit consentAt vor 30 Tagen
const res = await post('/api/abo/public/withdraw', { name: 'K', email: 'k@example.invalid', order: 'wechsel' })
assert.equal(res.status, 200)
const evs = await listContractEvents('c1')
assert.notEqual(evs.find((e) => e.type === 'wechsel').status, 'widerrufen')
assert.equal(evs.find((e) => e.type === 'widerruf').details.eventId, null)
})
- Step 2: Run
node --test test/publicForms.test.js. Erwartet: FAIL. - Step 3: Umsetzen: Server und Formulare wie oben.
- Step 4: Prüfen mit
npm test. Dazu die Oberfläche: Playwright aufkuendigen.htmlundwiderrufen.html(Fixture-Server aus Task 14, Step 1). Die Auswahl muss sichtbar sein und im Request stehen. Die Bestätigung zeigt die gewählte Beschriftung. - Step 5: Commit mit der Nachricht „Kündigung und Widerruf: Vertrag bzw. Bestellung auswählbar (§ 312k, § 356a BGB)“.
Task 11: Angebote je Produkt
Files:
- Modify:
server/services/offers.js,server/routes/offers.js,public/admin.js(Angebotsformular, ab Z. ~937) - Test:
test/offerProduct.test.js
Interfaces:
-
Consumes:
getProduct(Plan 1) -
Produces:
validateOfferInput(body):productKeyist Pflicht ('wartung'oder'hosting').regularPriceCentsistgetProduct(productKey).amountCents, und der Angebotspreis muss darunter liegen.createOffer: speichertproductKey.getOpenOffer(customerId, productKey): liefert nur Angebote dieses Produkts. Alte Angebote ohneproductKeygelten alswartung.offerContext(customerId):regularPriceCentswird ersetzt durchproducts: { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } }.subscription.productKeykommt dazu.- Laufende Angebote (
scope 'laufend') gibt es nur für das Produkt des laufenden Abos.
activateOfferFromCheckout(session): Weichtoffer.productKeyvommetadata.productKeyab, wird nichts angewendet, und T03 geht mit Grund raus.GET /api/abo(aus Task 7) liefert jetzt auchoffers.hosting. Der Checkout prüft, dassoffer.productKeyzum bestellten Produkt passt, sonst gibt es 409.- Admin-Formular: Auswahl „Produkt“ (Hosting oder Wartung), Normalpreis und Vorschau je Produkt. K05 und K06 nennen das Produkt.
-
Step 1: Failing test
test('Angebot Hosting: Normalpreis 15 €, Preis darunter', () => {
assert.equal(validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1000 }).regularPriceCents, 1500)
assert.throws(() => validateOfferInput({ productKey: 'hosting', months: 2, monthlyPriceCents: 1500 }), (e) => e.status === 400)
assert.throws(() => validateOfferInput({ months: 2, monthlyPriceCents: 1000 }), (e) => e.status === 400)
})
test('Offenes Angebot nur fürs passende Produkt; alte Angebote gelten als Wartung', async () => {
aw.seed('aboOffers', { $id: 'o_alt', customerId: 'c1', status: 'offen', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 2500 })
aw.seed('aboOffers', { $id: 'o_h', customerId: 'c1', status: 'offen', productKey: 'hosting', validUntil: '2099-01-01T00:00:00.000Z', months: 2, monthlyPriceCents: 1000 })
assert.equal((await getOpenOffer('c1', 'hosting')).$id, 'o_h')
assert.equal((await getOpenOffer('c1', 'wartung')).$id, 'o_alt')
})
- Step 2: Run
node --test test/offerProduct.test.js. Erwartet: FAIL. - Step 3: Umsetzen: Dienst, Route und Admin-Formular. Das Formular prüft Playwright auf der Admin-Seite mit Fixtures: Produktauswahl vorhanden, Vorschau mit „danach 15 €“ bei Hosting.
- Step 4: Run
npm test(inklusive der bestehenden TestsofferDiscountundofferMail). Erwartet: PASS. - Step 5: Commit mit der Nachricht „Abo-Angebote: für Hosting- oder Wartungs-Abo“.
Task 12: Mails
Files:
- Modify:
server/mail-templates/K01 Vertragsbestaetigung.html,K02 …,K03 …,K04 …,K05 …,K06 …,T02 Neues Wartungs-Abo.html(umbenennen inT02 Neues Abo.html),T07 Abo beendet.html,T09 Widerruf eingegangen.htmlserver/routes/abo.js(sendContractConfirmation: Anhang je Version, Werteprodukt,preis)
- Create:
server/mail-templates/K12 Wechsel Wartungs-Abo.html,K13 Zusatzspeicher.html,T12 Wechsel.html,T13 Speicher-Aufgabe.html(Aufbau wie die bestehenden Vorlagen, Kopfkommentar mit Betreff, Platzhaltern und Blöcken) - Test:
test/mailTemplates.test.js(ergänzen)
Interfaces:
-
Consumes:
renderMail(template, values, blocks),templateIds(),termsDocFor -
Produces (Platzhalter und Blöcke, wörtlich):
contractAttachmentFor(termsVersion)inserver/services/products.js: liefert{ filename, path }mit dem absoluten Pfad unterpublic/und nutzttermsDocFor.sendContractConfirmationhängt damit an.- K01:
- Betreff
Ihr WEBklar {{produkt}} – Vertragsbestätigung. - Neue Platzhalter
{{produkt}}und{{preis}}(z. B. „15,00 € / Monat“). - Neue Blöcke
wartungundhosting, je mit der Liste „Enthalten“ aus Spec 3.1. Die Stunde steht nur beiwartung. - Anhang: die Datei
termsDocFor(subDoc.termsVersion).pdfFile.
- Betreff
- K02–K06:
{{produkt}}statt fest „Wartungs-Abo“.- K02 verliert den falschen Hinweis, die Kündigung sei über „Abo verwalten“ gekommen. Kündigen ist dort ausgeschaltet.
- K03 bekommt den Block
zusatzspeicher(„Ihr E-Mail-Zusatzspeicher endet zum {{vertragsende}}“). - K04 bekommt den Platzhalter
{{bestellung}}.
- K12: Betreff
Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung. Platzhalter:{{name}},{{wechseltag}},{{betrag_heute}},{{monatspreis}},{{mindestlaufzeit_bis}},{{bedingungen_version}},{{zustimmung_zeitpunkt}}. PDF im Anhang. - K13: Betreff
Ihr E-Mail-Zusatzspeicher – Bestätigung. Platzhalter:{{name}},{{pakete}},{{gesamt_gb}},{{monatspreis_zusatz}},{{wirksam_ab}}. Blöckeplus(mit Widerrufshinweis und PDF) undminus. - T02: Betreff
[Abo] Neues {{produkt}}: {{email}}. Neue Zeile: „Website als Monitor in UptimeRobot anlegen.“ - T07: neue Zeile „Monitor in UptimeRobot entfernen.“
- T09:
{{bestellung}}und Blockkeine_frist. - T12: Betreff
[Abo] Wechsel ins Wartungs-Abo: {{email}}. - T13: Betreff
[Speicher] {{aktion}}: {{email}}.{{aktion}}ist „einrichten“ oder „reduzieren zum TT.MM.JJJJ“. Platzhalter:{{postfaecher_hinweis}},{{neue_groesse_gb}},{{faellig}}.
-
Step 1: Failing test (ergänzen in
test/mailTemplates.test.js)
test('Neue Vorlagen vorhanden', () => {
for (const id of ['K12', 'K13', 'T12', 'T13']) assert.ok(templateIds().includes(id), id)
})
test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => {
const base = { name: 'K', produkt: 'Hosting-Abo', preis: '15,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026',
mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' }
const h = renderMail('K01', base, { hosting: true, sofortbeginn: true })
assert.ok(h.subject.includes('Hosting-Abo') && h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'))
assert.deepEqual(h.problems, [])
const w = renderMail('K01', { ...base, produkt: 'Wartungs-Abo', preis: '50,00 € / Monat' }, { wartung: true, sofortbeginn: true })
assert.ok(w.text.includes('beliebig viele Postfächer'))
})
test('T02 und T07 erinnern an UptimeRobot', () => {
const t02 = renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', kunde_email: 'k@example.invalid', appwrite_kunde: 'c1', stripe_abo_id: 'sub_1' }, {})
assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo') && t02.text.includes('UptimeRobot anlegen'))
assert.ok(renderMail('T07', { email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled' }, {}).text.includes('UptimeRobot entfernen'))
})
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12 und T12', () => {
for (const id of templateIds()) if (!['K12', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
})
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
// contractAttachmentFor(termsVersion) liefert { filename, path } und wird von sendContractConfirmation benutzt
assert.equal(contractAttachmentFor('2026-09-25-v3').filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(contractAttachmentFor('2026-10-05-v4').filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.ok(existsSync(contractAttachmentFor('2026-10-05-v4').path))
})
- Step 2: Run
node --test test/mailTemplates.test.js. Erwartet: FAIL. - Step 3: Vorlagen und Aufrufe umsetzen
- Aufrufe in
abo.js,mailStorage.jsundplanSwitch.js. - Die bestehenden Tests zu Vorlagen (Platzhalter vollständig, keine offenen Blöcke) müssen grün bleiben.
- Aufrufe in
- Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Mails: Produktname statt fest Wartungs-Abo, neue Mails für Wechsel und Zusatzspeicher, UptimeRobot-Hinweis“.
Task 13: Admin-Reiter „E-Mail-Speicher“, Abo-Anzeige, Schnittstelle fürs Ticketsystem
Files:
- Create:
server/routes/storageTasks.js - Modify:
server/routes/portalAdmin.js:router.use('/storage-tasks', storageTasksAdminRouter)server/routes/admin/customers.js:router.get('/:customerId/abo', …)server/config.js:mailStorage.hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 }test/support/appwriteMock.js: neuGET /v1/account. Beim HeaderX-Appwrite-JWT: jwt-adminkommt{ $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] }zurück, beijwt-kundedasselbe mitlabels: [], sonst 401.public/admin.htmlundpublic/admin.js: neuer Reiter „E-Mail-Speicher“. In der Kundenansicht wird die Zeile „Wartungs-Abo“ zu „Abo“ (Produkt, Status, Zusatzspeicher).
- Test:
test/storageTasks.test.js
Interfaces:
-
Consumes:
listOpenStorageTasks,completeContractEvent(Task 5) -
Produces:
-
GET /api/portal-admin/storage-tasks: liefert:{ tasks: [{ id, customerId, customerName, email, fromUnits, toUnits, dueAt, createdAt }], allocatedGb, packageGb: 50, warnGb: 40, }allocatedGbist die Summe aus 5 + 5 ×storageUnitsüber alle Abos mit aktivem Status. -
POST /api/portal-admin/storage-tasks/:id/done: liefert{ task }. -
GET /api/admin/customers/:customerId/abo: liefert{ productKey: 'wartung'|'hosting'|null, active: boolean }. Es giltrequireAppwriteStaff, und die CORS-Regeln wie heute erlauben ticket.webklar.com.
-
-
Step 1: Failing test
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
// zwei aktive Abos: storageUnits 0 und 2 → 5 + 15 = 20 GB
const r = await get('/api/portal-admin/storage-tasks', { cookie: adminCookie() })
assert.equal(r.allocatedGb, 20); assert.equal(r.packageGb, 50)
assert.equal(r.tasks[0].toUnits, 2)
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', { cookie: adminCookie() })
const { body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, { cookie: adminCookie() })
assert.equal(task.status, 'erledigt'); assert.equal(task.doneBy, 'kenso@webklar.com')
})
test('Ohne Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
})
test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => {
const r = await get('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-admin' } })
assert.deepEqual(r, { productKey: 'hosting', active: true })
assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403)
})
- Step 2: Run
node --test test/storageTasks.test.js. Erwartet: FAIL. - Step 3: Umsetzen: Routen und Admin-Oberfläche. Den Reiter prüft Playwright mit Fixtures: Liste, Knopf „Erledigt“, Anzeige „20 von 50 GB vergeben (Paket M)“, ab 40 GB rot.
- Step 4: Run
npm test. Erwartet: PASS. - Step 5: Commit mit der Nachricht „Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem“.
Block C – Oberfläche im Kundenportal
Alle Prüfungen laufen gegen den Fixture-Server aus Task 14, Step 1. Die Ansichten auf Desktop (1280×800) und Handy (390×844, hasTouch) kommen dabei auch per Pixelvergleich mit main (build/public).
Task 14: Navigation „Verträge“
Files:
- Modify:
public/dashboard.html- Nav-Eintrag
data-view="vertraege"für alle Größen. Er ersetztkb-tab-wartung. view-wartungwird zuview-vertraege,view-abozuview-stunden.- Neue Sections
view-bestellen-hosting,view-bestellen-wartungundview-wechsel. - Zurück-Knopf mit dem Text „Verträge“.
- Konto-Menü, Gruppe „Verträge“: erster Eintrag
<button data-view="vertraege">Mein Vertrag</button>.hrefvon „Vertragsbedingungen“ setzt JavaScript.
- Nav-Eintrag
public/app.jsVIEW_HASHES:vertraege: 'vertraege',stunden: 'stunden','bestellen-hosting': 'bestellen-hosting','bestellen-wartung': 'bestellen-wartung',wechsel: 'wechsel',takeover: 'gutschrift'.- Alias
wartungundabo→vertraegeinviewFromHash. APP_PARENT:{ stunden, 'bestellen-hosting', 'bestellen-wartung', wechsel, takeover }→'vertraege',projekt: 'projects'.APP_PARENT_LABEL.vertraege = 'Verträge'.?abo=successführt zuvertraege.- Das Badge
kb-tab-wartung-badgeheißt künftigkb-tab-vertraege-badge.
public/kundenbereich.css: Namenwartung→vertraege
- Test: Playwright-Skript
ui/nav.mjsim Scratchpad
Interfaces:
-
Consumes: –
-
Produces:
showView('vertraege'). Die Viewsstunden,bestellen-hosting,bestellen-wartungundwechselrendern Tasks 15–18. Das Ereigniskb:viewbleibt,kb:abobleibt. -
Step 1: Fixture-Server und Playwright im Scratchpad (
<scratchpad>/ui/)fixtures.mjsliefert je Szenario (Cookiescenario) die Antwort vonGET /api/aboin der Form aus Task 7. Die Szenarien:ohne-abo,hosting,hosting-gekuendigt,wartung,wartung-v3zahlungsproblem,widerrufsfrist,speicher-einrichtung,speicher-reduzierung
- Dazu kommen die übrigen
/api/*-Antworten wie im Memory-Rezept „Fixture-Server“. server.mjsliefertdev-vertraege/publicauf Port 4310 undbuild/publicauf Port 4311.playwright-corewird übercreateRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/')geladen, Chrome aus~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome.
-
Step 2: Failing check –
ui/nav.mjs- Desktop: Die Seitenleiste hat den Eintrag „Verträge“.
#wartungund#abozeigen#view-vertraege. - Handy: Die Tab-Leiste zeigt als fünften Tab „Verträge“. Aus
#stundenzeigt der Zurück-Knopf „Verträge“ und führt dorthin. - Konto-Menü: Der erste Eintrag der Gruppe „Verträge“ ist „Mein Vertrag“.
- Run:
node <scratchpad>/ui/nav.mjs. Erwartet: FAIL.
- Desktop: Die Seitenleiste hat den Eintrag „Verträge“.
-
Step 3: Umsetzen wie unter Files beschrieben.
-
Step 4: Prüfen
node <scratchpad>/ui/nav.mjsmeldet PASS.- Pixelvergleich der Views Projekte, Fortschritt, Rechnungen und Chat mit main (Desktop und Handy): identisch.
-
Step 5: Commit mit der Nachricht „Kundenbereich: ‚Wartung‘ heißt jetzt ‚Verträge‘, auch am Desktop in der Navigation“.
Task 15: Ansicht „Verträge“ (Mein Vertrag, Vergleich, Wechsel-Karte, Stundenkonto)
Files:
- Modify:
public/app.js,public/kundenbereich.cssloadAboCardheißt künftigloadContracts.- Neu sind
renderContractsView(view, data)undrenderSideCard(card, data). renderAboAccountViewheißt künftigrenderHoursView.ABO_FEATURESundABO_SIDE_FEATURESfallen weg, die Leistungen kommen ausdata.products[].features.
- Test:
ui/vertraege.mjs
Interfaces:
-
Consumes:
GET /api/abo(Task 7) -
Produces:
renderStoragePanel(container, data)wird in Task 17 gefüllt. Bis dahin gibt es eine leere Hülle#storage-panel.- Der Knopf „Zum Wartungs-Abo wechseln“ führt zu
data-view="wechsel".
-
Step 1: Failing check –
ui/vertraege.mjshosting:- Die Karte „Mein Vertrag“ zeigt „Hosting-Abo“, „15,00 €“ und die nächste Abbuchung.
- Es gibt kein Stundenkonto, dafür die Karte mit dem Knopf „Zum Wartungs-Abo wechseln“.
- Vorhanden sind der Knopf „Postfach anfragen“ und
#storage-panel. - Der Link „Vertragsbedingungen“ zeigt auf
/abo-bedingungen.html.
hosting-gekuendigt: Ein Status-Pill „gekündigt“ ist zu sehen, der Wechsel-Knopf nicht. Stattdessen steht dort „Bitte schreiben Sie uns im Chat“.wartung: Die Stunden stehen da, der Link „Stundenkonto“ führt zu#stunden, eine Wechsel-Karte gibt es nicht.wartung-v3: Der Link „Vertragsbedingungen“ zeigt auf/vertragsunterlagen/v3/abo-bedingungen.html, im Konto-Menü ebenso.ohne-abo: Zwei Vergleichskarten mit „Abo abschließen“ führen zu#bestellen-hostingbzw.#bestellen-wartung.widerrufsfrist: „Vertrag widerrufen · bis TT.MM.“ ist hervorgehoben, sowohl in der Seitenleisten-Karte als auch in „Mein Vertrag“.zahlungsproblem: Der Knopf „Zahlungsart aktualisieren“ ist da.- „Postfach anfragen“: Der Knopf öffnet den Chat. Im Eingabefeld steht „Bitte legen Sie ein neues Postfach an: “.
- Run: Erwartet: FAIL.
-
Step 2: Umsetzen: Die Seitenleisten-Karte trägt das Label „Ihr Vertrag“. Die Hinweise zu Zahlungsproblem, Widerrufsfrist und Übernahmegutschrift bleiben wie heute.
-
Step 3: Prüfen
ui/vertraege.mjsmeldet PASS auf Desktop und Handy.- Pixelvergleich der nicht betroffenen Views mit main: identisch.
-
Step 4: Commit mit der Nachricht „Verträge: Mein Vertrag, Vergleich der Abos, Wechsel-Karte, Unterlagen in der zugestimmten Fassung“.
Task 16: Bestellseiten je Produkt
Files:
- Modify:
public/app.js.renderAboOrderViewheißt künftigrenderOrderView(view, data, productKey), gerendert inview-bestellen-hostingundview-bestellen-wartung. - Test:
ui/bestellen.mjs
Interfaces:
-
Consumes:
data.products,data.offers[productKey],POST /api/abo/checkoutmitproductKey -
Produces: –
-
Step 1: Failing check –
ui/bestellen.mjs#bestellen-hosting:- Überschrift „Hosting-Abo“.
- Die Übersicht nennt „WEBklar Hosting-Abo“ und „15,00 € pro Monat“.
- Die Leistungen stammen aus
products[hosting].features. - Der Knopf „Zahlungspflichtig bestellen“ sendet
productKey: 'hosting'.
#bestellen-wartung: „50,00 € pro Monat“ und „beliebig viele Postfächer“.- Angebot: Das Angebot für Hosting erscheint nur auf der Hosting-Seite.
- Die Pflicht-Häkchen und die Wahl des Leistungsbeginns funktionieren wie heute.
- Run: Erwartet: FAIL.
-
Step 2: Umsetzen
-
Step 3: Prüfen:
ui/bestellen.mjsmeldet PASS. -
Step 4: Commit mit der Nachricht „Bestellseiten für Hosting- und Wartungs-Abo“.
Task 17: Zusatzspeicher bedienen
Files:
- Modify:
public/app.js(renderStoragePanel),public/kundenbereich.css - Test:
ui/speicher.mjs
Interfaces:
-
Consumes:
data.subscription.storage,data.mailStorage,POST /api/abo/storage/previewundPOST /api/abo/storage(Task 8) -
Produces: –
-
Step 1: Failing check –
ui/speicher.mjs- Anzeige: „5 GB inklusive“, Plus und Minus mit Zähler, „= N GB gesamt“ und „+ N,00 € im Monat“.
- Plus:
- Nach der Vorschau erscheinen der anteilige Betrag, das Datum der nächsten Abbuchung, das Pflicht-Häkchen „Regeln zum Zusatzspeicher (Link
/abo-bedingungen.html#zusatzspeicher)“ und der Knopf „Zahlungspflichtig bestellen“. - Ohne Häkchen gibt es einen Fehlerhinweis und keinen Request.
- Mit Häkchen geht der Request
{ units, acceptTerms: true, termsVersion }raus.
- Nach der Vorschau erscheinen der anteilige Betrag, das Datum der nächsten Abbuchung, das Pflicht-Häkchen „Regeln zum Zusatzspeicher (Link
- Minus: Der Hinweis lautet „wirkt zum TT.MM.“, der Knopf heißt „Speichern“, der Request enthält kein
acceptTerms. - Grenzen: Bei 0 ist Minus deaktiviert, bei 10 Plus.
speicher-einrichtung: „wird eingerichtet“ steht da.speicher-reduzierung: „ab TT.MM. X GB“ steht da.hosting-gekuendigtundzahlungsproblem: Plus und Minus sind nicht zu sehen.- Run: Erwartet: FAIL.
-
Step 2: Umsetzen
-
Step 3: Prüfen:
ui/speicher.mjsmeldet PASS auf Desktop und Handy (Tastatur-Simulation persetViewportSize). -
Step 4: Commit mit der Nachricht „Zusatzspeicher: Plus und Minus mit Bestellübersicht“.
Task 18: Wechsel-Seite
Files:
- Modify:
public/app.js(renderSwitchView(view, data)) - Test:
ui/wechsel.mjs
Interfaces:
-
Consumes:
POST /api/abo/switch/previewundPOST /api/abo/switch(Task 9) -
Produces: –
-
Step 1: Failing check –
ui/wechsel.mjs- Übersicht direkt über dem Knopf:
- „Wartungs-Abo 50,00 € pro Monat“
- „Gutschrift für ungenutzte Hosting-Tage −9,00 €“ und „Heute fällig 41,00 €“ (Werte aus der Fixture-Vorschau)
- neuer Abbuchungstag und „Mindestlaufzeit bis TT.MM.JJJJ“
- bei Zusatzspeicher die Zeile dafür
- Häkchen: Beide Pflicht-Häkchen (Bedingungen, Sofortbeginn) sind nötig, sonst gibt es einen Fehlerhinweis und keinen Request.
- Bestellung: „Zahlungspflichtig bestellen“ sendet
{ acceptTerms: true, earlyStart: true, termsVersion, prorationDate }. - Ergebnis:
- Antwort mit
paymentUrl: Weiterleitung dorthin. - Antwort ohne: Die Ansicht „Verträge“ zeigt das Wartungs-Abo.
- Antwort mit
- Szenario
wartung:#wechselführt zu „Verträge“. - Run: Erwartet: FAIL.
- Übersicht direkt über dem Knopf:
-
Step 2: Umsetzen
-
Step 3: Prüfen:
ui/wechsel.mjsmeldet PASS auf Desktop und Handy. -
Step 4: Commit mit der Nachricht „Wechsel ins Wartungs-Abo: Übersicht, Zustimmung, Bestellung“.
Block D – Ticketsystem
Task 19: Schalter „Vom Abo-Stundenkonto abziehen“ nur bei Wartungs-Abo
Files (Worktree /home/webklar/apps/ticket.webklar.com/dev-abo-stunden):
- Modify:
src/lib/customerAdminApi.js(neugetCustomerAbo(customerId), ruftGET /api/admin/customers/:id/aboauf),src/components/CreateWorksheetModal.jsx,src/components/WorksheetList.jsx - Create:
src/hooks/useCustomerAbo.js:useCustomerAbo(customerId)liefert{ productKey, active, loading }und speichert das Ergebnis je Kunde zwischen.
Interfaces:
-
Consumes:
GET /api/admin/customers/:customerId/abo(Task 13) -
Produces: –
-
Step 1: Prüfung im bewährten Ticketsystem-Harness schreiben (esbuild-Bundle unter
systemd-run --scope -p MemoryMax=400M, Playwright mit gemocktemadminFetch, siehe Memory „woms-ticketsystem“)- Wartung aktiv: Schalter und Badge „Abo-Stunden“ sichtbar.
- Hosting aktiv: Schalter nicht sichtbar.
- Kein Abo: Schalter nicht sichtbar.
- Fehler der Schnittstelle: Schalter sichtbar wie heute (fail-open), Warnung in der Konsole.
- Erwartet: FAIL.
-
Step 2: Umsetzen
-
Step 3: Prüfen: Harness meldet PASS. Das Bundle baut ohne Fehler.
-
Step 4: Commit im Ticketsystem-Worktree mit der Nachricht „Arbeitsnachweis: Abo-Stundenkonto nur bei Kunden mit Wartungs-Abo“.
Block E – Livegang
Task 20: Livegang (mit dem Nutzer)
Files: server/config.js (abo.termsVersion), public/abo-bedingungen.html (Stand), public/datenschutz.html (Stand), PDFs
Interfaces:
-
Consumes: alles aus Tasks 1–19 und die Freigabe der Rechtsrunde
-
Produces: Plan 2 ist live.
-
Step 1: Freigabedatum eintragen
abo.termsVersion = '<Freigabedatum>-v4', dazu „Version 4 · Stand <TT.MM.JJJJ>“ in den Bedingungen.- Der Stand der Datenschutzerklärung wird das Freigabedatum,
takeover.conditionsVersionebenfalls. - PDFs neu erzeugen mit
scripts/build-legal-pdfs.sh. - Run:
npm test. Erwartet:# fail 0, einschließlichVersion und Stand passen zu termsVersion.
-
Step 2: Node 20
docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege node:20-alpine npm testErwartet:
# fail 0 -
Step 3: Alle UI-Prüfungen noch einmal:
nav,vertraege,bestellen,speicher,wechsel, die Formulare und der Admin-Bereich melden PASS. -
Step 4: Mit main abgleichen:
git fetch origin && git log --oneline HEAD..origin/main. Bei Abweichung erst rebasen, dann Step 1–3 wiederholen. -
Step 5: Schema in Produktion anlegen. Das macht der Nutzer oder gibt es frei, und zwar vor dem Deploy.
docker exec -i -w /app webklar-kundenbereich-server node --input-type=module - < /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege/scripts/setup-abo-collections.mjsErwartet:
Fertig., neue Zeilen fürtermStartAt,storageUnits,storagePaidUnits,storagePaidPeriodEnd,aboOffers.productKeyund die CollectioncontractEvents. -
Step 6: Portal pushen. Nur mit Freigabe des Nutzers, der Push löst den Deploy aus.
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-vertraege push origin feature/vertraege-hosting-abo:mainDanach zeigt
build/den Commit, der Container ist neu gestartet, und die Logs sind ohne Fehler. -
Step 7: Stripe einrichten. Das ist ein Live-Schreibzugriff und braucht die ausdrückliche Freigabe des Nutzers.
docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjsErwartet: Die Preise für Hosting (1500) und Zusatzspeicher (100) sind vorhanden. Die Beschreibung des Wartungs-Produkts entspricht Fassung 4, die Überschrift des Kundenportals lautet „WEBklar – Abo verwalten“.
-
Step 8: Website pushen. Nur mit Freigabe des Nutzers.
git -C /home/webklar/apps/webklar.com/dev-hosting-abo push origin feature/hosting-abo:mainDer Deploy dauert etwa 20 s. Danach liefern
https://webklar.com/hosting-aboundhttps://webklar.com/wartungs-aboden Status 200, und der Headless-Check zeigt die neuen Texte. -
Step 9: Ticketsystem. Der Nutzer pusht selbst auf
test, und nur auf diesen Branch:git -C /home/webklar/apps/ticket.webklar.com/dev-abo-stunden push origin feature/abo-stundenkonto:test -
Step 10: Live prüfen:
https://project.webklar.com/abo-bedingungen.htmlzeigt „Version 4“.…/WEBklar-Abo-Vertragsunterlagen.pdfliefert 200.…/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdfliefert 200 und enthält Version 3.…/vertragsunterlagen/v3/abo-bedingungen.htmlliefert 200.- Die Datenschutzerklärung zeigt den neuen Stand und UptimeRobot.
GET /api/aboohne Session liefert 401.- Mit dem Testkonto des Nutzers, und nur mit seiner Freigabe: „Verträge“ zeigt das Wartungs-Abo, Plus/Minus und die Vorschau funktionieren.
-
Step 11: Betrieb (Nutzer):
- In UptimeRobot sind alle Abo-Websites als Monitor angelegt.
- Der belegte Speicher in konsoleH ist abgelesen (Paket M, Warnschwelle 40 GB).
- Die Info-Mail an Bestandskunden mit Fassung 3 (beliebig viele Postfächer) ist entworfen, vom Nutzer freigegeben und verschickt.
-
Step 12: Memory aktualisieren: Plan 2 live, Commits, offene Punkte.