From 7a03f073bc3cae96a6307f79ddf5b615193b5349 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:46:15 +0000 Subject: [PATCH] Kundenansicht P6: alte Abgleiche lassen Abo-Modell-Dokumente dem neuen Abgleich, Zeiten ohne Behalten Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/aboAbgleich.js | 9 ++- server/services/zeiten/aboAbgleich.js | 38 ++++++++++--- server/services/zeiten/behalten.js | 4 +- test/aboFunktionenKoexistenz.test.js | 78 ++++++++++++++++++++++++++ test/zeitenBehalten.test.js | 7 +++ 5 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 test/aboFunktionenKoexistenz.test.js diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js index 714693b..4d72b8e 100644 --- a/server/services/termine/aboAbgleich.js +++ b/server/services/termine/aboAbgleich.js @@ -1,5 +1,6 @@ import { config } from '../../config.js' import { aboLage, aboMemo } from '../aboStatus.js' +import { istAboModell } from '../aboModell.js' import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendCustomerMail } from '../mailer.js' @@ -33,6 +34,10 @@ import { datumKurz } from './zeit.js' * das Dokument `off` und wird nie automatisch aktiv. Nicht automatisch: 'abo_ende_vor_einrichtung' (die Erstattung laeuft, * das Team entscheidet per WOMS), 'team', 'kunde_geloescht' und Altbestand ohne Grund. * + * Abo-Modell der Kundenansicht neu (P6): Dokumente mit Marker (istAboModell: unlockedBy UND einrichtungAbrechnung aus abo oder projekt) gehoeren + * dem Abgleich der Funktionen des Abo-Modells (aboFunktionenAbgleich.js). Dieser Abgleich und der Sofortpfad ueberspringen sie in jedem Zustand; + * sie zaehlen weder in `geprueft` noch in `ausgenommen` und kosten keine Abo-Abfrage. + * * Sofortpfad (E9): endeTermineFuerKunde(customerId) beendet die Terminplaner eines Kunden sofort, wenn dessen Wechsel ins Wartungs- * Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Dieselben Aktionen wie Schritt 1 und 1b, aber OHNE Abo-Pruefung * (der Spiegel steht noch auf Wartung, das Team setzt ihn von Hand zurueck); die Meldung (meldeAboEnde) ist dieselbe wie im @@ -274,7 +279,7 @@ export async function endeTermineFuerKunde(customerId, { now = new Date() } = {} if (!customerId) return { beendet } try { const kandidaten = (await listCustomerFeatureDocs(customerId)).filter( - (doc) => doc.featureKey === 'termine' && doc.projectId && ['abschalten', 'einrichtung'].includes(aktionFuer(doc)) + (doc) => doc.featureKey === 'termine' && doc.projectId && !istAboModell(doc) && ['abschalten', 'einrichtung'].includes(aktionFuer(doc)) ) for (const doc of kandidaten) { try { @@ -407,7 +412,7 @@ export async function gleicheAboAb(now) { return erg } const nachKunde = new Map() - for (const doc of dokumente) { + for (const doc of dokumente.filter((d) => !istAboModell(d))) { if (!doc.customerId || !doc.projectId) { // ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst erg.fehler += 1 diff --git a/server/services/zeiten/aboAbgleich.js b/server/services/zeiten/aboAbgleich.js index 27aff30..210c572 100644 --- a/server/services/zeiten/aboAbgleich.js +++ b/server/services/zeiten/aboAbgleich.js @@ -1,4 +1,6 @@ import { config } from '../../config.js' +import { ohneAboBisher } from '../aboAufbauphase.js' +import { ABRECHNUNGEN, istAboModell } from '../aboModell.js' import { aboLage, aboMemo } from '../aboStatus.js' import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' @@ -17,8 +19,12 @@ import { ladeZeiten, loescheZeiten } from './speicher.js' * ist sie abgeschaltet und die Eingaben bleiben noch 30 Tage. gleicheZeitenAboAb(now) ist der Baustein des Tagesjobs (angeschlossen in * Task 22); er kennt weder Schalter noch Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen. * - * Nur Dokumente mit unlockedBy 'abo' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und das Abo - * wird dann nicht einmal gelesen. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel): + * Nur Dokumente mit unlockedBy 'abo' oder 'projekt' (unlockedByVon) sind ans Abo gekoppelt; 'kauf' und 'team' fasst der Abgleich nie an, und + * das Abo wird dann nicht einmal gelesen. 'projekt' (Kundenansicht neu, P6) gilt wie 'abo', ausser in der Aufbauphase (E9): ein projekt-Dokument + * im Zustand setup oder active bleibt unberuehrt, solange das Abo nicht laeuft und der Kunde noch nie ein Abo hatte (ohneAboBisher): die + * Website ist noch im Bau. Mit dem ersten Abo gilt die Abo-Regel auch fuer projekt. Dokumente des Abo-Modells (istAboModell: Marker + * einrichtungAbrechnung) bekommen K40 und K41 statt K29 und K30 (ohne Preis) und kein Behalten (behalten.js); Gratis-Zeiten ohne Marker + * behalten K29, K30 und den Behalten-Weg. Je Dokument gilt (Abo gelesen mit aboLage, grosszügige Regel inklusive Wechsel-Regel): * * setup/active, Abo läuft nicht, aboEndeAt leer Schritt 1: aboEndeAt = now, K29 (Variante nach art; Frist = Berliner Tag von * aboEndeAt + 30 Tage) @@ -79,8 +85,10 @@ const portalFeatures = () => config.collections.portalFeatures const istLaufend = (doc) => doc.state === 'setup' || doc.state === 'active' const istAbgeschaltet = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND -/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */ -const istGekoppelt = (doc) => unlockedByVon(doc) === 'abo' && (istLaufend(doc) || istAbgeschaltet(doc)) +/** Ans Wartungs-Abo gekoppelt: von ihm freigeschaltet (abo oder, im Abo-Modell, projekt) und im laufenden Betrieb oder wegen des Abo-Endes abgeschaltet. */ +const istGekoppelt = (doc) => ABRECHNUNGEN.includes(unlockedByVon(doc)) && (istLaufend(doc) || istAbgeschaltet(doc)) +/** Aufbauphasen-Kandidat (E9): im Projekt eingebaut und noch im laufenden Betrieb; ob der Kunde je ein Abo hatte, entscheidet ohneAboBisher. */ +const istProjektImBetrieb = (doc) => unlockedByVon(doc) === 'projekt' && istLaufend(doc) /** Berliner Kalendertag eines Datumsfeldes; unlesbar ist ein Fehler (das Dokument wird übersprungen), nie "heute". */ function tagVon(wert, feld) { @@ -150,6 +158,8 @@ async function unterSperre(doc, ctx) { const frisch = await getDocument(portalFeatures(), doc.$id) if (!istGekoppelt(frisch)) return null // das Team war schneller (anderer Grund, anderer Zustand, nicht mehr im Abo) const lage = await aboLage(frisch.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist + // Aufbauphase (E9): ein im Projekt eingebautes Dokument bleibt, solange es nie ein Abo gab (frisch gelesen; ein Lesefehler wirft: nichts geschrieben) + if (istProjektImBetrieb(frisch) && !lage.laeuft && (await ohneAboBisher(frisch.customerId))) return null const aktion = entscheide(frisch, lage, now) const hatteMail = Boolean(frisch.aboEndeMailAt) // vor dem Leeren gelesen: wer nie vom Ende erfuhr, hört nicht von der Rückkehr @@ -255,7 +265,7 @@ async function sendeWiederMail(doc, einbau, ctx) { async function danach({ aktion, doc, lage, hatteMail }, ctx) { switch (aktion) { case 'k29': - await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis() }), ctx) + await sendeMitMarker(doc, ({ kunde, projekt }) => k29Mail({ kunde, projekt, art: lage.art, frist: mittags(fristTag(doc)), preis: preis(), modell: istAboModell(doc) }), ctx) return case 'k30': // Erst das Ersatz-Ticket, dann K30 mit Marker. Scheitert das Ticket, gibt es weder K30 noch Marker: der nächste Lauf wiederholt beides @@ -264,7 +274,7 @@ async function danach({ aktion, doc, lage, hatteMail }, ctx) { ctx.erg.fehler += 1 return } - await sendeMitMarker(doc, ({ kunde, projekt }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis() }), ctx, doc.disabledAt) + await sendeMitMarker(doc, ({ kunde, projekt }) => k30Mail({ kunde, projekt, loeschdatum: mittags(loeschTag(doc)), preis: preis(), modell: istAboModell(doc) }), ctx, doc.disabledAt) return case 'zurueck': { const einbau = await stosseNeubauAn(await ladeProjekt(doc.projectId)) @@ -302,9 +312,21 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { console.error(`[zeiten-abo] Abo-Lage eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`) return } - erg.geprueft += gekoppelt.length + // Aufbauphase (E9): im Projekt eingebaute Dokumente bleiben in Ruhe, solange das Abo nicht laeuft und der Kunde noch nie ein Abo hatte; + // gelesen wird das nur, wenn es ein solches Dokument gibt. Ein Lesefehler ueberspringt den Kunden wie beim Lesen der Abo-Lage. + let zuBewerten = gekoppelt + if (!lage.laeuft && gekoppelt.some(istProjektImBetrieb)) { + try { + if (await ohneAboBisher(customerId)) zuBewerten = gekoppelt.filter((doc) => !istProjektImBetrieb(doc)) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-abo] Abo-Verlauf eines Kunden nicht lesbar, ${gekoppelt.length} Funktion(en) übersprungen: ${fehlerText(err)}`) + return + } + } + erg.geprueft += zuBewerten.length - for (const doc of gekoppelt) { + for (const doc of zuBewerten) { // je Dokument gefangen (auch Entscheidung, Builder und Versand): ein kaputtes Datum stoppt weder den Kunden noch den Lauf try { if (!entscheide(doc, lage, now)) continue // Vorauswahl mit dem Memo: nichts zu tun kostet weder Sperre noch Abo-Abfrage diff --git a/server/services/zeiten/behalten.js b/server/services/zeiten/behalten.js index 4fc1fb0..fa821de 100644 --- a/server/services/zeiten/behalten.js +++ b/server/services/zeiten/behalten.js @@ -1,5 +1,6 @@ import { config } from '../../config.js' import { aboLage } from '../aboStatus.js' +import { istAboModell } from '../aboModell.js' import { getDocument, updateDocument } from '../appwriteAdmin.js' import { featureConfig } from '../featureRegistry.js' import { withLocks } from '../keyedLock.js' @@ -43,9 +44,10 @@ async function datenDa(featureId) { /** * Darf der Kunde die Funktion jetzt behalten? In Einrichtung oder aktiv: sobald das Ende des Abos erkannt ist (aboEndeAt). Abgeschaltet: * nur nach dem Abo-Ende (disabledReason abo_ende, nicht: Team, vor der Einrichtung, Kunde geloescht) und solange die Eingaben noch da sind. + * Nie fuer ein Dokument des Abo-Modells (istAboModell). */ async function behaltenMoeglich(doc) { - if (!doc) return false + if (!doc || istAboModell(doc)) return false // Abo-Modell (P6): kein Behalten gegen Zahlung, die Funktion endet mit dem Abo und kehrt mit einem neuen zurueck if (doc.state === 'setup' || doc.state === 'active') return Boolean(doc.aboEndeAt) if (doc.state === 'disabled' && doc.disabledReason === 'abo_ende') return datenDa(doc.$id) return false diff --git a/test/aboFunktionenKoexistenz.test.js b/test/aboFunktionenKoexistenz.test.js new file mode 100644 index 0000000..5b2679f --- /dev/null +++ b/test/aboFunktionenKoexistenz.test.js @@ -0,0 +1,78 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Koexistenz (P6 Task 10): Dokumente des Abo-Modells (unlockedBy UND einrichtungAbrechnung) gehören dem neuen Abgleich; der alte +// Terminplaner-Abgleich fasst sie nicht an. Zeiten koppeln auch projekt (außer in der Aufbauphase) und senden K40 und K41 ohne Preis. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + termineConfig: schemaKeys('termineConfig'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], + }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { gleicheAboAb, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js') +const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js') +const mails = mailSink() +setMailTransportForTests(mails) +after(() => setMailTransportForTests(null)) + +seedFixtures(aw) +// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht +const leere = async (...collections) => { + for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) +} +beforeEach(async () => { + await leere('portalFeatures', 'subscriptions', 'oeffnungszeiten', 'zeitenErinnerungen') + mails.sent.length = 0 +}) + +const modell = { unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' } +test('Terminplaner-Abgleich überspringt Abo-Modell-Dokumente in jedem Zustand, auch ohne Wartungs-Abo; Kauf-Dokumente werden weiter abgeschaltet', async () => { + // kein Abo; Abo-Modell: active, setup, disabled/abo_ende (ohne Marker); dazu ein gekaufter, aktiver Terminplaner + for (const [id, state, extra] of [['f_a', 'active', {}], ['f_s', 'setup', {}], ['f_d', 'disabled', { disabledReason: 'abo_ende', disabledAt: new Date().toISOString() }]]) aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state, enabled: state === 'active', ...modell, ...extra }) + aw.seed('portalFeatures', { $id: 'f_k', customerId: 'c1', projectId: 'p_k', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true }) + const r = await gleicheAboAb(new Date()); assert.deepEqual([r.abgeschaltet, r.ausgenommen], [1, 0]); assert.deepEqual(['f_a', 'f_s', 'f_d', 'f_k'].map((id) => aw.docs('portalFeatures').get(id).state), ['active', 'setup', 'disabled', 'disabled']) + assert.deepEqual((await endeTermineFuerKunde('c1')).beendet, []); assert.equal(mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject)).length, 1) // nur der Kauf +}) +const T0 = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000 // Schritt 1 erkennt das Abo-Ende, Schritt 2 schaltet nach der 30-Tage-Frist ab (ohne gespeicherte Zeiten, also ohne Ersatz-Ticket) +const zeit = (id, over) => aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'zeiten', siteKey: `zk${id}`.padEnd(16, '2').slice(0, 16), state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 't1', einschaltMailAt: '2026-10-01T09:05:00.000Z', ...over }) +const zdoc = (id) => aw.docs('portalFeatures').get(id) +const beendetesAbo = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'canceled' })) +const k40Mails = () => mails.sent.filter((m) => m.subject.startsWith('Dein Abo ist beendet – Öffnungszeiten live läuft noch bis')) +test('Zeiten: Abo-Modell (abo und projekt) bekommt K40 und K41 ohne Preis, Gratis-Zeiten ohne Marker weiter K29 und K30 mit 30 €', async () => { + beendetesAbo() // das Wartungs-Abo des Kunden ist beendet (mit einem Abo-Dokument: sonst gälte für projekt die Aufbauphase, siehe nächster Test) + zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }); zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_alt', { unlockedBy: 'abo' }) + await gleicheZeitenAboAb(T0) + const k40 = k40Mails(); const k29 = mails.sent.filter((m) => m.subject.startsWith('Dein Wartungs-Abo ist beendet')) + assert.deepEqual([k40.length, k29.length], [2, 1]); assert.ok(k40.every((m) => !/€|behalten/i.test(m.text))); assert.match(k29[0].text, /30 €/) + await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)) + assert.deepEqual(['z_abo', 'z_projekt', 'z_alt'].map((id) => zdoc(id).disabledReason), ['abo_ende', 'abo_ende', 'abo_ende']) + const k41 = mails.sent.filter((m) => m.subject === 'Öffnungszeiten live ist abgeschaltet'); assert.equal(k41.length, 3); assert.equal(k41.filter((m) => /30 €/.test(m.text)).length, 1) // nur das Gratis-Dokument nennt noch den Preis +}) +test('Zeiten, Aufbauphase (E9): projekt ohne jedes Abo bleibt unberührt, abo nicht; mit dem ersten beendeten Abo gilt die Abo-Regel auch für projekt', async () => { + zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }) // kein Abo-Dokument + const r = await gleicheZeitenAboAb(T0); assert.equal(r.fehler, 0) + assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), Boolean(zdoc('z_abo').aboEndeAt), k40Mails().length], [false, true, 1]) // nur z_abo erkennt ein Abo-Ende + await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)); assert.deepEqual([zdoc('z_projekt').state, zdoc('z_projekt').disabledReason ?? null], ['active', null]) // auch nach 31 Tagen + aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' })); await gleicheZeitenAboAb(T0); assert.equal(Boolean(zdoc('z_projekt').aboEndeAt), false) // abgebrochene Bestellung ist kein Abo + await leere('subscriptions'); beendetesAbo(); await gleicheZeitenAboAb(T0); assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), k40Mails().length], [true, 2]) +}) diff --git a/test/zeitenBehalten.test.js b/test/zeitenBehalten.test.js index 76ecd40..58aae74 100644 --- a/test/zeitenBehalten.test.js +++ b/test/zeitenBehalten.test.js @@ -800,3 +800,10 @@ test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufr assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false) assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`) }) + +test('Behalten (30 €) ist für Abo-Modell-Dokumente gesperrt (409 ohne Spur), für Gratis-Zeiten im selben Zustand möglich', async () => { + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + seedeAbgeschaltet({ einrichtungAbrechnung: 'abo' }); await mitZeiten() + const res = await behalten(koerper()); assert.deepEqual([res.status, res.body.error], [409, NICHT_MOEGLICH]); keineSpuren() + await updateDocument('portalFeatures', 'f_p1_zeiten', { einrichtungAbrechnung: '' }); assert.equal((await behalten(koerper())).status, 200) +})