K02/K03: Hinweis auf das Ende von Analyse und Terminplaner nur für Kunden, die eine von beiden haben
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c7cd26532
commit
9350fccd77
2 files changed
+164
-39
No files matched your search
+137
-34
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user