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

80 KiB
Raw 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–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 active und ohne Kündigung (cancelAtPeriodEnd oder cancelAt gesetzt heißt gekündigt).
  • 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.
  • contractEvents.type ist abschluss, wechsel, speicher, kuendigung oder widerruf. status ist offen, erledigt oder widerrufen.
  • 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.
    • 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“.
  3. Gekündigtes Abo, Status trialing oder past_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).
  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.


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

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

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

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, 0, null) auf. 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

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“.

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)

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
    

    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-stundenkonto: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 12: Memory aktualisieren: Plan 2 live, Commits, offene Punkte.