diff --git a/public/funktionen.js b/public/funktionen.js index 855a5e8..1fc87dd 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -267,6 +267,17 @@ function zeitenFrist(aboEndeAt) { return new Date(Date.UTC(y, m - 1, d + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' }) } +// Behalten (30 €) ist nur bezahlbar, wenn das Wartungs-Abo wirklich beendet ist: inklusive false UND grund kein_abo (Z12, R20, R25). nicht_aktiv und +// gekuendigt heissen, dass das Abo laeuft (Probezeit/Zahlung offen bzw. Kuendigung vorgemerkt): 'abo', nie ein Bezahlknopf. Doppelt zu behaltenLage in +// zeiten-bestellen.js (funktionen.js importiert dort nichts); beide pinnen die Tests. 'bezahlen' | 'abo' | 'inklusive' | 'unklar' (Abo nicht lesbar) +function zeitenBehaltenLage(angebot) { + if (angebot.inklusive === true) return 'inklusive' + if (angebot.inklusive !== false) return 'unklar' + if (angebot.grund === 'kein_abo') return 'bezahlen' + return angebot.grund === 'nicht_aktiv' || angebot.grund === 'gekuendigt' ? 'abo' : 'unklar' +} +const ZEITEN_ABO_WIEDER = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.' + // 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst '' const zeitenTag = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof iso === 'string' ? iso : '') @@ -296,11 +307,14 @@ function zeitenTile(projects, angebot = {}) { } if (target.zeitenLeer === true) extra += `

${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}

` action = knopf('Zeiten bearbeiten') - // Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten; nur wenn sicher ist, dass das Abo nicht wieder laeuft (inklusive false) - const frist = f.aboEndeAt && angebot.inklusive === false ? zeitenFrist(f.aboEndeAt) : '' + // Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten, aber nur ohne Abo (kein_abo); laeuft es (nicht_aktiv, gekuendigt), steht der Hinweis ohne Knopf da + const lage = f.aboEndeAt ? zeitenBehaltenLage(angebot) : '' + const frist = lage === 'bezahlen' ? zeitenFrist(f.aboEndeAt) : '' if (frist) { extra += `

Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.

` action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' }) + } else if (lage === 'abo') { + extra += `

${ZEITEN_ABO_WIEDER}

` } } else if (f.state === 'ordered') { pill = ['Zahlung offen', 'setup'] @@ -313,9 +327,11 @@ function zeitenTile(projects, angebot = {}) { const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay()) extra = `

${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}

` if (daten && zeitenTag(f.loeschungAm)) extra += `

Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.

` - if (daten && angebot.inklusive === true) extra += '

Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).

' - else if (daten && angebot.inklusive !== false) extra += `

${ZEITEN_NICHT_PRUEFBAR}

` - else action = daten ? knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) : chat + const lage = daten ? zeitenBehaltenLage(angebot) : '' + if (!daten) action = chat + else if (lage === 'bezahlen') action = knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) + else if (lage === 'unklar') extra += `

${ZEITEN_NICHT_PRUEFBAR}

` + else extra += '

Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).

' } else if (angebot.inklusive === true) { extra = '

Im Wartungs-Abo enthalten.

' action = knopf('Einschalten') diff --git a/public/zeiten-bestellen.js b/public/zeiten-bestellen.js index 561f2cd..b0b3fd0 100644 --- a/public/zeiten-bestellen.js +++ b/public/zeiten-bestellen.js @@ -47,6 +47,19 @@ const text = (wert) => (typeof wert === 'string' ? wert : '') const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && typeof wert.knopf === 'string' && Array.isArray(wert.items) const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen') +/** + * Wo steht das Wartungs-Abo, wenn das Abo-Ende erkannt ist (Behalten)? 'bezahlen': kein Abo (inklusive false UND grund kein_abo), nur dann gibt es + * Behalten gegen 30 €. 'abo': das Abo laeuft (grund nicht_aktiv = Probezeit/Zahlung offen, gekuendigt = Kuendigung vorgemerkt; der Server antwortet + * beim Behalten dort mit 409 „Abo läuft wieder“): nie ein Bezahlknopf (Z12, R20, R25). 'inklusive': enthalten (inklusive true). 'unklar': Abo nicht + * lesbar oder unbekannter Grund (fail-closed, kein Bezahlknopf auf Verdacht). Doppelt zu zeitenBehaltenLage in funktionen.js (die Kachel importiert nichts). + */ +export function behaltenLage(angebot) { + if (angebot?.inklusive === true) return 'inklusive' + if (angebot?.inklusive !== false) return 'unklar' + if (angebot.grund === 'kein_abo') return 'bezahlen' + return angebot.grund === 'nicht_aktiv' || angebot.grund === 'gekuendigt' ? 'abo' : 'unklar' +} + /** Adresse einer Unterlage: https oder /rechtliches/ (sicherLink) oder eine Datei unter /vertragsunterlagen/ dieses Portals, sonst ''. */ function unterlagenLink(url) { const sicher = sicherLink(url) @@ -105,7 +118,11 @@ export function renderZeitenBestellen(root, { ctx = {}, angebot = {}, projekt = if (angebot.inklusive === null || angebot.inklusive === undefined) { return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck)) } - if (modus === 'behalten' && angebot.inklusive === true) { + if (modus === 'behalten' && behaltenLage(angebot) !== 'bezahlen') { + // das Abo laeuft (wieder) oder ist nicht sicher lesbar: nichts zu bezahlen, nie ein Bezahlknopf auf Verdacht + if (behaltenLage(angebot) === 'unklar') { + return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck)) + } return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'abo-laeuft' }, h('h3', null, 'Dein Wartungs-Abo läuft wieder'), h('p', null, 'Öffnungszeiten live ist darin enthalten – du musst nichts bezahlen.'), zurueck)) } if (modus === 'behalten') variante = 'behalten' @@ -265,7 +282,7 @@ function dankeText(danke, feature) { * projekt = Eintrag aus GET /api/funktionen (projectId, zeitenLeer), feature = projekt.features.zeiten (state, einbau, ticketWoid, orderedAt, * invoiceUrl, aboEndeAt, disabledReason, loeschungAm), angebot = zeiten (inklusive, preis), kundenansicht = Sitzung in der Kundenansicht, * danke = 'bestellt' | 'behalten' | 'eingeschaltet' (Rueckkehr von Stripe oder Einschalten), meldung = Text am Knopf. - * behalten() oeffnet die Behalten-Seite (Knopf „Für 30 € behalten“ nur, wenn sicher ist, dass das Abo nicht wieder laeuft: inklusive false); + * behalten() oeffnet die Behalten-Seite (Knopf „Für 30 € behalten“ nur ohne Abo: behaltenLage 'bezahlen', also inklusive false UND grund kein_abo; laeuft das Abo, steht nur der Hinweis); * weiter({ bestellen, meldung }) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (bestellen: es gibt keine offene Zahlungsseite mehr, die Ansicht * zeigt wieder die Bestellseite). */ @@ -273,7 +290,6 @@ export function zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, da const state = text(feature?.state) || 'off' const [pilleText, pilleArt] = PILLE[state] || PILLE.setup const preis = text(angebot?.preis) || '30 €' - const inklusive = angebot?.inklusive const leer = projekt?.zeitenLeer === true const ticket = feature?.ticketWoid const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== '' @@ -309,17 +325,21 @@ export function zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, da // Behalten (30 €): in Einrichtung/aktiv nach dem erkannten Abo-Ende, abgeschaltet solange die Eingaben noch da sind const behaltenKnopf = (beschriftung) => h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'behalten', disabled: sperre, onclick: () => { if (typeof behalten === 'function') behalten() } }, beschriftung) - const behaltenBox = frist && inklusive === false - ? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'behalten' }, + const lage = behaltenLage(angebot) + let behaltenBox = null + if (frist && lage === 'bezahlen') { + behaltenBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'behalten' }, h('span', null, `Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${preis}.`), behaltenKnopf(`Für ${preis} behalten`)) - : null + } else if (frist && lage === 'abo') { + behaltenBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.')) + } let abgeschaltetBox = null - if (daten && inklusive === false) { + if (daten && lage === 'bezahlen') { abgeschaltetBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'wieder-einschalten' }, h('span', null, `Deine Eingaben bleiben${tagText(feature?.loeschungAm) ? ` bis ${tagText(feature.loeschungAm)}` : ''} gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`), behaltenKnopf(`Für ${preis} wieder einschalten`)) - } else if (daten && inklusive === true) { + } else if (daten && (lage === 'inklusive' || lage === 'abo')) { abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).')) } else if (daten) { abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'nicht-pruefbar' }, h('span', null, NICHT_PRUEFBAR)) diff --git a/public/zeiten.js b/public/zeiten.js index 24c81be..7e199cd 100644 --- a/public/zeiten.js +++ b/public/zeiten.js @@ -17,7 +17,7 @@ Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */ import { h, infoKarte, request } from './termine-dom.js' -import { renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js' +import { behaltenLage, renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten const auswahl = { projectId: '' } @@ -44,10 +44,13 @@ function pickProjekt(projects) { || projects[0] } -/** Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben. */ -function behaltenMoeglich(projekt) { +/** + * Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben, + * und nur ohne Abo (behaltenLage 'bezahlen': inklusive false UND grund kein_abo); laeuft das Abo (nicht_aktiv, gekuendigt, inklusive), gibt es nichts zu bezahlen. + */ +function behaltenMoeglich(projekt, angebot) { const feature = projekt?.features?.zeiten - if (!feature) return false + if (!feature || behaltenLage(angebot) !== 'bezahlen') return false if (feature.state === 'setup' || feature.state === 'active') return Boolean(feature.aboEndeAt) return feature.state === 'disabled' && feature.disabledReason === 'abo_ende' && projekt.zeitenLeer === false } @@ -86,7 +89,7 @@ export async function initZeiten(root, { projectId } = {}) { const key = stand(s.projekt, s.angebot) const editorDa = root.dataset.zeitenKey === key && root.querySelector('.zt-editor') && S?.root === root if (editorDa && !rueckkehr.bestellt && !rueckkehr.behalten) { - if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt)) zeigeBehalten(S) + if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt, S.angebot)) zeigeBehalten(S) return } S = s @@ -199,7 +202,7 @@ function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } = return } kopf.replaceChildren(statusKarte()) - if (weg === 'behalten' && behaltenMoeglich(projekt)) zeigeBehalten(s) + if (weg === 'behalten' && behaltenMoeglich(projekt, s.angebot)) zeigeBehalten(s) } /** Behalten-Seite (30 €) statt der Statuskarte; der Editor darunter bleibt, nur versteckt, damit nichts verloren geht. „Zurück“ zeigt wieder die Karte. */ diff --git a/test/funktionenZeitenTile.test.js b/test/funktionenZeitenTile.test.js index f456e60..ac2518a 100644 --- a/test/funktionenZeitenTile.test.js +++ b/test/funktionenZeitenTile.test.js @@ -211,6 +211,47 @@ test('aboEndeAt: Behalten-Hinweis mit Datum', async () => { assert.doesNotMatch(nurText(kaputt), /bis NaN|Invalid|undefined/) }) +// Fix Runde 1: Behalten (30 €) gibt es nur ohne Abo (inklusive false UND grund kein_abo). nicht_aktiv und gekuendigt heissen, dass das Abo läuft (R20/R25). +test('aboEndeAt bei laufendem Abo (nicht_aktiv, gekuendigt): „Abo läuft wieder“, nie ein Bezahlknopf', async () => { + for (const grund of ['nicht_aktiv', 'gekuendigt']) { + for (const state of ['active', 'setup']) { + const k = kachel((await render([projekt('p1', state, { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html) + const was = `${grund}/${state}` + assert.match(nurText(k), /Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten\./, was) + assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'], was) + assert.doesNotMatch(nurText(k), /ist beendet|behalte die Funktion|für einmalig|Für 30 €|30 €/, was) + assert.doesNotMatch(k, /data-zeiten-weg/, was) + } + } + // unbekannter Grund oder kein Grund (false, null): nicht auf Verdacht bezahlen lassen + for (const grund of [null, 'unbekannt']) { + const k = kachel((await render([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html) + assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'], String(grund)) + assert.doesNotMatch(nurText(k), /Für 30 €|behalte die Funktion/, String(grund)) + } + // Gegenprobe: ohne Abo (kein_abo) bleibt es beim Angebot + const ohne = kachel((await render([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], { zeiten: ANGEBOT(false, 'kein_abo') })).html) + assert.deepEqual(knoepfe(ohne), ['Zeiten bearbeiten', 'Für 30 € behalten']) +}) + +test('disabled abo_ende mit Daten bei laufendem Abo (nicht_aktiv, gekuendigt): kein „Für 30 € wieder einschalten“, stattdessen „Abo läuft wieder“', async () => { + const feature = { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' } + for (const grund of ['nicht_aktiv', 'gekuendigt']) { + const k = kachel((await render([projekt('p1', 'disabled', feature, { zeitenLeer: false })], { zeiten: ANGEBOT(false, grund) })).html) + assert.doesNotMatch(k, /Für 30 €|data-zeiten-weg|