From 989e7be6407d6a573166cd754fb1a13eceb648d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:02:10 +0000 Subject: [PATCH] Kundenansicht neu P2: Reiter Abo und Abo-Auswahl Co-Authored-By: Claude Sonnet 5.5 --- public/neu/vertraege-abo.js | 174 ++++++++++++++++++++++++++++++++++++ test/knAnsichtAbo.test.js | 54 +++++++++++ 2 files changed, 228 insertions(+) create mode 100644 public/neu/vertraege-abo.js create mode 100644 test/knAnsichtAbo.test.js diff --git a/public/neu/vertraege-abo.js b/public/neu/vertraege-abo.js new file mode 100644 index 0000000..dc27517 --- /dev/null +++ b/public/neu/vertraege-abo.js @@ -0,0 +1,174 @@ +/* Kundenansicht neu (P2): Reiter Abo in Verträge. Mit laufendem Abo der Vertrag samt Einstiegen (Wechsel, Gutschrift, Widerruf), + ohne Abo die Auswahl der beiden Abos. Texte der Vertragsseite stammen aus app.js, in der Du-Form. */ + +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, safeUrl } from './ansicht-hilfen.js' + +const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs +const TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.' +const TEXT_ZAHLUNG = 'Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisiere deine Zahlungsart, damit deine Website online bleibt.' +const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus nicht möglich.' +const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.' + +const gekuendigt = (sub) => Boolean(sub?.cancelAtPeriodEnd || sub?.cancelAt) +const produktName = (daten, key) => { + const p = (daten?.products || []).find((x) => x.key === key) + return (p?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '') +} +const produkt = (daten, key) => (daten?.products || []).find((x) => x.key === key) || null + +/** 'ohne' | 'beendet' | 'laeuft' | 'gekuendigt'. */ +export function aboZustand(daten) { + const sub = daten?.subscription + if (!sub) return 'ohne' + if (!sub.active) return 'beendet' + return gekuendigt(sub) ? 'gekuendigt' : 'laeuft' +} + +/** Zeile zur Abrechnung (app.js aboDateLine). */ +export function aboZeile(sub) { + if (gekuendigt(sub)) return `Leistungen bis ${fmtDatum(sub.cancelAt || sub.currentPeriodEnd)}` + if (sub.serviceStartsAt) return `Beginn und erste Abbuchung am ${fmtDatum(sub.serviceStartsAt)}` + return `nächste Abbuchung ${fmtDatum(sub.currentPeriodEnd)}` +} + +/** Laufzeit-Hinweis (app.js aboTermText, § 8 der Bedingungen). */ +export function laufzeitText(sub) { + if (sub.cancelAtPeriodEnd) return `Gekündigt zum ${fmtDatum(sub.cancelAt || sub.currentPeriodEnd)}. Bis dahin bleiben alle Leistungen verfügbar.` + if (sub.minTermEnd && new Date(sub.minTermEnd) > new Date()) return `Mindestlaufzeit bis ${fmtDatum(sub.minTermEnd)}. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.` + return 'Monatlich kündbar zum Ende des Abrechnungsmonats.' +} + +const leistungen = (features) => h('ul', { class: 'kn-leistungen' }, (features || []).map(([titel, detail]) => h('li', null, titel, detail ? h('small', null, detail) : null))) +const fakt = (titel, wert) => h('div', null, h('dt', null, titel), h('dd', null, wert)) + +// "Zahlungsart aktualisieren": Stripe-Kundenportal; im Ansichtsmodus gesperrt +function zahlungsKnopf(viewAs) { + const fehler = h('p', { class: K.fehler, hidden: true }) + const knopf = h('button', { type: 'button', class: K.primaer, disabled: viewAs }, 'Zahlungsart aktualisieren') + knopf.addEventListener('click', async () => { + if (viewAs) return + knopf.disabled = true + fehler.hidden = true + try { + const { url } = await api('/api/abo/portal', { method: 'POST', body: {} }) + const ziel = safeUrl(url) + if (!ziel) throw new Error('Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.') + window.location.href = ziel + } catch (f) { + fehler.textContent = f.message + fehler.hidden = false + knopf.disabled = false + } + }) + return [knopf, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null, fehler] // ohne Umhüllung: der Knopf ist das Element mit diesem Text +} + +function wechselKarte(daten, sub) { + if (sub.productKey !== 'hosting') return null + const wartung = produkt(daten, 'wartung') + if (sub.switchPending) { + const url = safeUrl(sub.switchPending.paymentUrl) + return h('section', { class: 'kn-sektion kn-wechsel' }, + h('h3', null, 'Dein Wechsel wartet auf die Zahlung'), + h('p', { class: 'kn-hinweis' }, `Dein Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreib uns gern im Chat, wenn du Hilfe brauchst.'}`), + url ? h('a', { class: K.primaer, href: url }, 'Jetzt bezahlen') : null) + } + if (gekuendigt(sub)) { + return h('section', { class: 'kn-sektion kn-wechsel' }, h('h3', null, 'Wartungs-Abo'), + h('p', { class: 'kn-hinweis' }, 'Dein Abo ist gekündigt. Bitte schreib uns im Chat, wenn du zum Wartungs-Abo wechseln möchtest.')) + } + if (!sub.canSwitch || !wartung) return null + return h('section', { class: 'kn-sektion kn-wechsel' }, + h('h3', null, 'Mehr als Hosting? Das Wartungs-Abo'), + h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(wartung.amountCents / 100)), ' pro Monat'), + h('p', { class: 'kn-hinweis' }, `${TEXT_UST} Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.`), + h('a', { class: K.primaer, ...zuAnsicht('wechsel') }, 'Zum Wartungs-Abo wechseln')) +} + +function vertragsBlock(daten, sub, viewAs) { + const zustand = aboZustand(daten) + const pille = zustand === 'gekuendigt' ? K.pille.grau : sub.paymentProblem ? K.pille.orange : K.pille.gruen + const pilleText = zustand === 'gekuendigt' ? 'gekündigt' : sub.paymentProblem ? 'Zahlung offen' : 'aktiv' + const mail = daten.mailStorage || {} + const einheiten = Number(sub.storage?.units || 0) + const gb = (Number(mail.includedGb) || 0) + einheiten * (Number(mail.gbPerUnit) || 0) + const katalog = produkt(daten, sub.productKey) + return h('section', { class: 'kn-sektion kn-vertrag', id: 'kn-vertrag' }, + h('div', { class: 'kn-vertrag-kopf' }, h('h2', { class: K.abschnitt }, 'Mein Vertrag'), h('span', { class: pille }, pilleText)), + h('h3', null, produktName(daten, sub.productKey)), + h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(sub.priceAmount / 100)), ' pro Monat'), + h('p', { class: 'kn-hinweis' }, TEXT_UST), + h('dl', { class: 'kn-fakten' }, + fakt(gekuendigt(sub) ? 'Leistungen' : 'Abrechnung', aboZeile(sub)), + fakt('Laufzeit', laufzeitText(sub)), + sub.offerText ? fakt('Angebot', sub.offerText) : null, + fakt('E-Mail-Speicher', `${gb} GB${einheiten ? ` (${einheiten} Zusatzpaket${einheiten === 1 ? '' : 'e'})` : ''}`)), + leistungen(katalog?.features), + h('p', { class: 'kn-links' }, + h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), ' · ', + h('a', { href: sub.termsPdfUrl || '/WEBklar-Abo-Vertragsunterlagen.pdf', target: '_blank', rel: 'noopener noreferrer' }, 'als PDF')), + sub.withdrawalOpen ? h('p', { class: 'kn-widerruf' }, h('a', { href: '/widerrufen.html' }, `Vertrag widerrufen · bis ${fmtTagMonat(sub.withdrawalUntil)}`)) : null, + sub.paymentProblem ? zahlungsKnopf(viewAs) : null) +} + +function gutschriftLink() { + const badge = h('span', { class: 'kn-badge', 'data-badge': true, hidden: true }) + const link = h('a', { class: `${K.knopf} kn-gutschrift-link`, ...zuAnsicht('takeover') }, 'Übernahmegutschrift beantragen') + return { link, setzeZahl(n) { if (n > 0) { badge.textContent = String(n); badge.hidden = false; link.append(' ', badge) } } } +} + +function auswahlKarte(daten, key) { + const p = produkt(daten, key) + if (!p) return null + const angebot = daten.offers?.[key] + const link = daten.available + ? h('a', { class: K.primaer, ...zuAnsicht(`bestellen-${key}`) }, 'Abo abschließen') + : h('a', { class: `${K.primaer} kn-gesperrt`, 'aria-disabled': 'true', role: 'link' }, 'Abo abschließen') + return h('article', { class: 'kn-abo-karte', 'data-produkt': key }, + h('h3', null, produktName(daten, key)), + angebot ? h('p', { class: 'kn-angebot' }, h('span', { class: K.pille.violett }, 'Dein persönliches Angebot'), h('span', { class: 'kn-angebot-text' }, `${angebot.text}${angebot.validUntil ? ` · gültig bis ${fmtDatum(angebot.validUntil)}` : ''}`)) : null, + h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(p.amountCents / 100)), ' pro Monat'), + h('p', { class: 'kn-hinweis' }, `${TEXT_UST} ${p.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.`), + leistungen(p.features), + link) +} + +function auswahl(daten, beendet) { + return h('div', { class: 'kn-abo-auswahl' }, + beendet ? h('p', { class: K.meldung }, 'Dein bisheriges Abo ist beendet. Du kannst jederzeit ein neues abschließen.') : null, + daten.available ? null : h('p', { class: K.meldung }, TEXT_NICHT_VERFUEGBAR), + h('div', { class: 'kn-abo-karten' }, auswahlKarte(daten, 'hosting'), auswahlKarte(daten, 'wartung')), + h('p', { class: 'kn-hinweis' }, 'Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die ', h('a', { href: '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), '.')) +} + +/** Reiter-Inhalt Abo; daten ist die Antwort von GET /api/abo. */ +export async function zeigeAbo(box, { daten, me } = {}) { + const nr = (laeufe.get(box) ?? 0) + 1 + laeufe.set(box, nr) + const aktuell = () => laeufe.get(box) === nr + const viewAs = me?.viewAs === true + const sub = daten?.subscription ?? null + const zustand = aboZustand(daten) + const gutschrift = sub ? gutschriftLink() : null + + const teile = [] + if (sub?.active && sub.paymentProblem) teile.push(h('p', { class: `${K.meldung} kn-meldung-warn` }, TEXT_ZAHLUNG)) + if (sub?.active && sub.serviceStartsAt) teile.push(h('p', { class: K.meldung }, `Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDatum(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.`)) + if (sub?.active) { + teile.push(vertragsBlock(daten, sub, viewAs), wechselKarte(daten, sub)) + } else { + teile.push(auswahl(daten ?? {}, zustand === 'beendet')) + } + if (gutschrift) teile.push(h('p', { class: 'kn-gutschrift' }, gutschrift.link)) + leeren(box).append(...teile.filter(Boolean)) + + // Zahl der offenen Rückfragen zur Übernahmegutschrift: ein Ausfall lässt nur die Zahl weg + if (!gutschrift) return + try { + const antwort = await api('/api/abo/takeover-credits') + if (!aktuell()) return + gutschrift.setzeZahl((Array.isArray(antwort?.requests) ? antwort.requests : []).filter((r) => r?.status === 'rueckfrage').length) + } catch { /* ohne Zahl bleibt der Einstieg stehen */ } +} diff --git a/test/knAnsichtAbo.test.js b/test/knAnsichtAbo.test.js new file mode 100644 index 0000000..b01833f --- /dev/null +++ b/test/knAnsichtAbo.test.js @@ -0,0 +1,54 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigeAbo, aboZustand, aboZeile, laufzeitText } from '../public/neu/vertraege-abo.js' +import { mit, me, abo, aboAntwort, angebot, neueWurzel } from './support/knDaten.js' + +const WARTUNG = aboAntwort({ subscription: abo({ priceAmount: 5000, currentPeriodEnd: '2026-11-16T00:00:00.000Z', minTermEnd: '2099-06-01T00:00:00.000Z', termsUrl: '/abo-bedingungen.html', termsPdfUrl: '/WEBklar-Abo-Vertragsunterlagen.pdf' }) }) +const zeige = async (fx, daten, m = me()) => { const b = neueWurzel(fx); await zeigeAbo(b, { daten, me: m }); await fx.ruhe(); return b } +const SRV = { 'GET /api/abo/takeover-credits': { requests: [{ status: 'rueckfrage' }, { status: 'offen' }], limits: {} } } +test('aboZustand', () => { + assert.deepEqual([aboAntwort(), aboAntwort({ subscription: abo({ active: false }) }), WARTUNG, aboAntwort({ subscription: abo({ cancelAtPeriodEnd: true }) })].map(aboZustand), ['ohne', 'beendet', 'laeuft', 'gekuendigt']) +}) +test('Abo Wartung: Vertrag, Preis, Laufzeit, Bedingungen, Gutschrift-Einstieg mit Rückfragen', mit(SRV, async (fx) => { + const b = await zeige(fx, WARTUNG); const t = fx.text(b) + for (const s of ['Mein Vertrag', 'aktiv', 'Wartungs-Abo', 'Gemäß § 19 UStG ohne Umsatzsteuer.', 'nächste Abbuchung 16.11.2026', 'Mindestlaufzeit bis 01.06.2099. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.']) assert.ok(t.includes(s), s) + assert.equal(fx.finde(b, 'a[href="/WEBklar-Abo-Vertragsunterlagen.pdf"]').textContent, 'als PDF') + assert.equal(fx.finde(b, 'a[href="#gutschrift"][data-view="takeover"] [data-badge]').textContent, '1') +})) +test('Abo Hosting: Wechsel-Einstieg; wartende Zahlung nur mit sicherer Adresse; gekündigt ohne Wechsel-Karte', mit(SRV, async (fx) => { + const hosting = (o) => aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true, ...o }) }) + const w = fx.finde(await zeige(fx, hosting({})), 'a[data-view="wechsel"]'); assert.deepEqual([w.textContent, w.getAttribute('href')], ['Zum Wartungs-Abo wechseln', '#wechsel']) + const sicher = await zeige(fx, hosting({ switchPending: { since: 'x', paymentUrl: 'https://pay.stripe.com/x' } })); assert.equal(fx.finde(sicher, 'a[href="https://pay.stripe.com/x"]').textContent, 'Jetzt bezahlen') + const unsicher = await zeige(fx, hosting({ switchPending: { since: 'x', paymentUrl: 'javascript:alert(1)' } })); assert.match(fx.text(unsicher), /Schreib uns gern im Chat, wenn du Hilfe brauchst\./) + const gekuendigt = await zeige(fx, hosting({ cancelAtPeriodEnd: true })); assert.equal(fx.finde(gekuendigt, 'a[data-view="wechsel"]'), null); assert.match(fx.text(gekuendigt), /Dein Abo ist gekündigt\. Bitte schreib uns im Chat, wenn du zum Wartungs-Abo wechseln möchtest\./) +})) +test('Zahlungsproblem: Knopf öffnet das Stripe-Portal; Widerruf nur in der Frist; Ansichtsmodus sperrt', mit({ ...SRV, 'POST /api/abo/portal': { url: 'https://billing.stripe.com/x' } }, async (fx) => { + const daten = aboAntwort({ subscription: abo({ paymentProblem: true, withdrawalOpen: true, withdrawalUntil: '2026-10-17T21:59:00.000Z' }) }) + const b = await zeige(fx, daten); assert.match(fx.text(b), /Bitte aktualisiere deine Zahlungsart, damit deine Website online bleibt\./); assert.match(fx.finde(b, 'a[href="/widerrufen.html"]').textContent, /^Vertrag widerrufen · bis 17\.10\./) + await fx.klick(fx.finde(b, (e) => e.textContent === 'Zahlungsart aktualisieren')); assert.equal(fx.window.location.href, 'https://billing.stripe.com/x') + const ansicht = await zeige(fx, daten, me({ viewAs: true })); assert.equal(fx.finde(ansicht, (e) => e.textContent === 'Zahlungsart aktualisieren').hasAttribute('disabled'), true) + assert.equal(fx.finde(await zeige(fx, WARTUNG), 'a[href="/widerrufen.html"]'), null) +})) +test('Ohne Abo: zwei Produkte mit Link zur Bestellung, nicht verfügbar sperrt, beendet sagt es', mit(SRV, async (fx) => { + const b = await zeige(fx, aboAntwort()); assert.deepEqual(['hosting', 'wartung'].map((k) => fx.finde(b, `[data-produkt="${k}"] a`).getAttribute('href')), ['#bestellen-hosting', '#bestellen-wartung']) + assert.match(fx.text(b), /20,00 €/) + const aus = await zeige(fx, aboAntwort({ available: false })); assert.equal(fx.finde(aus, '[data-produkt="hosting"] a').getAttribute('aria-disabled'), 'true'); assert.match(fx.text(aus), /schreib uns gern im Chat\./) + assert.match(fx.text(await zeige(fx, aboAntwort({ subscription: abo({ active: false }) }))), /Dein bisheriges Abo ist beendet\. Du kannst jederzeit ein neues abschließen\./) +})) +test('Abo: Ausfall der Rückfragen-Abfrage lässt den Einstieg stehen', mit({ 'GET /api/abo/takeover-credits': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { + const b = await zeige(fx, WARTUNG); assert.ok(fx.finde(b, 'a[href="#gutschrift"]')); assert.equal(fx.finde(b, '[data-badge]'), null) +})) +test('Texte aus app.js: Zeile und Laufzeit', () => { + assert.equal(aboZeile(abo({ cancelAtPeriodEnd: true, cancelAt: '2026-12-01T00:00:00.000Z' })), 'Leistungen bis 01.12.2026') + assert.equal(aboZeile(abo({ serviceStartsAt: '2026-10-30T00:00:00.000Z' })), 'Beginn und erste Abbuchung am 30.10.2026') + assert.equal(laufzeitText(abo({ cancelAtPeriodEnd: true, currentPeriodEnd: '2026-12-01T00:00:00.000Z' })), 'Gekündigt zum 01.12.2026. Bis dahin bleiben alle Leistungen verfügbar.') + assert.equal(laufzeitText(abo({ minTermEnd: '2000-01-01T00:00:00.000Z' })), 'Monatlich kündbar zum Ende des Abrechnungsmonats.') +}) +test('Ohne Abo: Angebot mit Etikett und Gültigkeit; Fußsatz mit Link; Zusatzspeicher und Beginn nach Frist beim laufenden Abo', mit(SRV, async (fx) => { + const b = await zeige(fx, aboAntwort({ offers: { hosting: null, wartung: angebot({ text: 'Erste 2 Monate je 25,00 €', validUntil: '2026-11-30T00:00:00.000Z' }) } })) + assert.match(fx.text(fx.finde(b, '[data-produkt="wartung"]')), /Dein persönliches AngebotErste 2 Monate je 25,00 € · gültig bis 30\.11\.2026/) + assert.doesNotMatch(fx.text(fx.finde(b, '[data-produkt="hosting"]')), /Dein persönliches Angebot/) + assert.equal(fx.finde(b, 'a[href="/abo-bedingungen.html"]').textContent, 'Vertragsbedingungen'); assert.match(fx.text(b), /Alle Preise gemäß § 19 UStG ohne Umsatzsteuer\. Es gelten die Vertragsbedingungen\./) + const l = await zeige(fx, aboAntwort({ subscription: abo({ storage: { units: 2, pendingSetup: false, pendingReduction: null }, serviceStartsAt: '2026-10-30T00:00:00.000Z' }) })) + assert.match(fx.text(l), /E-Mail-Speicher15 GB \(2 Zusatzpakete\)/); assert.match(fx.text(l), /Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am 30\.10\.2026\. Dann wird auch zum ersten Mal abgebucht\./) +}))