From 9350fccd7763e6896c8fcfd257b2a03bec756a2e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:50:03 +0000 Subject: [PATCH] =?UTF-8?q?K02/K03:=20Hinweis=20auf=20das=20Ende=20von=20A?= =?UTF-8?q?nalyse=20und=20Terminplaner=20nur=20f=C3=BCr=20Kunden,=20die=20?= =?UTF-8?q?eine=20von=20beiden=20haben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Absatz "Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm." stand bei jeder Kündigung eines Wartungs-Abos. Nach der Entscheidung vom 05.10.2026 steht er nur noch, wenn der Kunde ein Funktionsdokument (portalFeatures, featureKey analyse oder termine) in einem Zustand ausser off hat. - abo.js: hatAboFunktionen(sub) liest über listCustomerFeatureDocs (wie die übrigen Funktions- Abfragen) die Dokumente des Kunden des Abos; ein Lesefehler zählt als ja (eine Logzeile, die Mail geht in jedem Fall raus). Der Block funktionen in K02 (Kündigung im Stripe-Kundenportal) und K03 (Kündigungsseite, weiterhin nur beim Abo) ist jetzt isWartung(sub) && hatAboFunktionen(sub); beim Hosting-Abo wird gar nicht erst gelesen. Die Vorlagentexte bleiben unverändert. - Tests: K02 und K03 ohne Dokument, mit aktiver Analyse, Terminplaner in setup/ordered/disabled, nur off (und ohne Zustand), News oder Dokument eines anderen Kunden, Lesefehler (Hinweis bleibt, Mail geht raus, eine Logzeile) und Hosting-Abo (kein Hinweis, kein Lesen). Co-Authored-By: Claude Sonnet 5.5 --- server/routes/abo.js | 32 ++++++-- test/publicForms.test.js | 171 +++++++++++++++++++++++++++++++-------- 2 files changed, 164 insertions(+), 39 deletions(-) diff --git a/server/routes/abo.js b/server/routes/abo.js index 72527c4..d2814ac 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -18,7 +18,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' -import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' +import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' import { featureConfig } from '../services/featureRegistry.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { @@ -68,6 +68,8 @@ function minTermEndOf(sub) { // Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6) const productKeyOf = (sub) => sub?.productKey || 'wartung' const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung' +// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner +const ABO_FUNKTIONEN = ['analyse', 'termine'] // {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo" const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo') // {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne @@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => { // Stripe-Webhook // --------------------------------------------------------------------------- +/** + * Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse + * oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und + * K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId, + * wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde + * wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus. + */ +async function hatAboFunktionen(sub) { + try { + if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID') + const docs = await listCustomerFeatureDocs(sub.customerId) + return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off') + } catch (err) { + console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`) + return true + } +} + /** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */ async function customerNameOf(sub, fallback = '') { const customer = sub?.customerId @@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) { vertragsende: fmtDate(endsAt), vertragsende_paragraph: endParagraphOf(sub), }, - // Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo - blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) }, + // Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim + // Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen) + blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) }, }) await updateDocument(config.collections.subscriptions, sub.$id, { cancelRequestedAt: receivedAt.toISOString(), @@ -947,8 +968,9 @@ router.post('/public/cancel', async (req, res) => { abo: !storage, // Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo) wartung: !storage && isWartung(sub), - // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung) - funktionen: !storage && isWartung(sub), + // Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung), + // nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen) + funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)), automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe', sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe', ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich', diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 5f67713..ac19cf1 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1,4 +1,4 @@ -import { test, after, beforeEach } from 'node:test' +import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { startAppwriteMock } from './support/appwriteMock.js' @@ -149,52 +149,155 @@ test('K03/K04/T08/T09: Produkt des Kunden, Vertragsende nach § 11 der Fassung 4 assert.ok(k03u.text.includes('§ 11 der Vertragsbedingungen'), k03u.text) }) -// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden +// Terminplaner TP2 (E3): Website-Analyse und Terminplaner gehören zum Wartungs-Abo; K03 und K02 sagen, dass sie mit ihm enden. +// Entscheidung 2026-10-05: nur, wenn der Kunde eine von beiden hat (Funktionsdokument analyse oder termine in einem Zustand ausser off). const FUNKTIONEN_SATZ = 'Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm.' const WARTUNG_V4 = { productKey: 'wartung', termsVersion: config.abo.termsVersion } +const WARTUNG = { patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } } const k02 = () => bySubject('Bestätigung Ihrer Kündigung') - -test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos', async () => { - await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) +const FUNKTION_DOC = 'fk_funktion' +/** Fuehrt fn mit einem Funktionsdokument von c1 aus (doc null: keins; doc ueberschreibt die Vorgaben) und raeumt es danach auf. */ +async function mitFunktion(doc, fn) { + if (doc) aw.seed('portalFeatures', { $id: FUNKTION_DOC, customerId: 'c1', projectId: 'fk_p', featureKey: 'analyse', state: 'active', enabled: doc.state === 'active', ...doc }) + try { + return await fn() + } finally { + await deleteDocument(config.collections.portalFeatures, FUNKTION_DOC).catch(() => {}) + } +} +/** K03: Abo-Kuendigung ueber die Kuendigungsseite; liefert die Mail. setup = Parameter von resetSubH. */ +async function kuendigeMitK03(setup = {}) { + await resetSubH(setup) assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) - assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text) - assert.ok(k03().text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), k03().text) + assert.ok(k03(), 'K03') + return k03() +} +const gekuendigt = (id) => ({ + id, + object: 'event', + type: 'customer.subscription.updated', + created: Math.floor(Date.now() / 1000), + data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } }, +}) +let k02Nummer = 0 +/** K02: Kuendigung im Stripe-Kundenportal (Webhook); liefert die Mail. setup = Parameter von resetSubH. */ +async function kuendigeMitK02(setup = {}) { + await resetSubH({ patch: { ...setup.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...setup.stripe } }) + const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(k02(), 'K02') + return k02() +} - // Hosting-Abo: weder Analyse noch Terminplaner - await resetSubH() - assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) - assert.doesNotMatch(k03().text, /Terminplaner/) +test('K03: Hinweis auf das Ende der Funktionen nur bei der Kündigung eines Wartungs-Abos mit Analyse oder Terminplaner', async () => { + await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => { + const mail = await kuendigeMitK03(WARTUNG) + assert.ok(mail.text.includes(FUNKTIONEN_SATZ), mail.text) + assert.ok(mail.text.includes('Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.'), mail.text) - // nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis - await resetSubH({ units: 3, patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) - assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200) - assert.doesNotMatch(k03().text, /Terminplaner/) + // Hosting-Abo: weder Analyse noch Terminplaner + assert.doesNotMatch((await kuendigeMitK03()).text, /Terminplaner/) - // unbekannte E-Mail (kein Abo gefunden): kein Hinweis - await resetSubH({ patch: WARTUNG_V4, stripe: { productKey: 'wartung', metadata: { termsVersion: config.abo.termsVersion } } }) - assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200) - assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/) + // nur der Zusatzspeicher eines Wartungs-Abos gekündigt: das Abo läuft weiter, kein Hinweis + await resetSubH({ units: 3, ...WARTUNG }) + assert.equal((await cancel({ contract: 'zusatzspeicher' })).status, 200) + assert.doesNotMatch(k03().text, /Terminplaner/) + + // unbekannte E-Mail (kein Abo gefunden): kein Hinweis + await resetSubH(WARTUNG) + assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich', email: 'niemand@example.invalid' })).status, 200) + assert.doesNotMatch(bySubject('Eingangsbestätigung Ihrer Kündigung').text, /Terminplaner/) + }) }) -test('K02: Kündigung im Stripe-Kundenportal, Hinweis auf das Ende der Funktionen nur beim Wartungs-Abo', async () => { - const gekuendigt = (id) => ({ - id, - object: 'event', - type: 'customer.subscription.updated', - created: Math.floor(Date.now() / 1000), - data: { object: { id: 'sub_h', object: 'subscription', cancel_at_period_end: true, metadata: {} }, previous_attributes: { cancel_at_period_end: false } }, +// (a) bis (d): welche Funktionsdokumente des Kunden den Hinweis ausloesen +const FUNKTION_FAELLE = [ + ['ohne Funktionsdokument', null, false], + ['aktive Analyse', { featureKey: 'analyse', state: 'active' }, true], + ['Terminplaner im Zustand setup', { featureKey: 'termine', state: 'setup' }, true], + ['Terminplaner bestellt (ordered)', { featureKey: 'termine', state: 'ordered' }, true], + ['abgeschalteter Terminplaner (disabled)', { featureKey: 'termine', state: 'disabled' }, true], + ['nur ein Terminplaner-Dokument im Zustand off', { featureKey: 'termine', state: 'off' }, false], + ['nur ein Analyse-Dokument im Zustand off', { featureKey: 'analyse', state: 'off' }, false], + ['Dokument ohne Zustand (gilt wie off)', { featureKey: 'termine', state: '' }, false], + ['aktive News-Pinnwand (keine Funktion des Hinweises)', { featureKey: 'news', state: 'active' }, false], + ['aktive Analyse eines anderen Kunden', { featureKey: 'analyse', state: 'active', customerId: 'c2' }, false], +] +for (const [name, doc, erwartet] of FUNKTION_FAELLE) { + const wort = erwartet ? 'mit' : 'ohne' + test(`K03, Wartungs-Abo: ${name} => ${wort} Hinweis`, async () => { + const mail = await mitFunktion(doc, () => kuendigeMitK03(WARTUNG)) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text) + assert.equal(/Terminplaner/.test(mail.text), erwartet) + assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung') }) - for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) { - const patch = productKey === 'wartung' ? WARTUNG_V4 : {} - await resetSubH({ patch: { ...patch, cancelAtPeriodEnd: true }, stripe: { productKey, cancel_at_period_end: true, metadata: productKey === 'wartung' ? { termsVersion: config.abo.termsVersion } : {} } }) - const res = await postWebhook(gekuendigt(`evt_k02_${productKey}`)) - assert.equal(res.status, 200, JSON.stringify(res.body)) - assert.ok(k02(), `${productKey}: K02`) - assert.equal(k02().text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${k02().text}`) - assert.equal(/Terminplaner/.test(k02().text), mitHinweis, productKey) + test(`K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: ${name} => ${wort} Hinweis`, async () => { + const mail = await mitFunktion(doc, () => kuendigeMitK02(WARTUNG)) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), erwartet, mail.text) + assert.equal(/Terminplaner/.test(mail.text), erwartet) + assert.equal(/Terminplaner/.test(mail.html), erwartet, 'auch in der HTML-Fassung') + }) +} + +// (f) Hosting-Abo: nie ein Hinweis, auch nicht mit aktiver Analyse; die Funktionen werden dafuer gar nicht erst gelesen +test('K03 und K02, Hosting-Abo: kein Hinweis (auch mit aktiver Analyse), Funktionsdokumente werden nicht gelesen', async () => { + await mitFunktion({ featureKey: 'analyse', state: 'active' }, async () => { + aw.resetListCalls() + const mail03 = await kuendigeMitK03() + assert.doesNotMatch(mail03.text, /Terminplaner/) + const mail02 = await kuendigeMitK02({ stripe: { productKey: 'hosting' } }) + assert.doesNotMatch(mail02.text, /Terminplaner/) + assert.equal(aw.listCalls('portalFeatures').length, 0, 'kein Lesen der Funktionen beim Hosting-Abo') + }) +}) + +// (e) Lesefehler: lieber informieren als nicht; die Mail geht in jedem Fall raus, eine Zeile im Log +test('K03, Wartungs-Abo: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => { + const log = mock.method(console, 'error', () => {}) + try { + await resetSubH(WARTUNG) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' }) + assert.equal((await cancel({ contract: 'abo', kind: 'ordentlich' })).status, 200) + assert.ok(k03(), 'K03 geht trotzdem raus') + assert.ok(k03().text.includes(FUNKTIONEN_SATZ), k03().text) + assert.ok(t08(), 'T08 ans Team') + const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen')) + assert.equal(zeilen.length, 1, zeilen.join('\n')) + assert.match(zeilen[0], /\[abo\] Funktionen/) + } finally { + log.mock.restore() } }) +test('K02, Wartungs-Abo im Stripe-Kundenportal gekündigt: Funktionen nicht lesbar => Hinweis bleibt, Mail geht raus, eine Logzeile', async () => { + const log = mock.method(console, 'error', () => {}) + try { + await resetSubH({ patch: { ...WARTUNG.patch, cancelAtPeriodEnd: true }, stripe: { cancel_at_period_end: true, ...WARTUNG.stripe } }) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau: Funktionen nicht lesbar' }) + const res = await postWebhook(gekuendigt(`evt_k02_${++k02Nummer}`)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(k02(), 'K02 geht trotzdem raus') + assert.ok(k02().text.includes(FUNKTIONEN_SATZ), k02().text) + assert.ok(aw.docs('subscriptions').get('sub_h').cancelRequestedAt, 'die Kündigung wird danach noch vermerkt') + const zeilen = log.mock.calls.map((c) => String(c.arguments[0])).filter((z) => z.includes('Funktionen')) + assert.equal(zeilen.length, 1, zeilen.join('\n')) + assert.ok(!log.mock.calls.some((c) => String(c.arguments[0]).includes('Kuendigungsbestaetigung')), 'kein Fehler der Bestätigung') + } finally { + log.mock.restore() + } +}) + +test('K02: Kündigung im Stripe-Kundenportal, Hinweis nur beim Wartungs-Abo mit Funktion (Hosting-Abo nie)', async () => { + await mitFunktion({ featureKey: 'termine', state: 'active' }, async () => { + for (const [productKey, mitHinweis] of [['wartung', true], ['hosting', false]]) { + const setup = productKey === 'wartung' ? WARTUNG : { patch: {}, stripe: { productKey, metadata: {} } } + const mail = await kuendigeMitK02(setup) + assert.equal(mail.text.includes(FUNKTIONEN_SATZ), mitHinweis, `${productKey}\n${mail.text}`) + assert.equal(/Terminplaner/.test(mail.text), mitHinweis, productKey) + } + }) +}) + test('2 von 3 Paketen ergibt Menge 1', async () => { await resetSubH({ units: 3 }) const res = await cancel({ contract: 'zusatzspeicher', packages: '2' })