From addb88d5fd967d932b4f331c9d3eee0e7271f573 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:32:14 +0000 Subject: [PATCH] =?UTF-8?q?Abo-Abgleich:=20Abo=20endet=20vor=20der=20Einri?= =?UTF-8?q?chtung=20=E2=87=92=20abgeschaltet,=20T19=20und=20K22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminplaner TP2 E5. Schritt 1b in gleicheAboAb: Dokumente im Zustand ordered oder setup werden bei fehlendem Wartungs-Abo unter feature-admin: nach frischem Lesen von Dokument und Abo mit Grund abo_ende_vor_einrichtung abgeschaltet. Die Meldelogik liegt in meldeAboEnde (auch für den Sofortpfad E9): bei bezahlter Einrichtung zuerst T19 über meldeErstattung (außerhalb jeder feature-admin-Sperre), dann K22 mit Block vor_einrichtung, dann der Marker aboEndeMailAt; scheitert T19, folgen weder K22 noch Marker und der nächste Tag wiederholt beides. Ein unbezahltes ordered-Dokument wird nur abgeschaltet. Schritt 2 holt auch abo_ende_vor_einrichtung nach. Die Stripe-Sitzung und das Ticket bleiben unangetastet. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/aboAbgleich.js | 117 +++++--- test/termineAboAbgleich.test.js | 376 ++++++++++++++++++++++++- 2 files changed, 450 insertions(+), 43 deletions(-) diff --git a/server/services/termine/aboAbgleich.js b/server/services/termine/aboAbgleich.js index f232bfa..2536813 100644 --- a/server/services/termine/aboAbgleich.js +++ b/server/services/termine/aboAbgleich.js @@ -3,7 +3,7 @@ import { aboLage, aboMemo } from '../aboStatus.js' import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendCustomerMail } from '../mailer.js' -import { customerOf, disable, projectName } from '../siteFeatures.js' +import { customerOf, disable, meldeErstattung, projectName } from '../siteFeatures.js' import { loeschzeit } from './frist.js' import { pruefeNow, zeitMs } from './util.js' import { datumKurz } from './zeit.js' @@ -14,25 +14,33 @@ import { datumKurz } from './zeit.js' * * Schritt 1 aktive, bezahlte Terminplaner (state active, unlockedByPurchase true) ohne laufendes Wartungs-Abo => abschalten * (disabledReason 'abo_ende'; ab disabledAt laeuft die 30-Tage-Frist bis zur Loeschung, jobs.js) - * Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' ohne aboEndeMailAt die Mail K22 nachholen (auch fuer die - * eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem Versand gesetzt + * Schritt 1b Einrichtungen, die das Abo-Ende erreicht (state ordered oder setup) => abschalten (disabledReason + * 'abo_ende_vor_einrichtung'); eine bezahlte Einrichtung wird dem Team als Erstattung gemeldet (T19), der Kunde bekommt K22 + * mit dem Block vor_einrichtung. Die offene Stripe-Sitzung wird nicht storniert, das Ticket nicht angefasst. + * Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' oder (bezahlt) 'abo_ende_vor_einrichtung' ohne aboEndeMailAt + * die Meldung nachholen (auch fuer die eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem + * Versand gesetzt * * Regeln (Block E): * - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird - * fuer die Vorauswahl nur einmal gelesen. Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz: unlockedByPurchase - * nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage. + * fuer die Vorauswahl nur einmal gelesen. Aktive Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz: + * unlockedByPurchase nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage. * - Geschrieben wird nur unter der Sperre feature-admin: (wie die Team-Route) und nach frischem Lesen von Dokument UND * Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten. + * Die Mails (T19, K22) und die Marker folgen NACH der Sperre (meldeAboEnde), denn T19 laeuft ueber meldeErstattung mit eigener + * Sperre feature-meldung: und darf nie innerhalb einer feature-admin- oder feature-paid-Sperre gerufen werden. * - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden * uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen. - * - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt K22 fehl, wiederholt der naechste Tag den Versand mit + * - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt eine Mail fehl, wiederholt der naechste Tag den Versand mit * denselben Daten (Enddatum, Loeschdatum), die Loeschfrist waechst nicht. * - Nur die Gruende aus siteFeatures.DISABLED_REASONS gelten. 'team', 'kunde_geloescht' und Altbestand ohne Grund fasst der - * Abgleich nie an; 'abo_ende_vor_einrichtung' gehoert zu E5, ordered/setup ebenfalls (sie zaehlen hier nur als geprueft). + * Abgleich nie an. */ const GRUND = 'abo_ende' const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet' +const GRUND_VOR = 'abo_ende_vor_einrichtung' +const NOTIZ_VOR = 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g @@ -49,13 +57,19 @@ const istBewertet = (doc) => const portalFeatures = () => config.collections.portalFeatures /** - * Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie K22 einzeln): - * K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now. - * Grund 'abo_ende': Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen': das - * Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach. - * Der Aufrufer liest `doc` frisch und haelt die Sperre feature-admin: (diese Funktion sperrt nicht, damit der Sofortpfad - * sie aus seiner eigenen Sperre rufen kann). Ohne Kunde oder ohne E-Mail gibt es nichts zu senden: der Marker wird gesetzt, damit - * es kein Wiederholen gibt. Wirft nie. + * Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2, Schritt 1b und der Sofortpfad E9 rufen nur ihn, nie T19 oder + * K22 einzeln). Reihenfolge: (T19,) K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now. + * - Grund 'abo_ende': K22 mit Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen': + * das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag von disabledAt, loeschdatum = 30 Tage danach. + * - Grund 'abo_ende_vor_einrichtung': ein unbezahltes Dokument (unlockedByPurchase nicht true, die Bestellung wartet noch auf die + * Zahlung) hat nichts zu erstatten: weder T19 noch K22 noch Marker, kein Schreibzugriff (kommt die Zahlung spaeter, meldet + * siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team + * (meldeErstattung, T19, genau einmal je Dokument, auch ohne erreichbaren Kunden), dann K22 mit Block `vor_einrichtung` (ohne + * `daten`: es gibt nichts zu loeschen). Scheitert T19 (weder gesendet noch schon gemeldet), folgen weder K22 noch Marker: der + * naechste Tag wiederholt beides in dieser Reihenfolge. + * Der Aufrufer liest `doc` frisch und haelt KEINE feature-admin- und keine feature-paid-Sperre (meldeErstattung sperrt selbst + * feature-meldung:). Ohne Kunde oder ohne E-Mail gibt es keine K22 zu senden: der Marker wird gesetzt, damit es kein + * Wiederholen gibt. Wirft nie. * @param {object} doc portalFeatures-Dokument (disabled) * @param {{ lage: { art: string }, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen * @returns {Promise} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker @@ -63,11 +77,20 @@ const portalFeatures = () => config.collections.portalFeatures */ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { const nowMs = pruefeNow(now, 'meldeAboEnde') - if (doc.disabledReason !== GRUND || doc.aboEndeMailAt) return true // E5 ergaenzt abo_ende_vor_einrichtung; mit Marker ist alles gesagt + const vorEinrichtung = doc.disabledReason === GRUND_VOR + if ((doc.disabledReason !== GRUND && !vorEinrichtung) || doc.aboEndeMailAt) return true // mit Marker ist alles gesagt + if (vorEinrichtung && doc.unlockedByPurchase !== true) return true // nichts zu erstatten, nichts zu sagen: die Zahlung kommt eventuell noch const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() }) let gesendet = false try { const customer = await customerOf(doc.customerId) + if (vorEinrichtung) { + const erstattung = await meldeErstattung(doc, { customer, nachAboEnde: false }) + if (!erstattung.sent && !erstattung.schonGemeldet) { + console.error(`[termine-abo] T19 für Funktion ${doc.$id} nicht gesendet, der nächste Lauf wiederholt (K22 folgt danach)`) + return false + } + } if (!customer?.email) { console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt') await setzeMarker() @@ -75,6 +98,9 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { } const projekt = (await projectName(doc.projectId)) || 'Dein Projekt' const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten + const grundBlock = vorEinrichtung + ? { vor_einrichtung: true } + : { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true } await sendCustomerMail(customer.email, { template: 'K22', values: { @@ -85,45 +111,68 @@ export async function meldeAboEnde(doc, { lage, now, onGesendet = null }) { preis: config.features.termine.priceLabel, portal_url: `${config.publicBaseUrl}/dashboard.html#termine`, }, - blocks: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true }, + blocks: grundBlock, }) gesendet = true onGesendet?.() await setzeMarker() return true } catch (err) { - console.error(`[termine-abo] K22 für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`) + console.error(`[termine-abo] Meldung für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`) return false } } /** - * Ein Dokument unter seiner Sperre: frisch lesen, frisch das Abo lesen, erst dann schreiben. - * Dokument war in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten, dann K22. - * Dokument war in der Liste abgeschaltet (abo_ende) => noch so und ohne Marker, Abo laeuft weiter nicht => K22 nachholen. + * Was der Abgleich mit einem Dokument tut, das er aus der Liste bekommt (null = nichts): + * 'abschalten' aktiv und vom Kauf freigeschaltet (Grund abo_ende), 'einrichtung' ordered/setup (Grund abo_ende_vor_einrichtung), + * 'nachholen' abgeschaltet mit dem Grund abo_ende oder (bezahlt) abo_ende_vor_einrichtung ohne Marker. Ein unbezahltes, + * abgeschaltetes abo_ende_vor_einrichtung-Dokument hat nichts nachzuholen. + */ +function aktionFuer(doc) { + if (doc.state === 'active') return doc.unlockedByPurchase === true ? 'abschalten' : null + if (doc.state === 'ordered' || doc.state === 'setup') return 'einrichtung' + if (doc.state !== 'disabled' || doc.aboEndeMailAt) return null + if (doc.disabledReason === GRUND) return 'nachholen' + return doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true ? 'nachholen' : null +} + +/** + * Ein Dokument: unter der Sperre feature-admin: frisch lesen, frisch das Abo lesen, erst dann schreiben; die Meldung + * (meldeAboEnde) folgt nach der Sperre. + * 'abschalten': in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten. + * 'einrichtung': in der Liste ordered/setup => noch ordered/setup (das Team kann inzwischen aktiviert haben), Abo laeuft nicht + * => abschalten (abo_ende_vor_einrichtung). Eine bezahlte Bestellung, die zwischen Liste und Sperre vom Webhook auf setup + * gestellt wurde, liefert disable() bereits als bezahlt zurueck. + * 'nachholen': in der Liste abgeschaltet mit Grund und ohne Marker => noch so und ohne Marker, Abo laeuft weiter nicht. */ async function bearbeiteDokument(doc, ctx) { const { erg, now } = ctx - const warAktiv = doc.state === 'active' - await withLocks([`feature-admin:${doc.projectId}`], async () => { + const aktion = aktionFuer(doc) + const grund = aktion === 'einrichtung' ? GRUND_VOR : GRUND + const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => { const frisch = await getDocument(portalFeatures(), doc.$id) - if (warAktiv) { - if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return // das Team war schneller - } else if (frisch.state !== 'disabled' || frisch.disabledReason !== GRUND || frisch.aboEndeMailAt) { - return + if (aktion === 'abschalten') { + if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return null // das Team war schneller + } else if (aktion === 'einrichtung') { + if (frisch.state !== 'ordered' && frisch.state !== 'setup') return null // aktiviert oder abgeschaltet + } else if (frisch.state !== 'disabled' || frisch.disabledReason !== doc.disabledReason || frisch.aboEndeMailAt) { + return null } const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist - if (lage.laeuft) return // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6) + if (lage.laeuft) return null // das Abo ist zurueck (Schritt 2 und die Wiedereinschaltung gehoeren E6) let zuMelden = frisch - if (warAktiv) { - zuMelden = await disable(frisch.$id, { reason: GRUND, adminNote: NOTIZ }) - if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== GRUND) return // jemand war zwischen Lesen und Schreiben schneller + if (aktion !== 'nachholen') { + zuMelden = await disable(frisch.$id, { reason: grund, adminNote: grund === GRUND_VOR ? NOTIZ_VOR : NOTIZ }) + if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== grund) return null // jemand war zwischen Lesen und Schreiben schneller erg.abgeschaltet += 1 } - const fertig = await meldeAboEnde(zuMelden, { lage, now, onGesendet: () => (erg.mails += 1) }) - if (!fertig) erg.fehler += 1 + return { zuMelden, lage } }) + if (!gefunden) return + const fertig = await meldeAboEnde(gefunden.zuMelden, { lage: gefunden.lage, now, onGesendet: () => (erg.mails += 1) }) + if (!fertig) erg.fehler += 1 } async function bearbeiteKunde(customerId, dokumente, ctx) { @@ -144,9 +193,7 @@ async function bearbeiteKunde(customerId, dokumente, ctx) { if (lage.laeuft) return // laeuft weiter (E6 schaltet abgeschaltete wieder ein) for (const doc of bewertet) { - const aktiv = doc.state === 'active' - const nachholen = doc.state === 'disabled' && doc.disabledReason === GRUND && !doc.aboEndeMailAt - if (!aktiv && !nachholen) continue + if (!aktionFuer(doc)) continue try { await bearbeiteDokument(doc, ctx) } catch (err) { diff --git a/test/termineAboAbgleich.test.js b/test/termineAboAbgleich.test.js index 21917e8..ef38ea6 100644 --- a/test/termineAboAbgleich.test.js +++ b/test/termineAboAbgleich.test.js @@ -5,9 +5,10 @@ import { mailSink } from './support/mailSink.js' import { schemaKeys } from './support/schema.js' import { useTestEnv } from './support/testEnv.js' -// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4): aktive Terminplaner ohne laufendes -// Wartungs-Abo abschalten (Schritt 1) und die Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von -// Appwrite mit Schema und Standard-Limit 25, Mails in der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). +// Abo-Abgleich des Terminplaners (server/services/termine/aboAbgleich.js, Terminplaner TP2 E4/E5): aktive Terminplaner ohne laufendes +// Wartungs-Abo abschalten (Schritt 1), Einrichtungen, die das Abo-Ende erreicht, abschalten und erstatten (Schritt 1b, E5) und die +// Mail K22 nachholen (Schritt 2). Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, Mails in +// der Senke, feste Zeit JETZT. Stripe wird nie erreicht (Port 1). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), @@ -25,7 +26,7 @@ const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixt const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { withLocks } = await import('../server/services/keyedLock.js') -const { disable } = await import('../server/services/siteFeatures.js') +const { disable, activateClosedTickets, handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js') const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js') after(() => setMailTransportForTests(null)) @@ -42,7 +43,7 @@ let sink let log beforeEach(async () => { - for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects']) { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'customers', 'websiteProjects', 'workorders']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } aw.resetListCalls() @@ -85,6 +86,26 @@ function legeFunktion(nr = 1, over = {}, { projekt = 'Praxis Muster', kunde = KU } const funktion = (id = F1) => aw.docs('portalFeatures').get(id) +const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung' +const K22_BETREFF = /^Dein Terminplaner wurde beendet/ + +/** Bezahlte Einrichtung im Aufbau (setup): Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711 (Dokument t_ in workorders). */ +function legeAufbau(nr = 1, over = {}) { + aw.seed('workorders', { $id: `t_${nr}`, woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' }) + return legeFunktion(nr, { + state: 'setup', + enabled: false, + stripeSessionId: 'cs_test_1', + stripeInvoiceId: 'in_1', + invoiceUrl: 'https://invoice.stripe.test/in_1', + ticketId: `t_${nr}`, + ...over, + }) +} +/** Bestellung ohne Zahlung (ordered): die Stripe-Sitzung ist offen, unlockedByPurchase fehlt. */ +const legeBestellung = (nr = 1, over = {}) => + legeFunktion(nr, { state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1', ...over }) + /** Wartungs-Abo von KUNDE (ersetzt nichts: ein einziges Abo-Dokument je Test). */ const wartung = (status, over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status, ...over })) const hosting = (status = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, customerId: KUNDE, status, ...over })) @@ -103,6 +124,8 @@ const ERGEBNIS_LEER = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, au const ergebnis = (over) => ({ ...ERGEBNIS_LEER, ...over }) const mails = () => sink.sent +const t19 = () => mails().filter((m) => m.subject === T19_BETREFF) +const k22 = () => mails().filter((m) => K22_BETREFF.test(m.subject)) const abfragenAufAbos = () => aw.listCalls('subscriptions').length /** @@ -312,9 +335,10 @@ test('Entwuerfe (off) werden nicht gelesen: eine Abfrage mit featureKey termine assert.equal(abfragenAufAbos(), 0) }) -test('ordered, setup und abo_ende_vor_einrichtung zaehlen als geprueft, E4 aendert daran nichts (E5 erweitert)', async () => { - legeFunktion(1, { state: 'ordered', enabled: false }) - legeFunktion(2, { state: 'setup', enabled: false }) +test('Laeuft das Abo: ordered, setup und ein abgeschaltetes abo_ende_vor_einrichtung zaehlen als geprueft und bleiben unberuehrt (kein T19, kein K22)', async () => { + wartung('active') + legeBestellung(1) + legeAufbau(2) legeFunktion(3, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) const vorher = [1, 2, 3].map((nr) => ({ ...funktion(funktionsId(nr)) })) assert.deepEqual(await laufe(), ergebnis({ geprueft: 3 })) @@ -573,3 +597,339 @@ test('meldeAboEnde: unbekannter Projektname faellt auf "Dein Projekt" zurueck, e assert.ok(!funktion(id2).aboEndeMailAt) assert.equal(mails().length, 1) }) + +// ---------------------------------------------------------------- Schritt 1b: Abo endet vor der Einrichtung (E5) + +test('Bezahlt im Aufbau (setup, Ticket) ohne Abo: abgeschaltet, ein T19 an das Team, K22 vor_einrichtung, Ticket bleibt offen, am Folgetag nichts', async () => { + legeAufbau(1) + const ticketVorher = { ...aw.docs('workorders').get('t_1') } + + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.enabled, false) + assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') + assert.equal(ms(doc.disabledAt), JETZT.getTime()) + assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung') + assert.equal(doc.purchaseStatus, 'paid', 'die Zahlung bleibt gebucht') + assert.equal(doc.zahlungMeldung, 'gemeldet', 'T19 ist raus') + assert.equal(doc.stripeSessionId, 'cs_test_1') + assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker erst nach T19 und K22') + + // T19: genau einmal, an das Team, mit Kunde, Projekt, Sitzung, Rechnungslink und Ticketnummer + assert.equal(t19().length, 1) + const mailT19 = t19()[0] + assert.deepEqual(mailT19.to.map((a) => a.address).sort(), [...config.chatNotify.emails].sort()) + assert.match(mailT19.text, /Silke Müller/) + assert.match(mailT19.text, /Praxis Muster/) + assert.match(mailT19.text, /cs_test_1/) + assert.ok(mailT19.html.includes('https://invoice.stripe.test/in_1'), 'Rechnungslink') + assert.match(mailT19.text, /WO-4711/) + assert.match(mailT19.text, /Ticket noch offen/) + assert.doesNotMatch(mailT19.text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende') + + // K22 an den Kunden: Block vor_einrichtung mit Preis, ohne den Kasten "Was jetzt gilt" + assert.equal(k22().length, 1) + const mailK22 = k22()[0] + assert.deepEqual(mailK22.to.map((a) => a.address), ['silke@example.com']) + assert.equal(mailK22.subject, 'Dein Terminplaner wurde beendet – Praxis Muster') + assert.match(mailK22.text, /Dein Wartungs-Abo wurde am 05\.10\.2026 beendet, noch vor der Einrichtung Deines Terminplaners/) + assert.match(mailK22.text, /erstatten Dir die Einrichtung \(149 €\) vollständig/) + assert.doesNotMatch(mailK22.text, /Was jetzt gilt|löschen wir|Hosting-Abo|weil Dein Wartungs-Abo/, 'kein Loeschkasten, nicht die anderen Gruende') + assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22') + + // das Ticket wird nicht angefasst + assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher) + assert.equal(aw.docs('workorders').get('t_1').status, 'Open') + + // zweiter Lauf: weder T19 noch K22, nichts geaendert + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(t19().length, 1) + assert.equal(k22().length, 1) +}) + +test('T19 schlaegt fehl: abgeschaltet, kein Marker, kein K22, fehler; der naechste Lauf sendet T19 und danach K22, spaetere Stripe-Ereignisse kein zweites T19', async () => { + legeAufbau(1) + setMailTransportForTests({ + sendMail: async (mail) => { + if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') + return sink.sendMail(mail) + }, + }) + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) + let doc = funktion() + assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem') + assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') + assert.ok(!doc.zahlungMeldung, 'Marker gemeldet erst nach gesendetem T19') + assert.ok(!doc.aboEndeMailAt, 'Marker aboEndeMailAt erst nach T19 und K22') + assert.equal(doc.purchaseStatus, 'paid') + assert.equal(mails().length, 0, 'auch K22 ging nicht (T19 zuerst)') + const disabledAt = doc.disabledAt + + setMailTransportForTests(sink) + const naechster = plus(JETZT, TAG) + assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(t19().length, 1) + assert.equal(k22().length, 1) + assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22') + assert.match(k22()[0].text, /am 05\.10\.2026 beendet/, 'Enddatum aus disabledAt, nicht vom Folgetag') + doc = funktion() + assert.equal(doc.zahlungMeldung, 'gemeldet') + assert.equal(doc.purchaseStatus, 'paid') + assert.equal(ms(doc.aboEndeMailAt), naechster.getTime()) + assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht') + + assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }), 'danach nie wieder') + assert.equal(mails().length, 2) + + // ein spaeteres Stripe-Ereignis zur selben Sitzung bucht nichts und meldet nichts mehr + await handleFeatureCheckoutEvent({ + type: 'checkout.session.async_payment_succeeded', + data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } }, + }) + assert.equal(t19().length, 1, 'kein zweites T19') + assert.equal(mails().length, 2) + assert.equal(funktion().state, 'disabled') +}) + +test('T19 schlaegt fehl und K22 wuerde gehen: K22 wird nicht vorgezogen (Reihenfolge T19, K22, Marker), fehler zaehlt je Lauf', async () => { + legeAufbau(1) + setMailTransportForTests({ + sendMail: async (mail) => { + if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen') + return sink.sendMail(mail) + }, + }) + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 })) + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, fehler: 1 }), 'auch der zweite Tag scheitert an T19') + assert.equal(k22().length, 0) + assert.ok(!funktion().aboEndeMailAt) + assert.ok(!funktion().zahlungMeldung) +}) + +test('Unbezahltes ordered ohne Abo: abgeschaltet, kein T19, kein K22, kein Marker, Sitzung unveraendert, auch am Folgetag nichts', async () => { + legeBestellung(1) + assert.deepEqual(await laufe(JETZT), ergebnis({ geprueft: 1, abgeschaltet: 1 })) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.enabled, false) + assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') + assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung') + assert.ok(!doc.aboEndeMailAt, 'kein Marker') + assert.ok(!doc.zahlungMeldung) + assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Stripe-Sitzung wird nicht angefasst') + assert.equal(doc.purchaseStatus, 'ordered') + assert.equal(doc.unlockedByPurchase, false) + assert.equal(mails().length, 0) + + const vorher = { ...funktion() } + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher, 'kein Schreibzugriff') + assert.equal(mails().length, 0) +}) + +test('Unbezahltes ordered, danach trifft die Zahlung ein: das Webhook-Ereignis meldet T19 genau einmal (nach dem Abo-Ende), der naechste Lauf sendet K22 vor_einrichtung', async () => { + legeBestellung(1) + await laufe(JETZT) + assert.equal(mails().length, 0) + + await handleFeatureCheckoutEvent({ + type: 'checkout.session.completed', + data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } }, + }) + assert.equal(t19().length, 1) + assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/) + assert.equal(funktion().state, 'disabled') + assert.equal(funktion().unlockedByPurchase, true) + assert.equal(funktion().zahlungMeldung, 'gemeldet') + assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich') + + assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 })) + assert.equal(t19().length, 1, 'kein zweites T19') + assert.equal(k22().length, 1) + assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) + assert.ok(funktion().aboEndeMailAt) + assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 2) +}) + +test('setup ohne ticketId ("Ticket fehlt"): T19 ohne den Block ticket_offen, K22 vor_einrichtung', async () => { + legeAufbau(1, { ticketId: '' }) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(t19().length, 1) + assert.doesNotMatch(t19()[0].text, /Ticket noch offen/) + assert.doesNotMatch(t19()[0].text, /WO-4711/) + assert.equal(k22().length, 1) + assert.equal(funktion().zahlungMeldung, 'gemeldet') +}) + +for (const status of ['past_due', 'trialing', 'active']) { + test(`Abo ${status}: ordered und setup bleiben unberuehrt, keine Mail`, async () => { + legeBestellung(1) + legeAufbau(2) + wartung(status) + const vorher = [1, 2].map((nr) => ({ ...funktion(funktionsId(nr)) })) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 2 })) + assert.deepEqual([1, 2].map((nr) => funktion(funktionsId(nr))), vorher) + assert.equal(mails().length, 0) + }) +} + +test('Lesefehler beim Abo: ordered und setup bleiben unberuehrt, fehler 1; der naechste Lauf schaltet ab (auch beim Fehler der frischen Pruefung)', async () => { + legeBestellung(1) + legeAufbau(2) + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + assert.deepEqual(await laufe(), ergebnis({ fehler: 1 })) + assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup']) + assert.equal(mails().length, 0) + + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, after: 1 }) // Vorauswahl ok, die frische Pruefung des ersten Dokuments scheitert + const zweiter = await laufe(plus(JETZT, TAG)) + assert.equal(zweiter.fehler, 1) + assert.equal(zweiter.abgeschaltet, 1, 'das andere Dokument kommt dran') + + const dritter = await laufe(plus(JETZT, 2 * TAG)) + assert.equal(dritter.fehler, 0) + assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['disabled', 'disabled']) + assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung') + assert.equal(k22().length, 1) +}) + +test('Rennen: das Abo kommt zwischen Vorauswahl und Sperre zurueck - ordered und setup bleiben, nichts wird gesendet', async () => { + legeBestellung(1) + legeAufbau(2) + const erg = await mitEinwurf(liste('subscriptions'), async () => wartung('active'), () => laufe()) + assert.deepEqual(erg, ergebnis({ geprueft: 2 })) + assert.deepEqual([funktion(funktionsId(1)).state, funktion(funktionsId(2)).state], ['ordered', 'setup']) + assert.equal(mails().length, 0) +}) + +test('Rennen: das Team aktiviert zwischen Liste und Sperre - das frisch gelesene Dokument ist active, Schritt 1b greift nicht', async () => { + legeAufbau(1) + const erg = await mitEinwurf( + liste('portalFeatures'), + async () => aw.seed('portalFeatures', { ...funktion(), state: 'active', enabled: true }), + () => laufe(), + ) + assert.deepEqual(erg, ergebnis({ geprueft: 1 })) + assert.equal(funktion().state, 'active') + assert.equal(funktion().disabledReason, undefined) + assert.equal(mails().length, 0) +}) + +test('Review Focus 1, nie ohne Spur: das Ticket wird nach dem Abo-Ende geschlossen - activateClosedTickets aktiviert das abgeschaltete Dokument nicht', async () => { + legeAufbau(1) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 })) + assert.equal(t19().length, 1, 'die Spur: T19 mit dem offenen Ticket') + + aw.seed('workorders', { ...aw.docs('workorders').get('t_1'), status: 'Closed' }) + assert.deepEqual(await activateClosedTickets({ now: plus(JETZT, TAG) }), []) + const doc = funktion() + assert.equal(doc.state, 'disabled') + assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung') + assert.equal(doc.enabled, false) + assert.equal(mails().length, 2, 'keine weitere Mail') +}) + +test('abo_ende_vor_einrichtung-Dokument mit Abo-Rueckkehr: bleibt wie es ist (die Rueckkehr gilt nur fuer abo_ende)', async () => { + legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -TAG).toISOString() }) + wartung('active') + const vorher = { ...funktion() } + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 0) +}) + +test('Unbezahltes, abgeschaltetes abo_ende_vor_einrichtung-Dokument wird taeglich uebergangen: kein Schreibzugriff, keine Mail', async () => { + legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: plus(JETZT, -3 * TAG).toISOString() }) + const vorher = { ...funktion() } + assert.deepEqual(await laufe(), ergebnis({ geprueft: 1 })) + assert.deepEqual(funktion(), vorher) + assert.equal(mails().length, 0) + assert.equal(abfragenAufAbos(), 1, 'nur die Vorauswahl: fuer das Dokument ohne Aufgabe gibt es keine frische Pruefung unter der Sperre') +}) + +test('Mehrere Projekte: bezahlter Aufbau, unbezahlte Bestellung und aktiver Terminplaner eines Kunden - je Dokument das Richtige', async () => { + legeFunktion(1, {}, { projekt: 'Projekt Alpha' }) // aktiv + legeAufbau(2) + legeBestellung(3) + assert.deepEqual(await laufe(), ergebnis({ geprueft: 3, abgeschaltet: 3, mails: 2 })) + assert.equal(funktion(funktionsId(1)).disabledReason, 'abo_ende') + assert.equal(funktion(funktionsId(2)).disabledReason, 'abo_ende_vor_einrichtung') + assert.equal(funktion(funktionsId(3)).disabledReason, 'abo_ende_vor_einrichtung') + assert.equal(t19().length, 1, 'T19 nur fuer die bezahlte Einrichtung') + assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und den bezahlten Aufbau (vor_einrichtung)') + assert.ok(!funktion(funktionsId(3)).aboEndeMailAt) +}) + +// ---------------------------------------------------------------- meldeAboEnde fuer abo_ende_vor_einrichtung (der Aufruf, den E9 nutzt) + +test('meldeAboEnde direkt: bezahltes Dokument mit Grund abo_ende_vor_einrichtung - ein T19, ein K22 vor_einrichtung, Marker, true; zweiter Aufruf nichts', async () => { + const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) + const lage = { laeuft: false, art: 'beendet', endedAt: null } + let gesendet = 0 + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT, onGesendet: () => (gesendet += 1) }), true) + assert.equal(t19().length, 1) + assert.equal(k22().length, 1) + assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/) + assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) + assert.equal(gesendet, 1, 'onGesendet zaehlt K22') + assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime()) + assert.equal(funktion(id).zahlungMeldung, 'gemeldet') + assert.equal(funktion(id).purchaseStatus, 'paid') + + assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) + assert.equal(mails().length, 2, 'zweiter Aufruf: weder T19 noch K22') + assert.equal(ms(funktion(id).aboEndeMailAt), JETZT.getTime(), 'Marker unveraendert') +}) + +test('meldeAboEnde direkt: unbezahltes ordered-Dokument - weder T19 noch K22 noch Marker, true, Dokument unveraendert', async () => { + const id = legeBestellung(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) + const vorher = { ...funktion(id) } + const lage = { laeuft: false, art: 'beendet', endedAt: null } + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) + assert.equal(mails().length, 0) + assert.deepEqual(funktion(id), vorher) +}) + +test('meldeAboEnde direkt: T19 scheitert - false, kein K22, kein Marker; der naechste Aufruf sendet T19 und K22 in dieser Reihenfolge', async () => { + const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) + const lage = { laeuft: false, art: 'beendet', endedAt: null } + setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false) + assert.ok(!funktion(id).zahlungMeldung) + assert.ok(!funktion(id).aboEndeMailAt) + + setMailTransportForTests(sink) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) + assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false]) + assert.ok(funktion(id).aboEndeMailAt) +}) + +test('meldeAboEnde direkt: T19 war schon draussen (gemeldet) - kein zweites T19, K22 und Marker folgen; K22 scheitert - Marker leer, T19 nicht wiederholt', async () => { + const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString(), zahlungMeldung: 'gemeldet' }) + const lage = { laeuft: false, art: 'beendet', endedAt: null } + setMailTransportForTests({ sendMail: async () => { throw new Error('Verbindung abgebrochen') } }) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), false, 'K22 scheitert') + assert.ok(!funktion(id).aboEndeMailAt) + + setMailTransportForTests(sink) + assert.equal(await meldeAboEnde(funktion(id), { lage, now: plus(JETZT, TAG) }), true) + assert.equal(t19().length, 0, 'T19 war schon gemeldet') + assert.equal(k22().length, 1) + assert.ok(funktion(id).aboEndeMailAt) +}) + +test('meldeAboEnde direkt: bezahlt, aber Kunde nicht mehr vorhanden - T19 geht trotzdem an das Team, kein K22, Marker gesetzt', async () => { + const id = legeAufbau(1, { state: 'disabled', disabledReason: 'abo_ende_vor_einrichtung', disabledAt: JETZT.toISOString() }) + await deleteDocument('customers', KUNDE) + const lage = { laeuft: false, art: 'beendet', endedAt: null } + assert.equal(await meldeAboEnde(funktion(id), { lage, now: JETZT }), true) + assert.equal(t19().length, 1, 'das Geld hat eine Spur, auch ohne Kunden') + assert.equal(k22().length, 0) + assert.equal(funktion(id).zahlungMeldung, 'gemeldet') + assert.ok(funktion(id).aboEndeMailAt, 'kein Wiederholen') +})