diff --git a/server/routes/abo.js b/server/routes/abo.js index b6c2dfd..0cf1751 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -24,7 +24,7 @@ import { import { assertOwnProject, handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js' import { featureConfig } from '../services/featureRegistry.js' import { istAboModell } from '../services/aboModell.js' -import { ENTHALTEN_IN } from '../services/aboFunktionen.js' +import { ENTHALTEN_IN, aboModellFuerKunde } from '../services/aboFunktionen.js' import { neueAnsicht } from '../services/kundenansicht/schalter.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { @@ -1139,10 +1139,15 @@ async function withdrawOrder(sub, d, receivedAt) { } } if (inTime && type === 'wechsel') { - // Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran) + // Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran). Nicht in der neuen Ansicht + // (Kundenansicht neu, P6): dort enthaelt auch das Hosting-Abo die Analyse, sie bleibt. Ist das nicht lesbar, bleibt sie ebenfalls (der + // taegliche Abo-Check der Analyse raeumt bei Bedarf auf). try { - const { endAnalyseForCustomer } = await import('../services/analyse.js') - await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt }) + const neueAnsichtKunde = await aboModellFuerKunde(sub.customerId) + if (!neueAnsichtKunde) { + const { endAnalyseForCustomer } = await import('../services/analyse.js') + await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt }) + } } catch (err) { console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message) } @@ -1155,6 +1160,14 @@ async function withdrawOrder(sub, d, receivedAt) { } catch (err) { console.error(`[abo] Terminplaner nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message) } + // Funktionen des Abo-Modells (News, Terminplaner, Webshop, Buchungskalender, Oeffnungszeiten live; auch im Projekt eingebaute): ebenso sofort, + // K39 mit dem Hosting-Satz; was misslingt, holt der Tagesabgleich nach + try { + const { beendeWartungsFunktionen } = await import('../services/aboFunktionenAbgleich.js') + await beendeWartungsFunktionen(sub.customerId, { now: receivedAt }) + } catch (err) { + console.error(`[abo] Funktionen des Abo-Modells nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message) + } } const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen') return { diff --git a/server/services/aboFunktionenAbgleich.js b/server/services/aboFunktionenAbgleich.js index 4fa3396..b551b1a 100644 --- a/server/services/aboFunktionenAbgleich.js +++ b/server/services/aboFunktionenAbgleich.js @@ -7,7 +7,7 @@ import { Query, deleteDocument, getDocument, listAllDocuments, updateDocument } import { withLocks } from './keyedLock.js' import { deleteNewsImage } from './newsImages.js' import { listPosts } from './newsPosts.js' -import { activate, disable, stateFields } from './siteFeatures.js' +import { activate, disable, listCustomerFeatureDocs, stateFields } from './siteFeatures.js' import { pruefeNow } from './termine/util.js' import { loeschTag } from './termine/frist.js' import { ladeEinstellung } from './termine/kontext.js' @@ -40,6 +40,11 @@ import { leereCache } from './unterkunft/kontext.js' * (wie bei News, der Vermerk endet damit), unabhaengig vom Abo. Ohne Vermerk bleibt alles. Der Terminplaner wird von * termine/jobs.js selbst auf off gesetzt. * + * Sofortpfad (V11, N27): beendeWartungsFunktionen(customerId) beendet alle Abo-Modell-Funktionen eines Kunden, die nur im Wartungs-Abo stecken, + * sofort, wenn dessen Wechsel ins Wartungs-Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Ohne Abo-Pruefung (der Spiegel + * steht bis zur Handarbeit des Teams noch auf Wartung); die Analyse gehoert zu Hosting und bleibt. Die Mail K39 geht wie im Tagesabgleich + * (meldeBeendet), ausser fuer zeiten: dort sendet der Zeiten-Abgleich K41 samt Ersatz-Ticket. + * * Regeln wie termine/aboAbgleich.js: eine Listenabfrage fuer alle Kunden; das Abo je Kunde einmal fuer die Vorauswahl lesen. Geschrieben * wird nur unter der Sperre feature-admin: nach frischem Lesen von Dokument UND Abo (nie aus der Vorauswahl); die Mail und der * Marker folgen nach der Sperre. Fail-closed: ist etwas nicht lesbar (Liste, Abo, Dokument, Aufbauphase), wird nichts abgeschaltet; Kunde @@ -52,6 +57,7 @@ export const ABGLEICH_KEYS = Object.freeze(['news', 'termine', 'shop', 'unterkun const GRUND = 'abo_ende' const NOTIZ = 'System: Abgeschaltet: Abo beendet' const NOTIZ_WIEDER = 'System: Wieder eingeschaltet: Abo läuft wieder' +const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen' const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g @@ -300,6 +306,50 @@ export async function gleicheAboFunktionenAb(now = new Date()) { return erg } +/** Steckt die Funktion nur im Wartungs-Abo (ENTHALTEN_IN genau ['wartung'])? Die Analyse (auch Hosting) nicht. */ +const nurImWartungsAbo = (key) => Object.hasOwn(ENTHALTEN_IN, key) && ENTHALTEN_IN[key].length === 1 && ENTHALTEN_IN[key][0] === 'wartung' + +/** + * Sofortpfad beim Widerruf des Wechsels ins Wartungs-Abo (V11, N27): alle Dokumente des Abo-Modells (auch projekt) des Kunden im Zustand + * setup oder active, deren Funktion nur im Wartungs-Abo steckt (news, termine, shop, unterkunft, zeiten; nicht analyse), werden sofort + * abgeschaltet (Grund abo_ende, Notiz "Abgeschaltet: Wechsel widerrufen"), je Dokument unter feature-admin: nach frischem Lesen + * und ohne Abo-Pruefung. Danach, ausserhalb der Sperre, K39 mit dem Hosting-Satz (meldeBeendet, art hosting) fuer alle ausser zeiten (dort + * sendet der Zeiten-Abgleich K41 samt Ersatz-Ticket). Ein Fehler bei einem Dokument stoppt die uebrigen nicht; was misslingt, holt der + * Tagesabgleich nach (aboProdukt kennt den widerrufenen Wechsel). Wirft nie; das Log nennt weder Namen noch Adressen. + * @param {string} customerId + * @param {{ now?: Date }} [options] Zeitpunkt des Widerrufs (Marker aboEndeMailAt) + * @returns {Promise<{ beendet: string[] }>} die IDs der abgeschalteten Funktionsdokumente + */ +export async function beendeWartungsFunktionen(customerId, { now = new Date() } = {}) { + const beendet = [] + if (!customerId) return { beendet } + try { + const kandidaten = (await listCustomerFeatureDocs(customerId)).filter( + (doc) => istAboModell(doc) && doc.projectId && (doc.state === 'setup' || doc.state === 'active') && nurImWartungsAbo(doc.featureKey) + ) + for (const doc of kandidaten) { + try { + const abgeschaltet = await withLocks([`feature-admin:${doc.projectId}`], async () => { + const frisch = await getDocument(portalFeatures(), doc.$id) + if (!istAboModell(frisch) || (frisch.state !== 'setup' && frisch.state !== 'active')) return null // das Team war schneller + const aus = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ_WECHSEL }) + if (aus.state !== 'disabled' || aus.disabledReason !== GRUND) return null // jemand war zwischen Lesen und Schreiben schneller + if (aus.featureKey === 'unterkunft' && aus.siteKey) leereCache(aus.siteKey) + return aus + }) + if (!abgeschaltet) continue + beendet.push(abgeschaltet.$id) + if (abgeschaltet.featureKey !== 'zeiten') await meldeBeendet(abgeschaltet, { art: 'hosting', now }) // nach der Sperre; bei Misserfolg wiederholt der Tagesabgleich + } catch (err) { + console.error(`[abo-funktionen] Sofortpfad: Funktion ${doc.$id} nicht vollständig beendet, der Tagesabgleich holt nach: ${fehlerText(err)}`) + } + } + } catch (err) { + console.error(`[abo-funktionen] Sofortpfad: Funktionen eines Kunden nicht lesbar, nichts beendet: ${fehlerText(err)}`) + } + return { beendet } +} + let tagesJobLaeuft = false /** diff --git a/test/publicForms.test.js b/test/publicForms.test.js index 91df2f0..4a6e6f2 100644 --- a/test/publicForms.test.js +++ b/test/publicForms.test.js @@ -1489,3 +1489,39 @@ test('K03: Abo-Modell-Dokument und alte Analyse zusammen: nur der neue Satz; Hos aw.resetListCalls(); assert.equal((await kuendigeMitK03()).text.includes(MODELL_SATZ), false); assert.equal(aw.listCalls('portalFeatures').length, 0) }) }) + +// Kundenansicht P6 (Task 13): der Widerruf des Wechsels beendet die Funktionen des Abo-Modells, die nur im Wartungs-Abo stecken; die Analyse bleibt +const NEU_MODUS = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] } +after(() => { + config.kundenansichtNeu.mode = NEU_MODUS.mode + config.kundenansichtNeu.pilotEmails = NEU_MODUS.pilotEmails +}) +const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] } +const wm = (key, over = {}) => ({ $id: `wm_${key}`, customerId: 'c1', projectId: 'wf_pt', featureKey: key, siteKey: `wmkey${key}`.slice(0, 16).padEnd(16, '2'), state: 'active', enabled: true, activatedAt: daysAgo(10), unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over }) +const wdoc = (id) => aw.docs('portalFeatures').get(id) +const k39 = () => mails.sent.filter((m) => m.subject.startsWith('Deine Funktion wurde beendet')) +const mitModell = async (keys, fn) => { for (const k of keys) aw.seed('portalFeatures', wm(k)); try { return await fn() } finally { for (const k of keys) await deleteDocument(config.collections.portalFeatures, `wm_${k}`).catch(() => {}) } } +test('Widerruf Wechsel, neue Ansicht: alle Wartungs-Funktionen des Abo-Modells enden (abo_ende, K39 Hosting), Bestand und Analyse bleiben', async () => { + pilot(true); await wartungSpiegel(); await wechselIn(3) + aw.seed('portalFeatures', wm('shop', { $id: 'wm_shop_p', projectId: 'wf_p2', unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' })); aw.seed('portalFeatures', wm('news', { $id: 'wm_kauf', projectId: 'wf_p3', unlockedBy: 'kauf', unlockedByPurchase: true, einrichtungAbrechnung: '' })) + await mitModell(['news', 'termine', 'shop', 'unterkunft', 'zeiten'], () => withActiveAnalyse(async (stops) => { + const res = await withdraw({ order: 'wechsel' }); assert.equal(res.status, 200, JSON.stringify(res.body)) + for (const id of ['wm_news', 'wm_termine', 'wm_shop', 'wm_unterkunft', 'wm_zeiten', 'wm_shop_p']) assert.deepEqual([wdoc(id).state, wdoc(id).disabledReason, wdoc(id).adminNote], ['disabled', 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'], id) + assert.equal(wdoc('wm_kauf').state, 'active'); assert.deepEqual(stops, []); assert.equal(wdoc('wf_feat').state, 'active'); assert.equal(bySubject('Deine Website-Analyse wurde beendet'), null) + assert.equal(k39().length, 5); assert.ok(k39().every((m) => /ein Hosting-Abo reicht dafür nicht/.test(m.text))); assert.ok(k04() && t09()) // zeiten: K41 kommt vom Zeiten-Abgleich + })) + for (const id of ['wm_shop_p', 'wm_kauf']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {}) +}) +test('Widerruf Wechsel, alte Ansicht: Analyse endet wie bisher', async () => { + pilot(false); await wartungSpiegel(); await wechselIn(3) + await withActiveAnalyse(async (stops) => { assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.deepEqual(stops, ['wfsitekey0123456789']); assert.equal(wdoc('wf_feat').state, 'off') }) +}) +test('Widerruf Wechsel: scheitert eine Abschaltung, bleibt der Widerruf 200 und die übrigen enden; der Tagesabgleich holt die letzte nach', async (t) => { + const { gleicheAboFunktionenAb } = await import('../server/services/aboFunktionenAbgleich.js') + t.mock.method(console, 'error', () => {}); pilot(true); await wartungSpiegel(); await wechselIn(3) + await mitModell(['news', 'termine', 'shop'], async () => { + aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' }) + assert.equal((await withdraw({ order: 'wechsel' })).status, 200); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 1); assert.equal((await events('wechsel'))[0].status, 'widerrufen') + await gleicheAboFunktionenAb(new Date()); assert.equal(['wm_news', 'wm_termine', 'wm_shop'].filter((id) => wdoc(id).state === 'active').length, 0); assert.equal(k39().length, 3) + }) +})