diff --git a/public/neu/ansicht-wechsel.js b/public/neu/ansicht-wechsel.js index da2424e..802695a 100644 --- a/public/neu/ansicht-wechsel.js +++ b/public/neu/ansicht-wechsel.js @@ -1,18 +1,22 @@ -/* Kundenansicht neu (P2): Wechsel vom Hosting-Abo ins Wartungs-Abo (#wechsel), Text und Logik 1:1 aus app.js renderSwitchView - (Fassung 5, Sie-Form, C14). Vorschau vom Server, Bestellübersicht unmittelbar über dem Knopf (§ 312j BGB), Zustimmung, Bestellung. - Der Wechsel wirkt erst mit der Zahlung der Wechselrechnung (Bedingungen § 9 Abs. 3). Das Wortlaut-Orakel prüft den sichtbaren Text. - Neu: die gemerkte Funktion (E14, C5): kam der Kunde aus dem Einschalten einer Funktion, geht es danach zurück zu den Funktionen. */ +/* Kundenansicht neu (P7): Wechsel vom Hosting-Abo ins Wartungs-Abo (#wechsel) im schlanken Muster der Spec 5.6, Fassung 6. + Reihenfolge ohne Klappe: Block „Was du bestellst“ (Vorschau vom Server), eine Linkzeile, bei Privatkunden das Sofortbeginn-Häkchen mit + Sperrzeile, der Knopf „Zahlungspflichtig bestellen“ (§ 312j BGB), darunter die Kundenart-Zeile. Der Wechsel wirkt erst mit der Zahlung + der Wechselrechnung (Bedingungen § 9 Abs. 3). Die Sätze kommen vom Server (GET /api/abo → bestelltexte); der Server entscheidet die + Kundenart selbst, der Client sendet nie kundenart. Ein Betrieb braucht kein Häkchen. + Die gemerkte Funktion (E14, C5): kam der Kunde aus dem Einschalten einer Funktion, geht es danach zurück zu den Funktionen. + Der Titel kommt von der Hülle (meldeTitel); die Seite hat keine eigene h1. */ import { api } from './api.js' import { h, leeren } from './dom.js' import { K, ladeAbo, wechselFunktionAus, sichereAdresse, merkeHinweis, navigiere, meldeTitel, meldeZaehlerNeu, zuAnsicht, fmtEuro, fmtDatum, berlinTag, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' +import { baueBlock, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon } from './bestell-bausteine.js' const SCHLUESSEL = 'kn-wechsel-funktion' -const BEDINGUNGEN = '/abo-bedingungen.html' const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.' +const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.' +const KNOPF = 'Zahlungspflichtig bestellen' +const UST = 'gemäß § 19 UStG ohne Umsatzsteuer' const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs -const link = (href, text) => h('a', { href, target: '_blank', rel: 'noopener' }, text) -const reihe = (titel, ...inhalt) => h('tr', null, h('td', null, titel), h('td', null, ...inhalt)) /** Die von P3 gemerkte Funktion { key, projectId } oder null (kaputter oder fremder Wert, kein sessionStorage). */ export function gemerkteFunktion() { @@ -52,56 +56,70 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) { const sub = stand.subscription const wartung = (stand.products || []).find((p) => p.key === 'wartung') || null if (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch || !wartung) { - leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, 'Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter ', h('a', zuAnsicht('vertraege'), 'Verträge'), '.'))) + leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, 'Ein Wechsel ist derzeit nicht möglich. Details findest du unter ', h('a', zuAnsicht('vertraege'), 'Verträge'), '.'))) navigiere('#vertraege', { ersetzen: true }) return } + // Ohne Wortlaut vom Server gibt es keinen Bestellknopf und keine Vorschau + if (!hatWortlaut(stand, 'wechsel')) { + leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR))) + return + } + const T = stand.bestelltexte + const kundenart = kundenartVon(me, stand) + const privat = kundenart !== 'unternehmen' const units = Number(sub.storage?.units) || 0 const mail = stand.mailStorage || {} const unitCents = Number(mail.unitPriceCents) || 0 - const lauf = { busy: false, preview: null, error: '', loading: true, token: 0, terms: false, early: false, fehler: '', zeichne: null } + const lauf = { busy: false, preview: null, error: '', loading: true, token: 0, early: false, fehler: '', fehlerKlasse: null, zeichne: null } aktiv = lauf + /** Zeilen des Blocks aus der Vorschau des Servers (Leistung, Wartungs-Abo, Zusatzspeicher, Gutschrift, fällig, Abbuchungstag, Laufzeit, Beginn). */ + const zeilenVon = (p) => { + const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents + const tag = berlinTag(p.nextChargeAt) + return [ + { label: 'Leistung', text: 'Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.' }, + { label: 'Wartungs-Abo', text: `${fmtEuro(monthly / 100)} pro Monat (Gesamtpreis, ${UST}); enthalten sind Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat` }, + ...(units > 0 + ? [{ label: 'Zusatzspeicher', text: `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (${UST}), insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat` }] + : []), + ...((Number(p.creditCents) || 0) > 0 + ? [{ label: 'Gutschrift für nicht genutzte Tage', text: `−${fmtEuro(p.creditCents / 100)} aus dem bereits bezahlten Hosting-Abo (${UST})` }] + : []), + { label: 'Heute fällig', text: `${fmtEuro(p.amountDueCents / 100)} (${UST})` }, + { label: 'Neuer Abbuchungstag', text: `${tag ? `Ab dem Wechsel jeweils am ${tag.t}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDatum(p.nextChargeAt)}` }, + { label: 'Laufzeit', text: `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)} (12 Monate ab dem Wechsel), danach monatlich kündbar` }, + { label: 'Beginn', text: 'Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei deiner Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.' }, + ] + } + const zeichne = () => { const p = lauf.preview - const rows = [] - if (p) { - const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents - rows.push(reihe('Wartungs-Abo', h('strong', null, `${fmtEuro(monthly / 100)} pro Monat`), ' (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat')) - if (units > 0) { - rows.push(reihe('Zusatzspeicher', `${units} Paket${units === 1 ? '' : 'e'} (+${units * (Number(mail.gbPerUnit) || 0)} GB) bleiben gebucht: ${fmtEuro(units * unitCents / 100)} pro Monat zusätzlich (gemäß § 19 UStG ohne Umsatzsteuer), insgesamt ${fmtEuro((monthly + units * unitCents) / 100)} pro Monat`)) - } - if ((Number(p.creditCents) || 0) > 0) { - rows.push(reihe('Gutschrift für nicht genutzte Tage', `−${fmtEuro(p.creditCents / 100)} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)`)) - } - rows.push(reihe('Heute fällig', h('strong', null, fmtEuro(p.amountDueCents / 100)), ' (gemäß § 19 UStG ohne Umsatzsteuer)')) - const tag = berlinTag(p.nextChargeAt) - rows.push(reihe('Neuer Abbuchungstag', `${tag ? `Ab dem Wechsel jeweils am ${tag.t}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDatum(p.nextChargeAt)}`)) - rows.push(reihe('Laufzeit', h('strong', null, `Mindestlaufzeit bis ${fmtDatum(p.minTermEnd)}`), ' (12 Monate ab dem Wechsel), danach monatlich kündbar')) - rows.push(reihe('Beginn', 'Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei Ihrer Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.')) - } const uebersicht = lauf.error ? h('p', { class: K.fehler }, lauf.error) - : lauf.loading || !p ? h('p', { class: 'kn-hinweis' }, 'Ihre Übersicht wird berechnet …') - : h('table', { class: 'kn-zusammenfassung' }, h('tbody', null, rows)) + : lauf.loading || !p ? h('p', { class: 'kn-hinweis' }, 'Deine Übersicht wird berechnet …') + : baueBlock(zeilenVon(p), { id: 'switch-block' }) const sperre = lauf.busy || viewAs - const terms = h('input', { type: 'checkbox', id: 'switch-terms', checked: lauf.terms, disabled: sperre, on: { change: (e) => { lauf.terms = e.currentTarget.checked } } }) - const early = h('input', { type: 'checkbox', id: 'switch-early', checked: lauf.early, disabled: sperre, on: { change: (e) => { lauf.early = e.currentTarget.checked } } }) - leeren(wurzel).append(h('div', { class: 'kn-panel', id: 'switch-order' }, - h('h3', null, 'Ihre Bestellung'), - h('p', { class: 'kn-hinweis' }, 'Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.'), - h('label', { class: 'kn-check' }, terms, - h('span', null, 'Ich habe die ', link(BEDINGUNGEN, 'Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag'), ' gelesen und stimme ihnen zu. Die ', link(`${BEDINGUNGEN}#widerruf`, 'Widerrufsbelehrung'), ' habe ich zur Kenntnis genommen.')), - h('label', { class: 'kn-check' }, early, - h('span', null, h('strong', null, 'Sofortbeginn.'), ' Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB; siehe Hinweise zum Widerruf). Firmenkunden haben kein Widerrufsrecht.')), - h('div', { class: 'kn-uebersicht' }, uebersicht, - h('p', { class: 'kn-hinweis' }, 'Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: ', link(BEDINGUNGEN, 'Vertragsbedingungen'), ' · ', link('/datenschutz.html', 'Datenschutz'))), + const haken = privat + ? h('input', { + type: 'checkbox', id: 'switch-beginn', checked: lauf.early, disabled: sperre, + on: { change: (e) => { lauf.early = e.currentTarget.checked; zeichne(); wurzel.querySelector('#switch-beginn')?.focus() } }, + }) + : null + const gesperrt = privat && !lauf.early + leeren(wurzel).append(h('section', { class: 'kn-panel kn-bestellen', id: 'switch-order' }, + h('h2', null, 'Was du bestellst'), + uebersicht, + baueLinkzeile(T.wechsel.linkzeile, { id: 'switch-linkzeile' }), + haken ? h('label', { class: K.check }, haken, h('span', { id: 'switch-beginn-text' }, T.wechsel.haken)) : null, + gesperrt ? h('p', { class: 'kn-sperrzeile', id: 'switch-sperre' }, T.wechsel.gesperrt) : null, h('p', { class: lauf.fehlerKlasse ?? K.fehler, id: 'switch-error', hidden: !lauf.fehler }, lauf.fehler), h('div', { class: 'kn-aktionen' }, - h('button', { type: 'button', class: K.primaer, id: 'switch-buy', disabled: lauf.busy || viewAs || lauf.loading || !p, on: { click: absenden } }, lauf.busy ? 'Wird bestellt …' : 'Zahlungspflichtig bestellen'), - h('span', { class: 'kn-hinweis' }, 'Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart. Bis zum Klick können Sie abbrechen.'), - viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null))) + h('button', { type: 'button', class: K.primaer, id: 'switch-buy', disabled: lauf.busy || viewAs || lauf.loading || !p || gesperrt, on: { click: absenden } }, lauf.busy ? 'Wird bestellt …' : (T.knopf || KNOPF)), + viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null), + baueKundenartZeile(kundenart, T.kundenart, { id: 'switch-kundenart' }))) } lauf.zeichne = zeichne @@ -127,16 +145,17 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) { async function absenden() { if (viewAs || lauf.busy || !lauf.preview) return + if (privat && !lauf.early) return // ohne Häkchen kein Wechsel; die Sperrzeile steht da lauf.fehler = '' - if (!lauf.terms) { zeigeFehler('Bitte stimmen Sie den Vertragsbedingungen zu.'); return } - if (!lauf.early) { zeigeFehler('Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen.'); return } const p = lauf.preview const funktion = gemerkteFunktion() const gesendet = funktion !== null lauf.busy = true zeichne() try { - const body = { acceptTerms: true, earlyStart: true, termsVersion: stand.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents } + // Der Klick auf den Knopf ist die Zustimmung (acceptTerms). Nie kundenart: ob ein Häkchen nötig ist, entscheidet der Server. + const body = { acceptTerms: true, termsVersion: stand.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents } + if (privat) body.earlyStart = true if (gesendet) body.funktion = funktion const res = await api('/api/abo/switch', { method: 'POST', body }) if (gesendet) vergissFunktion() @@ -149,15 +168,15 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) { } const knopf = wurzel.querySelector('#switch-buy') if (knopf) knopf.textContent = 'Weiter zur Zahlung …' - zeigeFehler('Ihr Wechsel wartet auf die Zahlung. Wir leiten Sie zur Zahlung weiter.', 'kn-meldung') + zeigeFehler('Dein Wechsel wartet auf die Zahlung. Wir leiten dich zur Zahlung weiter.', 'kn-meldung') window.location.href = ziel return } lauf.busy = false const sepa = Boolean(res.subscription?.switchPending) const hinweis = sepa - ? 'Ihr Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen Ihnen den Wechsel per E-Mail.' - : 'Ihr Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.' + ? 'Dein Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen dir den Wechsel per E-Mail.' + : 'Dein Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.' meldeZaehlerNeu('vertraege') // Mit gemerkter Funktion zurück zu den Funktionen (dort öffnet P3 den Einschalt-Ablauf), sonst wie bisher zu den Verträgen if (gesendet) { diff --git a/public/neu/bestell-bausteine.js b/public/neu/bestell-bausteine.js index efcb212..99fbc8a 100644 --- a/public/neu/bestell-bausteine.js +++ b/public/neu/bestell-bausteine.js @@ -72,11 +72,20 @@ export function baueBlock(zeilen, { id } = {}) { return h('dl', { id, class: 'kn-block' }, (zeilen || []).map((z) => h('div', { class: 'kn-block-zeile' }, h('dt', null, z.label), h('dd', { id: z.id }, z.text)))) } -/** true, wenn die Antwort den Wortlaut trägt: status, knopf und die Linkzeile des Abos. Ohne ihn zeigt keine Seite einen Bestellknopf. */ -export function hatWortlaut(daten) { +const nichtLeer = (x) => typeof x === 'string' && x !== '' + +/** + * true, wenn die Antwort den Wortlaut trägt: status, knopf und die Sätze der Seite (art 'abo' | 'wechsel' | 'speicher' | 'gutschrift'). + * Ohne ihn zeigt keine Seite einen Bestellknopf. + */ +export function hatWortlaut(daten, art = 'abo') { const t = daten?.bestelltexte - return Boolean(t && typeof t === 'object' && typeof t.status === 'string' && t.status && typeof t.knopf === 'string' && t.knopf - && typeof t.abo?.linkzeile?.text === 'string' && t.abo.linkzeile.text) + if (!t || typeof t !== 'object' || !nichtLeer(t.status) || !nichtLeer(t.knopf)) return false + if (art === 'gutschrift') return nichtLeer(t.gutschrift?.satz?.text) && nichtLeer(t.gutschrift?.knopf) + if (!nichtLeer(t[art]?.linkzeile?.text)) return false + if (art === 'wechsel') return nichtLeer(t.wechsel.haken) && nichtLeer(t.wechsel.gesperrt) + if (art === 'abo') return nichtLeer(t.abo.haken) + return true } export { K } diff --git a/test/knAnsichtWechsel.test.js b/test/knAnsichtWechsel.test.js index 77394a0..d38fd9c 100644 --- a/test/knAnsichtWechsel.test.js +++ b/test/knAnsichtWechsel.test.js @@ -1,39 +1,131 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { zeigeWechsel, gemerkteFunktion } from '../public/neu/ansicht-wechsel.js' import { nimmHinweis } from '../public/neu/ansicht-hilfen.js' -import { sichtbarerText, ohneKopf, ladeAlteApp } from './support/knWortlautOrakel.js' import { mit, me, abo, aboAntwort, neueWurzel, VORSCHAU } from './support/knDaten.js' +import { NEU, meMit, aboNeu } from './support/knBestellDaten.js' -const alt = ladeAlteApp() -const HOSTING = aboAntwort({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }) }) -const zeigeNeu = async (fx, daten = HOSTING, m = me(), titel = []) => { const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); await zeigeWechsel(w, { route: { bereich: 'vertraege', etage: 'wechsel', query: '' }, me: m, nav: 'push', daten }); await fx.ruhe(); return w } -test('Wechsel: sichtbarer Text wie in app.js, Titel wird gemeldet', mit({ 'POST /api/abo/switch/preview': VORSCHAU }, async (fx) => { - const titel = []; assert.equal(sichtbarerText(await zeigeNeu(fx, HOSTING, me(), titel)), sichtbarerText(ohneKopf(await alt.wechsel(HOSTING, VORSCHAU)))); assert.deepEqual(titel, ['Wechsel ins Wartungs-Abo']) +// Wechselseite im schlanken Muster (Spec 5.6, Fassung 6): Block, Linkzeile, Häkchen nur privat mit Sperrzeile, Knopf, Kundenart-Zeile +const HOSTING = (o = {}) => aboNeu({ subscription: abo({ produkt: 'hosting', canSwitch: true, storage: { units: 2, pendingSetup: false, pendingReduction: null } }), ...o }) +const SRV = { 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.test/x', subscription: abo() } }) } +const IDS = ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart'] +const reihe = (fx, w) => fx.alle(fx.finde(w, '#switch-order'), (el) => IDS.includes(el.id)).map((el) => el.id) +const ROUTE = { bereich: 'vertraege', etage: 'wechsel', query: '' } + +/** Seite zeigen; die gemeldeten Titel (kn:titel) sammelt fx.titel. */ +async function zeige(fx, daten, meDaten) { + fx.titel = fx.titel ?? [] + const w = neueWurzel(fx) + w.addEventListener('kn:titel', (e) => fx.titel.push(e.detail.text)) + await zeigeWechsel(w, { route: ROUTE, me: meDaten, nav: 'push', daten }) + await fx.ruhe() + return w +} + +test('Betrieb: Block, Linkzeile, Knopf, kein Häkchen, Knopf nach der Vorschau frei, Body ohne earlyStart', mit(SRV, async (fx) => { + const w = await zeige(fx, HOSTING(), meMit('unternehmen')) + assert.deepEqual(fx.titel, ['Wechsel ins Wartungs-Abo']) + assert.equal(fx.alle(w, 'h1').length, 0) // Titel kommt als Ereignis, keine eigene h1 + assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-error', 'switch-buy', 'switch-kundenart']) + const t = fx.text(fx.finde(w, '#switch-block')) + for (const s of ['Leistung', 'Wartungs-Abo', 'Zusatzspeicher', '2 Pakete (+10 GB)', 'Gutschrift für nicht genutzte Tage', '−15,00', 'Heute fällig', '30,00', 'Neuer Abbuchungstag', 'Laufzeit', 'Beginn', 'bei deiner Bank']) assert.ok(t.includes(s), s) + assert.equal(fx.finde(w, '#switch-buy').textContent, 'Zahlungspflichtig bestellen') + assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false) + assert.equal(fx.alle(w, 'details, dialog, input[type="checkbox"]').length, 0) + assert.equal(fx.text(fx.finde(w, '#switch-kundenart')), 'Du bestellst als Betrieb · Stimmt nicht? Schreib uns im Chat') + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, termsVersion: NEU, prorationDate: VORSCHAU.prorationDate, amountDueCents: VORSCHAU.amountDueCents }) + assert.equal(fx.window.location.href, 'https://pay.stripe.test/x') + assert.match(fx.text(w), /Dein Wechsel wartet auf die Zahlung\. Wir leiten dich zur Zahlung weiter\./) })) + +test('Privat und leer: Knopf gesperrt mit Sperrzeile, nach dem Häkchen frei, Body mit earlyStart true', mit(SRV, async (fx) => { + for (const m of [meMit('privat'), meMit('')]) { + const w = await zeige(fx, HOSTING(), m) + assert.deepEqual(reihe(fx, w), ['switch-block', 'switch-linkzeile', 'switch-beginn', 'switch-sperre', 'switch-error', 'switch-buy', 'switch-kundenart']) + assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) + assert.equal(fx.text(fx.finde(w, '#switch-sperre')), HOSTING().bestelltexte.wechsel.gesperrt) + assert.equal(fx.text(fx.finde(w, '#switch-beginn-text')), HOSTING().bestelltexte.wechsel.haken) + assert.equal(fx.finde(w, '#switch-beginn').checked, false) + await fx.klick(fx.finde(w, '#switch-beginn')) + assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), false) + assert.equal(fx.finde(w, '#switch-sperre'), null) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.deepEqual(fx.fetchLog.findLast((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: NEU, prorationDate: VORSCHAU.prorationDate, amountDueCents: VORSCHAU.amountDueCents }) + } +})) + +test('Das Häkchen bleibt beim Neuzeichnen erhalten und lässt sich wieder abwählen', mit(SRV, async (fx) => { + const w = await zeige(fx, HOSTING(), meMit('privat')) + await fx.klick(fx.finde(w, '#switch-beginn')) + assert.equal(fx.finde(w, '#switch-beginn').checked, true) + await fx.klick(fx.finde(w, '#switch-beginn')) + assert.equal(fx.finde(w, '#switch-beginn').checked, false) + assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) + assert.ok(fx.finde(w, '#switch-sperre')) +})) + +test('Der Body enthält nie kundenart, unternehmer oder startChoice', mit(SRV, async (fx) => { + for (const m of [meMit('unternehmen'), meMit('privat')]) { + const w = await zeige(fx, HOSTING(), m) + if (fx.finde(w, '#switch-beginn')) await fx.klick(fx.finde(w, '#switch-beginn')) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.deepEqual(Object.keys(fx.fetchLog.findLast((a) => a.url === '/api/abo/switch').body).filter((k) => /kundenart|startChoice|unternehmer/i.test(k)), []) + } +})) + test('Wechsel nicht möglich: Satz und Rückweg zu Verträgen', mit({}, async (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` } - const w = await zeigeNeu(fx, aboAntwort({ subscription: abo({ produkt: 'wartung' }) })) - assert.match(fx.text(w), /Ein Wechsel ist derzeit nicht möglich\. Details finden Sie unter Verträge\./); assert.equal(fx.window.location.hash, '#vertraege') + const w = await zeige(fx, aboNeu({ subscription: abo({ produkt: 'wartung' }) }), meMit('privat')) + assert.match(fx.text(w), /Ein Wechsel ist derzeit nicht möglich\. Details findest du unter Verträge\./) + assert.equal(fx.window.location.hash, '#vertraege') })) -test('Wechsel: Prüfungen wörtlich, Body mit gemerkter Funktion, Schlüssel danach weg', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'https://pay.stripe.com/x', subscription: abo() } }) }, async (fx) => { - const speicher = new Map(); globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) } + +test('Ohne Wortlaut oder ohne Sätze des Wechsels: nur ein Satz, kein Knopf, keine Vorschau-Abfrage', mit(SRV, async (fx) => { + const ohneWechsel = HOSTING() + delete ohneWechsel.bestelltexte.wechsel + for (const d of [aboAntwort({ termsVersion: NEU, subscription: abo({ produkt: 'hosting', canSwitch: true }) }), ohneWechsel]) { + const w = await zeige(fx, d, meMit('unternehmen')) + assert.match(fx.text(w), /Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat\./) + assert.equal(fx.finde(w, '#switch-buy'), null) + } + assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 0) +})) + +test('Unsichere Linkadressen werden Text', mit(SRV, async (fx) => { + const boese = HOSTING() + boese.bestelltexte.wechsel.linkzeile.urls = ['javascript:alert(1)', '//evil.example'] + assert.equal(fx.alle(await zeige(fx, boese, meMit('unternehmen')), '#switch-linkzeile a[href]').length, 0) +})) + +test('Prüfungen: Body mit gemerkter Funktion, Schlüssel danach weg', mit(SRV, async (fx) => { + const speicher = new Map() + globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) } try { - const w = await zeigeNeu(fx) - await fx.klick(fx.finde(w, '#switch-buy')); assert.match(fx.text(w), /Bitte stimmen Sie den Vertragsbedingungen zu\./) - await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-buy')); assert.match(fx.text(w), /Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen\./) - await fx.klick(fx.finde(w, '#switch-early')); speicher.set('kn-wechsel-funktion', JSON.stringify({ key: 'termine', projectId: 'p1' })) - await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe() - assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: '2026-09-30-v5', prorationDate: 1760000000, amountDueCents: 3000, funktion: { key: 'termine', projectId: 'p1' } }) - assert.equal(speicher.has('kn-wechsel-funktion'), false); assert.equal(fx.window.location.href, 'https://pay.stripe.com/x'); assert.match(fx.text(w), /Ihr Wechsel wartet auf die Zahlung\. Wir leiten Sie zur Zahlung weiter\./) - } finally { delete globalThis.sessionStorage } + const w = await zeige(fx, HOSTING(), meMit('privat')) + await fx.klick(fx.finde(w, '#switch-beginn')) + speicher.set('kn-wechsel-funktion', JSON.stringify({ key: 'termine', projectId: 'p1' })) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.deepEqual(fx.fetchLog.find((a) => a.url === '/api/abo/switch').body, { acceptTerms: true, earlyStart: true, termsVersion: NEU, prorationDate: 1760000000, amountDueCents: 3000, funktion: { key: 'termine', projectId: 'p1' } }) + assert.equal(speicher.has('kn-wechsel-funktion'), false) + assert.equal(fx.window.location.href, 'https://pay.stripe.test/x') + } finally { + delete globalThis.sessionStorage + } })) + test('gemerkteFunktion: kaputte oder fremde Werte ergeben null', () => { const wert = (v) => { globalThis.sessionStorage = { getItem: () => v }; try { return gemerkteFunktion() } finally { delete globalThis.sessionStorage } } assert.deepEqual(wert('{"key":"shop","projectId":"p2"}'), { key: 'shop', projectId: 'p2' }) for (const v of ['kaputt', '{"key":"shop"}', '{"key":1,"projectId":"p"}', null]) assert.equal(wert(v), null) assert.equal(gemerkteFunktion(), null) // ohne sessionStorage }) + const FUNKTION = { key: 'termine', projectId: 'p1' } const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }; fx.window.history.pushState = fx.window.history.replaceState } for (const [name, gemerkt, antwort, nachgeladen, ziel] of [ @@ -44,36 +136,89 @@ for (const [name, gemerkt, antwort, nachgeladen, ziel] of [ ]) { test(`Wechsel ohne Zahlungsseite (${name}): Ziel ${ziel} (E14, C5)`, mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { subscription: abo(antwort) } }), - 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'wartung', ...nachgeladen }) }), + 'GET /api/abo': aboNeu({ subscription: abo({ produkt: 'wartung', ...nachgeladen }) }), }, async (fx) => { - WOHIN(fx); const speicher = new Map(gemerkt ? [['kn-wechsel-funktion', JSON.stringify(FUNKTION)]] : []) + WOHIN(fx) + const speicher = new Map(gemerkt ? [['kn-wechsel-funktion', JSON.stringify(FUNKTION)]] : []) globalThis.sessionStorage = { getItem: (k) => speicher.get(k) ?? null, setItem: (k, v) => speicher.set(k, v), removeItem: (k) => speicher.delete(k) } try { nimmHinweis() - const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-early')); await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe() - assert.equal(fx.window.location.hash, ziel); assert.equal(speicher.has('kn-wechsel-funktion'), false) + const w = await zeige(fx, HOSTING(), meMit('unternehmen')) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.equal(fx.window.location.hash, ziel) + assert.equal(speicher.has('kn-wechsel-funktion'), false) assert.equal(nimmHinweis() !== '', ziel === '#vertraege') // bei #funktionen bleibt kein Hinweis für später liegen assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo').length, gemerkt && !antwort.wechselFunktion ? 1 : 0) // höchstens ein Nachladen, nur mit gemerkter Funktion - } finally { delete globalThis.sessionStorage } + } finally { + delete globalThis.sessionStorage + } })) } -test('Wechsel: 409 lädt die Vorschau neu und behält die Meldung; Ansichtsmodus sperrt', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ status: 409, body: { error: 'Betrag hat sich geändert.' } }) }, async (fx) => { - const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-early')); await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe() - assert.match(fx.text(w), /Betrag hat sich geändert\./); assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 2) - const v = await zeigeNeu(fx, HOSTING, me({ viewAs: true })); assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), true); assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./) + +test('Erfolgstexte in du: SEPA und Karte', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { subscription: abo({ switchPending: { type: 'wechsel' } }) } }) }, async (fx) => { + WOHIN(fx) + nimmHinweis() + const w = await zeige(fx, HOSTING(), meMit('unternehmen')) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.equal(nimmHinweis(), 'Dein Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen dir den Wechsel per E-Mail.') + fx.tabelle['POST /api/abo/switch'] = () => ({ body: { subscription: abo() } }) + const v = await zeige(fx, HOSTING(), meMit('unternehmen')) + await fx.klick(fx.finde(v, '#switch-buy')) + await fx.ruhe() + assert.equal(nimmHinweis(), 'Dein Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.') })) -test('Wechsel: Vorschau-Fehler, hängende Bestellung nach pageshow, SEPA-Hinweis; im Ansichtsmodus geht kein POST raus', mit({ - 'POST /api/abo/switch/preview': () => ({ status: 500, body: { error: 'Vorschau kaputt.' } }) }, async (fx) => { - const w = await zeigeNeu(fx); assert.match(fx.text(w), /Vorschau kaputt\./); assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) - fx.tabelle['POST /api/abo/switch/preview'] = VORSCHAU; fx.tabelle['POST /api/abo/switch'] = { paymentUrl: 'https://pay.stripe.com/y', subscription: abo() } - const v = await zeigeNeu(fx); await fx.klick(fx.finde(v, '#switch-terms')); await fx.klick(fx.finde(v, '#switch-early')); await fx.klick(fx.finde(v, '#switch-buy')); await fx.ruhe() + +test('409 lädt die Vorschau neu und behält die Meldung; Ansichtsmodus sperrt Knopf und Häkchen', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ status: 409, body: { error: 'Betrag hat sich geändert.' } }) }, async (fx) => { + const w = await zeige(fx, HOSTING(), meMit('privat')) + await fx.klick(fx.finde(w, '#switch-beginn')) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.match(fx.text(w), /Betrag hat sich geändert\./) + assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo/switch/preview').length, 2) + assert.equal(fx.finde(w, '#switch-beginn').checked, true, 'das Häkchen bleibt nach dem Neuzeichnen') + const v = await zeige(fx, HOSTING(), meMit('privat', { viewAs: true })) + assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), true) + assert.equal(fx.finde(v, '#switch-beginn').hasAttribute('disabled'), true) + assert.match(fx.text(v), /Im Ansichtsmodus nicht möglich\./) +})) + +test('Vorschau-Fehler, hängende Bestellung nach pageshow; im Ansichtsmodus geht kein POST raus', mit({ 'POST /api/abo/switch/preview': () => ({ status: 500, body: { error: 'Vorschau kaputt.' } }) }, async (fx) => { + const w = await zeige(fx, HOSTING(), meMit('unternehmen')) + assert.match(fx.text(w), /Vorschau kaputt\./) + assert.equal(fx.finde(w, '#switch-buy').hasAttribute('disabled'), true) + fx.tabelle['POST /api/abo/switch/preview'] = VORSCHAU + fx.tabelle['POST /api/abo/switch'] = { paymentUrl: 'https://pay.stripe.test/y', subscription: abo() } + const v = await zeige(fx, HOSTING(), meMit('unternehmen')) + await fx.klick(fx.finde(v, '#switch-buy')) + await fx.ruhe() assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), true) // wartet auf die Zahlung - fx.window.dispatchEvent({ type: 'pageshow', persisted: true }); await fx.ruhe() - assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), false); assert.equal(fx.finde(v, '#switch-buy').textContent, 'Zahlungspflichtig bestellen') - const vorher = fx.fetchLog.length; const a = await zeigeNeu(fx, HOSTING, me({ viewAs: true })); await fx.klick(fx.finde(a, '#switch-buy')) + fx.window.dispatchEvent({ type: 'pageshow', persisted: true }) + await fx.ruhe() + assert.equal(fx.finde(v, '#switch-buy').hasAttribute('disabled'), false) + assert.equal(fx.finde(v, '#switch-buy').textContent, 'Zahlungspflichtig bestellen') + const vorher = fx.fetchLog.length + const a = await zeige(fx, HOSTING(), meMit('unternehmen', { viewAs: true })) + await fx.klick(fx.finde(a, '#switch-buy')) assert.deepEqual(fx.fetchLog.slice(vorher).filter((x) => x.url === '/api/abo/switch'), []) })) -test('Wechsel: unsichere Zahlungsadresse wird nicht geöffnet und nicht als Erfolg gezeigt', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'javascript:alert(1)', subscription: abo() } }) }, async (fx) => { - nimmHinweis(); const w = await zeigeNeu(fx); await fx.klick(fx.finde(w, '#switch-terms')); await fx.klick(fx.finde(w, '#switch-early')); await fx.klick(fx.finde(w, '#switch-buy')); await fx.ruhe() - assert.notEqual(fx.window.location.href, 'javascript:alert(1)'); assert.match(fx.text(w), /Die Zahlungsseite konnte nicht geöffnet werden\./); assert.equal(nimmHinweis(), '') + +test('Unsichere Zahlungsadresse wird nicht geöffnet und nicht als Erfolg gezeigt', mit({ 'POST /api/abo/switch/preview': VORSCHAU, 'POST /api/abo/switch': () => ({ body: { paymentUrl: 'javascript:alert(1)', subscription: abo() } }) }, async (fx) => { + nimmHinweis() + const w = await zeige(fx, HOSTING(), meMit('unternehmen')) + await fx.klick(fx.finde(w, '#switch-buy')) + await fx.ruhe() + assert.notEqual(fx.window.location.href, 'javascript:alert(1)') + assert.match(fx.text(w), /Die Zahlungsseite konnte nicht geöffnet werden\./) + assert.equal(nimmHinweis(), '') })) + +test('Quelltext: kein Sie, keine Klappe, alte Ids weg, Body ohne kundenart', () => { + const q = readFileSync(new URL('../public/neu/ansicht-wechsel.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') + assert.doesNotMatch(q, /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/) + assert.doesNotMatch(q, /details|dialog|innerHTML|switch-terms|switch-early/) + const aufruf = q.slice(q.indexOf('const body = {'), q.indexOf("api('/api/abo/switch'")) + assert.ok(aufruf.includes('acceptTerms: true'), 'der Body der Bestellung ist gefunden') + assert.doesNotMatch(aufruf, /startChoice|kundenart|unternehmer/) +}) diff --git a/test/knBestellBausteine.test.js b/test/knBestellBausteine.test.js index cc9b92a..566a9de 100644 --- a/test/knBestellBausteine.test.js +++ b/test/knBestellBausteine.test.js @@ -52,6 +52,14 @@ test('baueLinkzeile, baueKundenartZeile, baueBlock', () => test('hatWortlaut', () => { assert.equal(hatWortlaut(aboNeu()), true) for (const d of [null, undefined, {}, { bestelltexte: {} }, { bestelltexte: { status: 'entwurf', knopf: 'x' } }, { bestelltexte: { status: 'entwurf', abo: { linkzeile: { text: 'a' } } } }]) assert.equal(hatWortlaut(d), false) + for (const art of ['abo', 'wechsel', 'speicher', 'gutschrift']) assert.equal(hatWortlaut(aboNeu(), art), true, art) + // jede Seite prüft ihre eigenen Sätze + const ohne = (teil) => { const d = aboNeu(); delete d.bestelltexte[teil]; return d } + assert.deepEqual(['abo', 'wechsel', 'speicher', 'gutschrift'].map((art) => hatWortlaut(ohne(art), art)), [false, false, false, false]) + assert.equal(hatWortlaut(ohne('wechsel'), 'abo'), true) + const ohneGesperrt = aboNeu() + ohneGesperrt.bestelltexte.wechsel.gesperrt = '' + assert.equal(hatWortlaut(ohneGesperrt, 'wechsel'), false) }) test('Quelltext: keine HTML-Einsetzung, Du-Form, keine h1', () => {