Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-09-27-vertraege-plan-2-sichtbar.md

93 KiB
Raw Permalink Blame History

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.js und server/services/products.js.
  • Drei neue Dienste:
    • contractEvents.js für Nachweise, Widerrufsfristen und Speicher-Aufgaben
    • mailStorage.js für Plus und Minus
    • planSwitch.js fü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.js und public/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 --test und 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–14 und Anhang (Abschnitt 14 = Entscheidungen der Rechtsrunde, geht vor).

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 active und ohne Kündigung (cancelAtPeriodEnd oder cancelAt gesetzt heißt gekündigt). Über „Verträge hier kündigen“ (Task 10) sind Verringerungen jederzeit möglich, auch bei gekündigtem Abo oder offener Zahlung.
  • Wechsel:
    • Nur ein Hosting-Abo mit Status active kann 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.

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-hosting und #bestellen-wartung
    • #wechsel

    Die alten #wartung und #abo führen zu #vertraege. #gutschrift bleibt.

Rechtstexte

  • Adresse und PDF: Die Bedingungen bleiben unter /abo-bedingungen.html. Das neue PDF heißt public/WEBklar-Abo-Vertragsunterlagen.pdf.
  • Archiv Version 3: public/vertragsunterlagen/v3/abo-bedingungen.html und public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf. public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf bleibt als Fassung 3 bestehen.
  • Versionsstand: config.abo.termsVersion hat das Format JJJJ-MM-TT-v4 und 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. Nach der Rechtsrunde kommen K14 (Wechsel nicht zustande gekommen) und T14 (Löschfristen, intern) dazu.
  • contractEvents.type ist abschluss, wechsel, speicher, kuendigung oder widerruf. status ist offen, erledigt, widerrufen oder gescheitert (Wechsel ohne Zahlung, nach der Rechtsrunde).
  • 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) und WEBKLAR-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 main nur mit Freigabe des Nutzers.
  • Website: Push nach main von JUSN/Webklar nur 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

  1. 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“.
  2. Die Wechsel-Rechnung braucht eine Bankbestätigung (3-D Secure) oder scheitert.
    • Karte: Der Wechsel bleibt ausstehend (pending_update). Das Abo bleibt active als Hosting-Abo. Das Portal zeigt „Ihr Wechsel wartet auf die Zahlung“ mit dem Link zur Stripe-Zahlungsseite. Verfällt die ausstehende Aktualisierung, wird das Ereignis gescheitert und K14 geht raus (Rechtsrunde).
    • SEPA: Der Wechsel gilt sofort. Eine geplatzte Lastschrift führt zu T12 lastschrift_gescheitert und wird von Hand rückgängig gemacht.
    • Der Wechsel bleibt als Ereignis nachweisbar.
    • Test: Task 9, Zahlung ausstehend liefert paymentUrl, Ereignis offen.
  3. Gekündigtes Abo, Status trialing oder past_due. Wechsel, Plus und Minus werden im Portal 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).
  4. Ö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.
  5. 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 -8 enthä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.
  • 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(/&nbsp;/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.html bzw. 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.
  • Step 5: scripts/build-legal-pdfs.sh anpassen

    • Die Zeile pdf public/abo-bedingungen.html public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf wird zu pdf 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
  • 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, dann node --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.js und der Nachricht „Datenschutz (Entwurf): unsere Abos, Überwachung mit UptimeRobot“.

Task 3: Übernahmegutschrift, Prüftexte, Leitfaden

Files:

  • Modify:
    • public/app.js: TAKEOVER_CONDITIONS, ab Z. ~1046
    • server/config.js: takeover.conditionsVersion
    • server/services/takeoverCredits.js: Beschriftungen und Fehlermeldungen mit „Wartungs-Abo“
    • docs/leitfaden-empfehlungsgeber.html und das PDF
  • Test: test/legalTexts.test.js (ergänzen)

Interfaces:

  • Consumes: –

  • Produces: config.takeover.conditionsVersion = Entwurfsdatum (Format JJJJ-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.js sagen „Abo“.
    • Die Plausibilitätsprüfung „mehr als das Abo“ (Z. ~641) vergleicht mit getProduct(subscription?.productKey || 'wartung').amountCents statt mit config.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)“.

Files (Worktree /home/webklar/apps/webklar.com/dev-hosting-abo):

  • Create: src/pages/HostingAbo.tsx, src/test/abos.test.tsx
  • Modify:
    • src/pages/WartungsAbo.tsx
    • src/App.tsx: Route /hosting-abo
    • src/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-abo existiert.

  • 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.tsx ist aufgebaut wie WartungsAbo.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.

✅ Rechtsrunde abgeschlossen (28.09.2026): Der Nutzer hat alle 19 Vorschläge des Übersichtsblatts freigegeben („alle Vorschläge ok“). Die Folgen stehen als Hinweis „Rechtsrunde“ in Task 8, 9, 10, 12 und 20, im neuen Task 21 (Löschfristen) und in Spec Abschnitt 14.


Block B – Server

Task 5: Datenmodell und Dienst contractEvents

Files:

  • Modify: scripts/setup-abo-collections.mjs
    • subscriptions bekommt { type: 'datetime', key: 'termStartAt' }, { type: 'integer', key: 'storageUnits' }, { type: 'integer', key: 'storagePaidUnits' } und { type: 'datetime', key: 'storagePaidPeriodEnd' }.
    • aboOffers bekommt { 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_customer und idx_type_status über (type, status)
  • 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 nach test/support/schema.js verschoben.
    • withdrawalDeadline(contractAt) aus server/services/deadlines.js
  • Produces:

    • recordContractEvent({ customerId, subscriptionId, type, productKey, details = {}, consent = null, status = 'erledigt', dueAt = null }): liefert das Dokument. consent hat die Form { termsVersion, consentAt, consentIp, consentUserAgent, earlyStartConsentAt? }, details wird als JSON gespeichert.

    • listContractEvents(customerId): liefert die Dokumente, neueste zuerst, mit geparstem details.

    • openWithdrawals(events, now = new Date()): liefert [{ type, eventId, until }]. Das umfasst:

      • abschluss und wechsel
      • speicher mit details.toUnits > details.fromUnits

      Jeweils mit status !== 'widerrufen' und now <= withdrawalDeadline(consentAt).endsAt.

    • listOpenStorageTasks(): liefert type = speicher und status = offen, sortiert nach dueAt aufsteigend.

    • completeContractEvent(id, staffEmail): setzt status: 'erledigt', doneAt und doneBy.

  • Step 1: Failing test – test/contractEvents.test.js (Appwrite-Nachbau mit Schema aus dem Setup-Skript für contractEvents und subscriptions)

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 (Umgebungsvariable APPWRITE_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): key ist 'wartung', 'hosting' oder 'mailStorage', zurück kommt Promise<priceId>.
      • Die Funktion ersetzt ensureAboPrice, und alle Aufrufer werden umgestellt.
      • Sie legt Produkt und Preis an mit tax_behavior: 'inclusive', recurring.interval: 'month' und lookup_key.
      • Die Produkt-Metadaten webklar sind wartungs-abo, hosting-abo bzw. mail-speicher.
      • Das Ergebnis wird je Schlüssel zwischengespeichert.
    • ensureTestCoupon(productKey): liefert 'WEBKLAR-TEST-050' für Wartung bzw. 'WEBKLAR-TEST-050-HOSTING' für Hosting. amount_off ist amountCents - 50.
    • ensurePortalConfiguration(): Die Überschrift ist 'WEBklar – Abo verwalten'. Eine bestehende eigene Konfiguration mit anderer Überschrift wird per billingPortal.configurations.update angepasst.
    • termStartOf(stripeSub): liefert ein Date aus metadata.termStartAt, sonst trial_end, sonst start_date. Es ersetzt serviceStartOf in cancelSubscriptionAt.
    • Spiegel: syncSubscription schreibt zusätzlich termStartAt (ISO) und storageUnits.
    • termsDocFor(termsVersion) in products.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' }.
    • productView(key): liefert { key, name, amountCents, minTermMonths, minutesPerMonth, features }.
      • features ist 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_FEATURES mehr.
    • scripts/stripe-products.mjs:
      • node scripts/stripe-products.mjs --pruefen listet, was fehlt oder abweicht.
      • Ohne Schalter ruft das Skript ensurePrice fü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, productView mit features im 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, Webhook checkout.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.
    • POST /api/abo/checkout: Der Body bekommt zusätzlich productKey ('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 ab doc.termStartAt || doc.trialEnd || doc.startedAt.

    • Webhook checkout.session.completed: zusätzlich recordContractEvent({ type: 'abschluss', productKey, consent aus den Metadaten }).

    • startTestApp(routers) (in test/support/testApp.js): liefert Promise<{ url, close }>. Die App nutzt express.json mit rawBody und cookieParser(SESSION_SECRET).

    • sessionCookie(session): liefert einen signierten Cookie webklar_portal_session (mit cookie-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.js stellt bereit:

      • Kunde c1 mit k@example.invalid
      • hostingDoc: subscriptions-Dokument sub_h von c1 mit productKey 'hosting', Status active, termsVersion v4, storageUnits: 0 und currentPeriodEnd in 20 Tagen
      • wartungDoc
      • Kunde c2 mit einem zweiten Hosting-Abo sub_h2
      • consent = { termsVersion: config.abo.termsVersion, consentAt, consentIp: '127.0.0.1', consentUserAgent: 'test' }
      • Funktionen customerCookie() und adminCookie()
    • Angebote: offers.hosting bleibt bis Task 11 null. offers.wartung kommt aus dem heutigen getOpenOffer(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 offerId gibt es 409 („Ihr Angebot gilt für ein anderes Abo“).
    • GET /api/abo und publicSubscription erweitern.
    • Im Webhook das Ereignis abschluss schreiben.
  • 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

Rechtsrunde (28.09.2026):

  • Ein Minus speichert im Ereignis speicher keine Zustimmungsdaten (kein consent, also keine IP-Adresse und keine Browserkennung). Die Datenschutzerklärung verspricht Nachweisdaten nur für Bestellungen (Abschluss, Wechsel, Plus). Test: Das Ereignis eines Minus hat kein consentIp.
  • changeStorage bekommt als vierten Parameter options = {} mit viaCancellation. Mit viaCancellation: true sind Reduzierungen auch bei gekündigtem Abo erlaubt (Kündigungsformular, Task 10), Erhöhungen nie. Test: gekündigtes Abo mit viaCancellation und weniger Paketen gibt keine 409; mit mehr Paketen gibt es 409.

Files:

  • Create: server/services/mailStorage.js
  • Modify: server/routes/abo.js (POST /storage/preview, POST /storage, Webhook invoice.paid ruft recordPaidStorage)
  • Test: test/mailStorage.test.js

Interfaces:

  • Consumes:

    • ensurePrice('mailStorage'), describeItems, syncSubscription, getCurrentSubscription
    • recordContractEvent
    • 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 }.
      • direction ist 'plus', 'minus' oder 'none', steps eine Liste [{ quantity, proration_behavior }].
      • Ist newUnits keine ganze Zahl zwischen 0 und maxUnits, wirft die Funktion einen Fehler mit Status 400.
      • Plus bis paidUnits: ein Schritt mit none.
      • Plus über paidUnits, wenn currentUnits < paidUnits: erst { paidUnits, none }, dann { newUnits, create_prorations }.
      • Sonst Plus: ein Schritt create_prorations.
      • Minus: ein Schritt mit none.
    • paidUnitsOf(doc): Stimmt doc.storagePaidPeriodEnd mit doc.currentPeriodEnd überein, liefert die Funktion doc.storagePaidUnits, sonst doc.storageUnits || 0.
    • previewStorageChange(customerId, newUnits): liefert { direction, units, totalGb, monthlyCents, deltaMonthlyCents, prorationCents, nextChargeAt, effectiveAt }.
      • Das anteilige Plus kommt aus invoices.createPreview (mit subscription_details.items, proration_behavior, proration_date).
      • effectiveAt ist beim Minus currentPeriodEnd, sonst jetzt.
    • changeStorage(customerId, newUnits, consent): liefert { subscription, event }.
      • Plus ohne consent ergibt 400 („Bitte stimmen Sie den Regeln zum Zusatzspeicher zu“).
      • Ist das Abo nicht active oder 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) mit storagePaidPeriodEnd = currentPeriodEnd gesetzt.
      • Bei 0 Paketen bleibt die Position mit quantity: 0 bestehen. Stripe erlaubt 0 bei Stückpreisen, so behält ein späteres Plus seinen Posten.
      • Es entsteht das Ereignis speicher: Status offen, dueAt = jetzt (Plus) bzw. currentPeriodEnd (Minus), details: { fromUnits, toUnits }.
      • Danach gehen K13 an den Kunden (beim Plus mit PDF) und T13 ans Team.
    • recordPaidStorage(freshInvoice): Hat die Rechnung eine reguläre Zusatzspeicher-Zeile (nicht anteilig), setzt die Funktion storagePaidUnits auf deren quantity und storagePaidPeriodEnd auf line.period.end.
    • Alle Rechnungszeilen laden (Hinweis aus dem Review von Plan 1): invoices.retrieve bettet 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.
      • grantCreditForInvoice und recordPaidStorage laden deshalb bei lines.has_more alle Zeilen per invoices.listLineItems(id, { limit: 100 }).
      • Test: Rechnung mit 11 Zeilen, die Wartungs-Zeile ist die elfte. Ergebnis: 60 Minuten.
    • Routen:
      • POST /api/abo/storage/preview mit dem Body { units }
      • POST /api/abo/storage mit dem Body { units, acceptTerms, termsVersion }, dabei ist acceptTerms beim 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

Rechtsrunde (28.09.2026), Entscheidung 2: Der Wechsel wird erst mit der Zahlung wirksam. Das geht vor die Beschreibung unten.

  • Zahlart ermitteln: Standard-Zahlungsmethode des Abos, sonst die des Kunden (payment_method.type).
  • Karte (card, ebenso link): Der Aufruf bleibt wie unten, nur mit payment_behavior: 'pending_if_incomplete'. Stripe übernimmt Preis, Anker, Metadaten und Rabatte erst, wenn die Wechselrechnung bezahlt ist. metadata und discounts sind in ausstehenden Aktualisierungen erlaubt (docs.stripe.com/billing/subscriptions/pending-updates-reference, geprüft am 28.09.2026). Eine ausstehende Aktualisierung verfällt nach spätestens 23 Stunden.
  • SEPA-Lastschrift (sepa_debit): Stripe erlaubt dafür keine ausstehenden Aktualisierungen. Der Aufruf bleibt deshalb mit allow_incomplete, und der Wechsel gilt sofort. Scheitert die Lastschrift, machen wir ihn von Hand rückgängig (siehe Webhooks).
  • Ergebnis:
    • Hat das Abo danach ein pending_update (Karte, Zahlung nicht sofort erfolgreich, etwa wegen 3-D Secure), ist das Ereignis wechsel offen. paymentUrl ist die hosted_invoice_url, und Metadaten und Spiegel ändern sich noch nicht.
    • Ohne pending_update und mit bezahlter Rechnung ist das Ereignis erledigt.
    • Bei SEPA bleibt es offen, bis invoice.paid kommt.
    • Ein zweiter Wechsel, während einer auf die Zahlung wartet, gibt 409 („Ein Wechsel wartet noch auf die Zahlung“).
  • Mails:
    • K12 geht sofort nach dem Klick raus, als Vertragsbestätigung mit PDF.
    • Ist der Wechsel noch nicht wirksam, enthält K12 den Block zahlung_offen: „Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.“
    • T12 geht erst raus, wenn der Wechsel wirksam ist.
  • Webhooks (dort, wo die bestehenden Stripe-Ereignisse verarbeitet werden):
    • customer.subscription.pending_update_applied: Abo spiegeln, Ereignis wechsel wird erledigt, T12 geht raus.
    • customer.subscription.pending_update_expired: Ereignis wechsel wird gescheitert, K14 geht an den Kunden, T12 mit Block gescheitert.
    • invoice.payment_failed für eine Wechselrechnung mit SEPA (Abo mit switchedAt in den letzten 30 Tagen, billing_reason subscription_update): T12 mit Block lastschrift_gescheitert und diesen Handgriffen:
      • Preis ohne anteilige Verrechnung zurück auf Hosting
      • Wechselrechnung stornieren
      • Ereignis auf gescheitert setzen
      • K14 senden
      • Der Abbuchungstag bleibt der Wechseltag.
  • Tests (ergänzen):
    • Karte → pending_if_incomplete, SEPA → allow_incomplete.
    • Antwort mit pending_update → Ereignis offen und paymentUrl.
    • Webhook pending_update_expired → gescheitert und K14. Webhook pending_update_applied → erledigt und T12.
    • Wechsel bei ausstehendem Wechsel → 409.
  • Livegang: Die neuen Webhook-Ereignisse am Stripe-Endpunkt freischalten (Task 20).
  • Nach der Prüfung der Rechtsrunden-Texte:
    • Mails nennen keine feste Frist von 23 Stunden, weil die Frist bei Stripe auch kürzer sein kann.
    • Bei gescheiterter SEPA-Lastschrift die Wechselrechnung sofort stornieren, damit ein automatischer Einzugsversuch sie nicht mehr einzieht.
    • Weitere Postfächer richten wir erst ein, wenn die Wechselrechnung bezahlt ist (Vertragsbedingungen § 9 Abs. 3). T12 nennt das.

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, syncSubscription
    • recordContractEvent
    • Mails K12 und T12 (Task 12)
  • Produces:

    • previewSwitch(customerId): liefert { prorationDate, amountDueCents, creditCents, monthlyCents, nextChargeAt, minTermEnd }.
      • Grundlage ist invoices.createPreview mit subscription_details: { items: [{ id: aboItemId, price: wartungPriceId }], billing_cycle_anchor: 'now', proration_behavior: 'create_prorations', proration_date }.
      • creditCents ist die Summe der negativen anteiligen Zeilen als positiver Betrag.
      • minTermEnd liegt 12 Monate nach prorationDate.
    • switchToWartung(customerId, consent, prorationDate): liefert { subscription, paymentUrl }.
      • Voraussetzungen: productKey ist hosting, der Status active, 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'] }).
      • metadata enthält { productKey: 'wartung', termStartAt, termsVersion, termsAcceptedAt, earlyStartConsentAt, consentIp, consentUserAgent, switchedFrom: 'hosting', switchedAt }.
      • Bei einem Testkonto werden die Rabatte neu gesetzt: WEBKLAR-TEST-050-HOSTING fällt weg, WEBKLAR-TEST-050 kommt dazu, andere Rabatte bleiben.
      • paymentUrl ist latest_invoice.hosted_invoice_url, falls die Rechnung noch open ist, sonst null.
      • Danach folgen das Ereignis wechsel (mit consent und details: { from: 'hosting', amountDueCents, prorationDate }) und die Mails K12 (Kunde, PDF der Fassung 4) und T12.
    • Routen:
      • POST /api/abo/switch/preview
      • POST /api/abo/switch mit dem Body { acceptTerms: true, earlyStart: true, termsVersion, prorationDate }. Fehlt eines davon, gibt es 400. Stimmt termsVersion nicht, 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_preview und POST /v1/subscriptions/:id, dazu nextSubscriptionUpdate: Dieses Objekt wird einmalig in die nächste Antwort von POST /v1/subscriptions/:id gemischt.
  • 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

Rechtsrunde (28.09.2026), Entscheidung 5: Zusatzspeicher auch teilweise kündigen.

  • Bei der Auswahl „Nur den E-Mail-Zusatzspeicher“ fragt kuendigen.html zusätzlich die Zahl der Pakete ab, die gekündigt werden sollen: Feld packages, ganze Zahl von 1 bis 10, leer heißt alle gebuchten. validDeclaration übernimmt das Feld.
  • Mehr Pakete als gebucht heißt alle. Die Reduzierung wirkt zum Ende des Abrechnungsmonats, wie ein Minus und ohne Zustimmungsdaten.
  • Sie geht auch, wenn das Abo schon gekündigt ist (viaCancellation, Task 8).
  • Das Ereignis kuendigung bekommt details: { packages, fromUnits, toUnits }.
  • Die Reduzierung erscheint wie jedes Minus als Speicher-Aufgabe im Admin (Task 13).
  • K03 nennt die Zahl der Pakete (Block zusatzspeicher mit {{pakete}}), T08 ebenso.
  • Tests (zusätzlich zu den Tests unten):
    • 2 von 3 Paketen ergibt Menge 1.
    • Ohne packages ergibt Menge 0.
    • Gekündigtes Abo: die Menge wird trotzdem reduziert.
    • packages: 0 oder 11 ergibt 400.

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), listContractEvents und openWithdrawals (Task 5), Mails K03, K04, T08, T09 (Task 12)

  • Produces:

    • validDeclaration(body): hat zusätzlich contract ('abo' oder 'zusatzspeicher', Standard 'abo') und order ('abschluss', 'wechsel' oder 'zusatzspeicher', Standard 'abschluss').
    • Kündigung „zusatzspeicher“: Mit gebuchtem Speicher ruft die Route changeStorage(customerId, restUnits, null, { viaCancellation: true }) auf (restUnits = gebuchte Pakete minus packages, mindestens 0; siehe Hinweis Rechtsrunde). Die Kündigung wirkt zum Periodenende, dazu kommt das Ereignis kuendigung. 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 widerruf mit details: { order, eventId }, und das Bezugsereignis bekommt den Status widerrufen.
      • abschluss: wie heute cancelAtPeriodEnd.
      • wechsel und zusatzspeicher: T09 nennt die Handgriffe (Preis zurück bzw. Menge zurück, Erstattung).
      • Ohne offene Frist gibt es keine Änderung, nur das Ereignis widerruf mit details: { order, eventId: null }. Die Bestätigung sagt „wir prüfen“, und T09 geht mit dem Hinweis „keine offene Frist gefunden“ raus.
    • Formulare:
      • kuendigen.html hat Radioknöpfe name="contract": „Mein Abo (Hosting- oder Wartungs-Abo, mit Zusatzspeicher)“ und „Nur den E-Mail-Zusatzspeicher“.
      • widerrufen.html hat Radioknöpfe name="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 mit aboRoutes, 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 auf kuendigen.html und widerrufen.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): productKey ist Pflicht ('wartung' oder 'hosting'). regularPriceCents ist getProduct(productKey).amountCents, und der Angebotspreis muss darunter liegen.
    • createOffer: speichert productKey.
    • getOpenOffer(customerId, productKey): liefert nur Angebote dieses Produkts. Alte Angebote ohne productKey gelten als wartung.
    • offerContext(customerId):
      • regularPriceCents wird ersetzt durch products: { hosting: { regularPriceCents: 1500 }, wartung: { regularPriceCents: 5000 } }.
      • subscription.productKey kommt dazu.
      • Laufende Angebote (scope 'laufend') gibt es nur für das Produkt des laufenden Abos.
    • activateOfferFromCheckout(session): Weicht offer.productKey vom metadata.productKey ab, wird nichts angewendet, und T03 geht mit Grund raus.
    • GET /api/abo (aus Task 7) liefert jetzt auch offers.hosting. Der Checkout prüft, dass offer.productKey zum 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 Tests offerDiscount und offerMail). Erwartet: PASS.
  • Step 5: Commit mit der Nachricht „Abo-Angebote: für Hosting- oder Wartungs-Abo“.

Task 12: Mails

Rechtsrunde (28.09.2026):

  • Weitere Vorlagen: T05, T06 und T08 bekommen {{produkt}} statt fest „Wartungs-Abo“ (Spec 9, Zeile „T05, T06, T08, T09“).
  • Paragraph zum Vertragsende:
    • K02, K03, T05 und T07 nennen den Paragraphen zum Vertragsende aus der Fassung des Kunden, über den Platzhalter {{vertragsende_paragraph}} aus termsDocFor(subDoc.termsVersion): Fassung 3 → „§ 9“, Fassung 4 → „§ 11“. In Fassung 4 ist § 9 der Wechsel.
    • Test: Für ein Abo mit Fassung 4 nennt keine dieser Mails „§ 9 der Vertragsbedingungen“.
  • Neu:
    • K14, Betreff „Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen“, Platzhalter {{name}} und {{grund}}. Der Text sagt, dass das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiterläuft.
    • K12-Block zahlung_offen und T12-Blöcke gescheitert und lastschrift_gescheitert (Task 9).
    • K03- und T08-Platzhalter {{pakete}} im Block zusatzspeicher (Task 10).
    • T14 „[Datenschutz] Löschfristen fällig“ (Task 21).
  • Schon erledigt: K09 wurde in Task 3 angepasst („Ihres Abos“, Verrechnung nach einem Wechsel).

Files:

  • Modify:
    • server/mail-templates/K01 Vertragsbestaetigung.html, K02 …, K03 …, K04 …, K05 …, K06 …, T02 Neues Wartungs-Abo.html (umbenennen in T02 Neues Abo.html), T07 Abo beendet.html, T09 Widerruf eingegangen.html
    • server/routes/abo.js (sendContractConfirmation: Anhang je Version, Werte produkt, 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) in server/services/products.js: liefert { filename, path } mit dem absoluten Pfad unter public/ und nutzt termsDocFor. sendContractConfirmation hängt damit an.
    • K01:
      • Betreff Ihr WEBklar {{produkt}} – Vertragsbestätigung.
      • Neue Platzhalter {{produkt}} und {{preis}} (z. B. „15,00 € / Monat“).
      • Neue Blöcke wartung und hosting, je mit der Liste „Enthalten“ aus Spec 3.1. Die Stunde steht nur bei wartung.
      • Anhang: die Datei termsDocFor(subDoc.termsVersion).pdfFile.
    • 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öcke plus (mit Widerrufshinweis und PDF) und minus.
    • T02: Betreff [Abo] Neues {{produkt}}: {{email}}. Neue Zeile: „Website als Monitor in UptimeRobot anlegen.“
    • T07: neue Zeile „Monitor in UptimeRobot entfernen.“
    • T09: {{bestellung}} und Block keine_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.js und planSwitch.js.
    • Die bestehenden Tests zu Vorlagen (Platzhalter vollständig, keine offenen Blöcke) müssen grün bleiben.
  • 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: neu GET /v1/account. Beim Header X-Appwrite-JWT: jwt-admin kommt { $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] } zurück, bei jwt-kunde dasselbe mit labels: [], sonst 401.
    • public/admin.html und public/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,
      }
      

      allocatedGb ist 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 gilt requireAppwriteStaff, 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“.

Task 21: Löschfristen (Rechtsrunde, Entscheidung 11)

Files:

  • Create: server/services/retention.js, scripts/loeschfristen.mjs, server/mail-templates/T14 Loeschfristen.html
  • Test: test/retention.test.js

Interfaces:

  • Consumes:

    • Appwrite-Zugriff wie in contractEvents.js (Task 5)
    • renderMail, Nachbauten und schemaKeys() aus test/support/
    • Collections subscriptions (Felder status, endedAt, canceledAt, currentPeriodEnd, customerId), contractEvents, takeoverCredits
  • Produces:

    • findDueDeletions(now = new Date()): liefert { customers: [{ customerId, lastEndAt, subscriptionIds, eventIds }], takeover: [{ id, reason }] }.
      • Ein Kunde ist fällig, wenn alle seine Abos beendet sind und seit dem Ende des Jahres, in dem sein letztes Abo endete, drei Jahre vergangen sind („drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“, Datenschutzerklärung Abschnitt 7).
      • Das Skript läuft einmal im Jahr, im Januar.
      • Das Ende ist endedAt, sonst canceledAt. Fehlt beides bei status: 'canceled', zählt currentPeriodEnd.
      • Anträge auf Übernahmegutschrift sind fällig nach den Fristen in Abschnitt 7 der Datenschutzerklärung. Sie stehen dort im Absatz zur Übernahmegutschrift und werden wörtlich übernommen.
    • applyDeletions(plan): liefert { deleted, manual }.
      • Löscht die Appwrite-Dokumente in subscriptions und contractEvents der fälligen Kunden.
      • Leert bei Stripe die Metadaten consentIp und consentUserAgent ihrer Abos. Lehnt Stripe die Änderung eines beendeten Abos ab, landet es in manual (Hinweis im Bericht).
      • Löscht fällige Übernahme-Anträge samt hochgeladenen Nachweisen.
      • Rechnungen bleiben (Aufbewahrungspflicht, Abschnitt 9).
    • scripts/loeschfristen.mjs: läuft im Container wie das Setup-Skript.
      • Ohne Umgebungsvariable ist es ein Probelauf: Bericht auf der Konsole, es wird nichts gelöscht.
      • LOESCHFRISTEN=mail schickt den Bericht als T14 an das Team.
      • LOESCHFRISTEN=apply löscht.
  • Step 1: Failing test – test/retention.test.js (Appwrite- und Stripe-Nachbau)

    • Abo beendet am 15.03.2022 → fällig am 01.01.2026, nicht fällig am 31.12.2025.
    • Abo beendet vor zwei Jahren → nicht fällig.
    • Altes beendetes Abo und ein laufendes Abo desselben Kunden → nicht fällig.
    • applyDeletions löscht die Dokumente und schickt an Stripe leere Metadaten (Aufruf im Nachbau geprüft). Eine abgelehnte Stripe-Änderung landet in manual.
    • Ein Probelauf des Skripts ändert nichts.
  • Step 2: Run node --test test/retention.test.js. Erwartet: FAIL.

  • Step 3: Umsetzen: Dienst, Skript und T14 (Aufbau wie die bestehenden Vorlagen).

  • Step 4: Run npm test. Erwartet: PASS.

  • Step 5: Commit mit der Nachricht „Löschfristen: Probelauf, Bericht ans Team, Löschen nach drei Jahren“.

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 ersetzt kb-tab-wartung.
      • view-wartung wird zu view-vertraege, view-abo zu view-stunden.
      • Neue Sections view-bestellen-hosting, view-bestellen-wartung und view-wechsel.
      • Zurück-Knopf mit dem Text „Verträge“.
      • Konto-Menü, Gruppe „Verträge“: erster Eintrag <button data-view="vertraege">Mein Vertrag</button>. href von „Vertragsbedingungen“ setzt JavaScript.
    • public/app.js
      • VIEW_HASHES: vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift'.
      • Alias wartung und abo → vertraege in viewFromHash.
      • APP_PARENT: { stunden, 'bestellen-hosting', 'bestellen-wartung', wechsel, takeover } → 'vertraege', projekt: 'projects'.
      • APP_PARENT_LABEL.vertraege = 'Verträge'.
      • ?abo=success führt zu vertraege.
      • Das Badge kb-tab-wartung-badge heißt künftig kb-tab-vertraege-badge.
    • public/kundenbereich.css: Namen wartung → vertraege
  • Test: Playwright-Skript ui/nav.mjs im Scratchpad

Interfaces:

  • Consumes: –

  • Produces: showView('vertraege'). Die Views stunden, bestellen-hosting, bestellen-wartung und wechsel rendern Tasks 15–18. Das Ereignis kb:view bleibt, kb:abo bleibt.

  • Step 1: Fixture-Server und Playwright im Scratchpad (<scratchpad>/ui/)

    • fixtures.mjs liefert je Szenario (Cookie scenario) die Antwort von GET /api/abo in der Form aus Task 7. Die Szenarien:
      • ohne-abo, hosting, hosting-gekuendigt, wartung, wartung-v3
      • zahlungsproblem, widerrufsfrist, speicher-einrichtung, speicher-reduzierung
    • Dazu kommen die übrigen /api/*-Antworten wie im Memory-Rezept „Fixture-Server“.
    • server.mjs liefert dev-vertraege/public auf Port 4310 und build/public auf Port 4311.
    • playwright-core wird über createRequire('/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“. #wartung und #abo zeigen #view-vertraege.
    • Handy: Die Tab-Leiste zeigt als fünften Tab „Verträge“. Aus #stunden zeigt 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.
  • Step 3: Umsetzen wie unter Files beschrieben.

  • Step 4: Prüfen

    • node <scratchpad>/ui/nav.mjs meldet 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.css
    • loadAboCard heißt künftig loadContracts.
    • Neu sind renderContractsView(view, data) und renderSideCard(card, data).
    • renderAboAccountView heißt künftig renderHoursView.
    • ABO_FEATURES und ABO_SIDE_FEATURES fallen weg, die Leistungen kommen aus data.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.mjs

    • hosting:
      • 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-hosting bzw. #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.mjs meldet 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. renderAboOrderView heißt künftig renderOrderView(view, data, productKey), gerendert in view-bestellen-hosting und view-bestellen-wartung.
  • Test: ui/bestellen.mjs

Interfaces:

  • Consumes: data.products, data.offers[productKey], POST /api/abo/checkout mit productKey

  • 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.mjs meldet 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/preview und POST /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.
    • 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-gekuendigt und zahlungsproblem: Plus und Minus sind nicht zu sehen.
    • Run: Erwartet: FAIL.
  • Step 2: Umsetzen

  • Step 3: Prüfen: ui/speicher.mjs meldet PASS auf Desktop und Handy (Tastatur-Simulation per setViewportSize).

  • 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/preview und POST /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.
    • Szenario wartung: #wechsel führt zu „Verträge“.
    • Run: Erwartet: FAIL.
  • Step 2: Umsetzen

  • Step 3: Prüfen: ui/wechsel.mjs meldet 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 (neu getCustomerAbo(customerId), ruft GET /api/admin/customers/:id/abo auf), 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 gemocktem adminFetch, 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)

Rechtsrunde (28.09.2026), zusätzlich beim Livegang:

  1. Die AGB der Website bekommen „Stand “ (Entscheidung 19).
  2. Mit dem neuen takeover.conditionsVersion wird der Schlüssel in TAKEOVER_CONDITIONS_SHA256 (test/legalTexts.test.js) mitgezogen.
  3. Den Auftragsverarbeitungsvertrag von UptimeRobot (uptimerobot.com/dpa) herunterladen und ablegen, denn die Datenschutzerklärung bietet eine Kopie an. Die Angaben zu UptimeRobot noch einmal prüfen.
  4. Am Stripe-Webhook-Endpunkt customer.subscription.pending_update_applied, customer.subscription.pending_update_expired und invoice.payment_failed freischalten (Task 9). Das nur mit Freigabe des Nutzers.
  5. Die jährliche Erinnerung an die Löschfristen einrichten (Task 21, Lauf im Januar). Ein Cron-Eintrag auf dem Server nur mit Freigabe des Nutzers.

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.conditionsVersion ebenfalls.
    • PDFs neu erzeugen mit scripts/build-legal-pdfs.sh.
    • Run: npm test. Erwartet: # fail 0, einschließlich Version 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 test
    

    Erwartet: # 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.mjs
    

    Erwartet: Fertig., neue Zeilen für termStartAt, storageUnits, storagePaidUnits, storagePaidPeriodEnd, aboOffers.productKey und die Collection contractEvents.

  • 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:main
    

    Danach 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.mjs --anlegen
    docker exec -w /app webklar-kundenbereich-server node scripts/stripe-products.mjs --pruefen
    

    Erwartet: 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:main
    

    Der Deploy dauert etwa 20 s. Danach liefern https://webklar.com/hosting-abo und https://webklar.com/wartungs-abo den 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-stunden-wartung:test
    
  • Step 10: Live prüfen:

    • https://project.webklar.com/abo-bedingungen.html zeigt „Version 4“.
    • …/WEBklar-Abo-Vertragsunterlagen.pdf liefert 200. …/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf liefert 200 und enthält Version 3.
    • …/vertragsunterlagen/v3/abo-bedingungen.html liefert 200.
    • Die Datenschutzerklärung zeigt den neuen Stand und UptimeRobot.
    • GET /api/abo ohne 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 11a: Zusätzliche Schritte aus Rechtsrunde und Gesamtprüfung (30.09.2026)

    • Zu Step 1: Mit takeover.conditionsVersion auch den Schlüssel in TAKEOVER_CONDITIONS_SHA256 (test/legalTexts.test.js) umstellen. Den Stand in datenschutz.html und abo-bedingungen.html auf das Freigabedatum setzen. termsVersion muss auf -v4 enden. Danach die PDFs neu erzeugen.
    • Vor Step 6: Stripe-Testmodus mit einer eigenen Instanz mit Testschlüssel, nicht im Produktions-Container. Zu prüfen:
      • Wechsel mit 3-D-Secure-Testkarte: Metadaten und termStartAt bleiben bis zur Zahlung unverändert (F28).
      • Die gehostete Rechnungsseite bei einem ausstehenden Wechsel.
      • Rabatte in der Wechsel-Vorschau.
      • Speicher im flexiblen Abrechnungsmodus: erst Minus, dann Plus über die bezahlte Menge; die Vorschau mit der kommenden Rechnung vergleichen.
    • Vor oder mit Step 7: Die Info-Mail an die Bestandskunden der Fassung 3 geht raus (beliebig viele Postfächer, Zusatzspeicher nur mit Zustimmung). Grund: --anlegen ändert die Beschreibung des Wartungs-Produkts in Stripe.
    • Stripe-Webhook-Endpunkt (Dashboard, mit Freigabe des Nutzers): customer.subscription.pending_update_applied und customer.subscription.pending_update_expired freischalten. Außerdem prüfen, dass diese Ereignisse aktiv sind:
      • checkout.session.completed
      • invoice.paid
      • invoice.payment_failed
      • customer.subscription.created
      • customer.subscription.updated
      • customer.subscription.deleted
    • Pilot: Die Routen für Speicher und Wechsel haben keinen Pilot-Filter. Bevor ABO_PILOT_EMAILS nicht * ist, entscheidet der Nutzer, ob das so bleiben soll.
    • Nach Step 6: Über „Als Kunde ansehen“ einen echten Kunden mit Wartungs-Abo nach Fassung 3 prüfen: Verträge, Stundenkonto, Fristen. Außerdem muss der Admin-Reiter „E-Mail-Speicher“ laden (Sortierung nach dueAt in echtem Appwrite).
    • UptimeRobot: Den Auftragsverarbeitungsvertrag ablegen und die Angaben zu UptimeRobot prüfen.
    • Löschfristen (mit Freigabe des Nutzers): Cron nur mit LOESCHFRISTEN=mail im Januar. apply erst nach Durchsicht des Berichts, von Hand.
    • AGB der Website: Das Stand-Datum setzen.
  • Step 12: Memory aktualisieren: Plan 2 live, Commits, offene Punkte.